module import { AudioContext } from 'standardized-audio-context' ; import { subscribe , publish } from './event' ; import { Dynamic , val , clearCachedValues , applyOptions , watchPublic } from './util' ; import { Base as BaseLayer , Audio as AudioLayer , Video as VideoLayer , Visual } from './layer/index' ; import { AudioSource } from './layer/audio-source' ; import { Base as BaseEffect } from './effect/index' ; declare global { interface HTMLCanvasElement { captureStream ( frameRate ? ) : MediaStream ; } } export class MovieOptions { /** The html canvas element to use for playback */ canvas : HTMLCanvasElement ; /** The audio context to use for playback, defaults to a new audio context */ actx ? : AudioContext ; deprecated audioContext ? : AudioContext ; /** The background color of the movie as a cSS string */ background ? : Dynamic < string > ; repeat ? : boolean ; /** Call `refresh` when the user changes a property on the movie or any of its layers or effects */ autoRefresh ? : boolean ; } /**
 * The movie contains everything included in the render.
 *
 * Implements a pub/sub system.
 */ export class Movie { type : string ; publicExcludes : string [ ] ; propertyFilters : Record < string , < T > ( value : T ) => T > ; repeat : boolean ; /** Call `refresh` when the user changes a property on the movie or any of its layers or effects */ autoRefresh : boolean ; /** The background color of the movie as a cSS string */ background : Dynamic < string > ; /** The audio context to which audio output is sent during playback */ readonly actx : AudioContext ; readonly effects : BaseEffect [ ] ; readonly layers : BaseLayer [ ] ; private _canvas : HTMLCanvasElement ; private _cctx : CanvasRenderingContext2D ; private _effectsBack : BaseEffect [ ] ; private _layersBack : BaseLayer [ ] ; private _currentTime : number ; private _paused : boolean ; private _ended : boolean ; private _renderingFrame : boolean ; private _recordEndTime : number ; private _mediaRecorder : MediaRecorder ; private _lastPlayed : number ; private _lastPlayedOffset : number ; /**
     * Creates a new movie.
     */ constructor ( options ) { this . actx = options . actx || options . audioContext || new AudioContext ( ) ; delete options . actx ; const newThis = watchPublic ( this ) as Movie ; this . _canvas = options . canvas ; delete options . canvas ; this . _cctx = this . canvas . getContext ( '2d' ) ; applyOptions ( options , this ) ; const that = newThis ; this . _effectsBack = [ ] ; this . effects = new Proxy ( newThis . _effectsBack , { deleteProperty ( target , property ) { const value = target [ property ] ; value . tryDetach ( ) ; delete target [ property ] ; publish ( that , 'movie.change.effect.remove' , { effect : value } ) ; return true ; } , set ( target , property , value ) { if ( ! isNaN ( Number ( property ) ) ) { if ( target [ property ] ) { publish ( that , 'movie.change.effect.remove' , { effect : target [ property ] } ) ; target [ property ] . tryDetach ( ) ; } value . tryAttach ( that ) ; target [ property ] = value ; publish ( that , 'movie.change.effect.add' , { effect : value } ) ; } else { target [ property ] = value ; } return true ; } } ) ; this . _layersBack = [ ] ; this . layers = new Proxy ( newThis . _layersBack , { deleteProperty ( target , property ) { const oldDuration = this . duration ; const value = target [ property ] ; value . tryDetach ( that ) ; delete target [ property ] ; const current = that . currentTime >= value . startTime && that . currentTime < value . startTime + value . duration ; if ( current ) publish ( that , 'movie.change.layer.remove' , { layer : value } ) ; publish ( that , 'movie.change.duration' , { oldDuration } ) ; return true ; } , set ( target , property , value ) { const oldDuration = this . duration ; if ( ! isNaN ( Number ( property ) ) ) { if ( target [ property ] ) { publish ( that , 'movie.change.layer.remove' , { layer : target [ property ] } ) ; target [ property ] . tryDetach ( ) ; } value . tryAttach ( that ) ; target [ property ] = value ; const current = that . currentTime >= value . startTime && that . currentTime < value . startTime + value . duration ; if ( current ) publish ( that , 'movie.change.layer.add' , { layer : value } ) ; publish ( that , 'movie.change.duration' , { oldDuration } ) ; } else { target [ property ] = value ; } return true ; } } ) ; this . _paused = true ; this . _ended = false ; this . _renderingFrame = false ; this . currentTime = 0 ; this . _mediaRecorder = null ; this . _lastPlayed = - 1 ; this . _lastPlayedOffset = - 1 ; if ( newThis . autoRefresh ) newThis . refresh ( ) ; subscribe ( newThis , 'movie.change' , ( ) => { if ( newThis . autoRefresh && ! newThis . rendering ) newThis . refresh ( ) ; } ) ; subscribe ( newThis , 'movie.recordended' , ( ) => { if ( newThis . recording ) { newThis . _mediaRecorder . requestData ( ) ; newThis . _mediaRecorder . stop ( ) ; } } ) ; return newThis ; } return play ( ) { return new Promise ( resolve => { if ( ! this . paused ) throw new Error ( 'Already playing' ) ; this . _paused = this . _ended = false ; this . _lastPlayed = performance . now ( ) ; this . _lastPlayedOffset = this . currentTime ; if ( ! this . renderingFrame ) this . _render ( true , undefined , resolve ) ; this . _renderingFrame = false ; publish ( this , 'movie.play' , { } ) ; } ) ; } param options param frameRate param options . video param options . audio param options . mediaRecorderOptions param options . type return record ( options ) { if ( options . video === false && options . audio === false ) throw new Error ( 'Both video and audio cannot be disabled' ) ; if ( ! this . paused ) throw new Error ( 'Cannot record movie while already playing or recording' ) ; const mimeType = options . type || 'video/webm' ; if ( MediaRecorder && MediaRecorder . isTypeSupported && ! MediaRecorder . isTypeSupported ( mimeType ) ) throw new Error ( 'Please pass a valid MIME type for the exported video' ) ; return new Promise ( ( resolve , reject ) => { const canvasCache = this . canvas ; this . _canvas = document . createElement ( 'canvas' ) ; this . canvas . width = canvasCache . width ; this . canvas . height = canvasCache . height ; this . _cctx = this . canvas . getContext ( '2d' ) ; const recordedChunks = [ ] ; let tracks = [ ] ; if ( options . video !== false ) { const visualStream = this . canvas . captureStream ( options . frameRate ) ; tracks = tracks . concat ( visualStream . getTracks ( ) ) ; } const hasMediaTracks = this . layers . some ( layer => layer instanceof AudioLayer || layer instanceof VideoLayer ) ; if ( hasMediaTracks && options . audio !== false ) { const audioDestination = this . actx . createMediaStreamDestination ( ) ; const audioStream = audioDestination . stream ; tracks = tracks . concat ( audioStream . getTracks ( ) ) ; publish ( this , 'movie.audiodestinationupdate' , { movie : this , destination : audioDestination } ) ; } const stream = new MediaStream ( tracks ) ; const mediaRecorderOptions = { ... ( options . mediaRecorderOptions || { } ) , mimeType } ; const mediaRecorder = new MediaRecorder ( stream , mediaRecorderOptions ) ; mediaRecorder . ondataavailable = event => { if ( event . data . size > 0 ) recordedChunks . push ( event . data ) ; } ; mediaRecorder . onstop = ( ) => { this . _ended = true ; this . _canvas = canvasCache ; this . _cctx = this . canvas . getContext ( '2d' ) ; publish ( this , 'movie.audiodestinationupdate' , { movie : this , destination : this . actx . destination } ) ; this . _mediaRecorder = null ; resolve ( new Blob ( recordedChunks , { type : mimeType } ) ) ; } ; mediaRecorder . onerror = reject ; mediaRecorder . start ( ) ; this . _mediaRecorder = mediaRecorder ; this . _recordEndTime = options . duration ? this . currentTime + options . duration : this . duration ; this . play ( ) ; publish ( this , 'movie.record' , { options } ) ; } ) ; } return pause ( ) { this . _paused = true ; for ( let i = 0 ; i < this . layers . length ; i ++ ) if ( Object . prototype . hasOwnProperty . call ( this . layers , i ) ) { const layer = this . layers [ i ] ; layer . stop ( ) ; layer . active = false ; } publish ( this , 'movie.pause' , { } ) ; return this ; } return stop ( ) { this . pause ( ) ; this . currentTime = 0 ; return this ; } param timestamp param done private private _render ( repeat , timestamp = performance . now ( ) , done = undefined ) { clearCachedValues ( this ) ; if ( ! this . rendering ) { if ( done ) done ( ) ; return ; } this . _updateCurrentTime ( timestamp ) ; const recordingEnd = this . recording ? this . _recordEndTime : this . duration ; const recordingEnded = this . currentTime > recordingEnd ; if ( recordingEnded ) publish ( this , 'movie.recordended' , { movie : this } ) ; const end = this . duration ; const ended = this . currentTime > end ; if ( ended ) { publish ( this , 'movie.ended' , { movie : this , repeat : this . repeat } ) ; this . _currentTime = 0 ; publish ( this , 'movie.timeupdate' , { movie : this } ) ; this . _lastPlayed = performance . now ( ) ; this . _lastPlayedOffset = 0 ; this . _renderingFrame = false ; if ( ! this . repeat || this . recording ) { this . _ended = true ; for ( let i = 0 ; i < this . layers . length ; i ++ ) if ( Object . prototype . hasOwnProperty . call ( this . layers , i ) ) { const layer = this . layers [ i ] ; if ( ! layer ) continue ; layer . stop ( ) ; layer . active = false ; } } } if ( recordingEnded || ( ended && ! this . repeat ) ) { if ( done ) done ( ) ; return ; } this . _renderBackground ( timestamp ) ; const frameFullyLoaded = this . _renderLayers ( ) ; this . _applyEffects ( ) ; if ( frameFullyLoaded ) publish ( this , 'movie.loadeddata' , { movie : this } ) ; if ( ! repeat || ( this . _renderingFrame && frameFullyLoaded ) ) { this . _renderingFrame = false ; if ( done ) done ( ) ; return ; } window . requestAnimationFrame ( timestamp => { this . _render ( repeat , timestamp ) ; } ) ; } private _updateCurrentTime ( timestamp ) { if ( ! this . _renderingFrame ) { const sinceLastPlayed = ( timestamp - this . _lastPlayed ) / 1000 ; this . _currentTime = this . _lastPlayedOffset + sinceLastPlayed ; publish ( this , 'movie.timeupdate' , { movie : this } ) ; } } private _renderBackground ( timestamp ) { this . cctx . clearRect ( 0 , 0 , this . canvas . width , this . canvas . height ) ; const background = val ( this , 'background' , timestamp ) ; if ( background ) { this . cctx . fillStyle = background ; this . cctx . fillRect ( 0 , 0 , this . canvas . width , this . canvas . height ) ; } } return param timestamp private private _renderLayers ( ) { let frameFullyLoaded = true ; for ( let i = 0 ; i < this . layers . length ; i ++ ) { if ( ! Object . prototype . hasOwnProperty . call ( this . layers , i ) ) continue ; const layer = this . layers [ i ] ; if ( ! layer ) continue ; const reltime = this . currentTime - layer . startTime ; if ( ! val ( layer , 'enabled' , reltime ) || this . currentTime < layer . startTime || this . currentTime > layer . startTime + layer . duration ) { if ( layer . active && ! this . _renderingFrame ) { layer . stop ( ) ; layer . active = false ; } continue ; } if ( ! layer . active && val ( layer , 'enabled' , reltime ) && ! this . _renderingFrame ) { layer . start ( ) ; layer . active = true ; } if ( 'source' in layer ) frameFullyLoaded = frameFullyLoaded && ( layer as unknown as AudioSource ) . source . readyState >= 2 ; layer . render ( ) ; if ( layer instanceof Visual ) { const canvas = ( layer as Visual ) . canvas ; if ( canvas . width * canvas . height > 0 ) this . cctx . drawImage ( canvas , val ( layer , 'x' , reltime ) , val ( layer , 'y' , reltime ) , canvas . width , canvas . height ) ; } } return frameFullyLoaded ; } private _applyEffects ( ) { for ( let i = 0 ; i < this . effects . length ; i ++ ) { const effect = this . effects [ i ] ; if ( ! effect ) continue ; effect . apply ( this , this . currentTime ) ; } } return refresh ( ) { return new Promise ( resolve => { this . _renderingFrame = true ; this . _render ( false , undefined , resolve ) ; } ) ; } /**
     * Convienence method
     */ private _publishToLayers ( type , event ) { for ( let i = 0 ; i < this . layers . length ; i ++ ) if ( Object . prototype . hasOwnProperty . call ( this . layers , i ) ) publish ( this . layers [ i ] , type , event ) ; } /**
     * If the movie is playing, recording or refreshing
     */ get rendering ( ) : boolean { return ! this . paused || this . _renderingFrame ; } /**
     * If the movie is refreshing current frame
     */ get renderingFrame ( ) : boolean { return this . _renderingFrame ; } /**
     * If the movie is recording
     */ get recording ( ) : boolean { return ! ! this . _mediaRecorder ; } /**
     * The combined duration of all layers
     */ get duration ( ) : number { return this . layers . reduce ( ( end , layer ) => Math . max ( layer . startTime + layer . duration , end ) , 0 ) ; } param layer return addLayer ( layer ) { this . layers . push ( layer ) ; return this ; } param effect return addEffect ( effect ) { this . effects . push ( effect ) ; return this ; } /**
     */ get paused ( ) : boolean { return this . _paused ; } /**
     * If the playback position is at the end of the movie
     */ get ended ( ) : boolean { return this . _ended ; } /**
     * The current playback position
     */ get currentTime ( ) : number { return this . _currentTime ; } set currentTime ( time ) { this . _currentTime = time ; publish ( this , 'movie.seek' , { } ) ; this . refresh ( ) ; } param time param refresh return setCurrentTime ( time , refresh = true ) { return new Promise ( ( resolve , reject ) => { this . _currentTime = time ; publish ( this , 'movie.seek' , { } ) ; if ( refresh ) this . refresh ( ) . then ( resolve ) . catch ( reject ) ; else resolve ( ) ; } ) ; } /**
     * The rendering canvas
     */ get canvas ( ) : HTMLCanvasElement { return this . _canvas ; } /**
     * The rendering canvas's context
     */ get cctx ( ) : CanvasRenderingContext2D { return this . _cctx ; } /**
     * The width of the rendering canvas
     */ get width ( ) : number { return this . canvas . width ; } set width ( width ) { this . canvas . width = width ; } /**
     * The height of the rendering canvas
     */ get height ( ) : number { return this . canvas . height ; } set height ( height ) { this . canvas . height = height ; } get movie ( ) : Movie { return this ; } getDefaultOptions ( ) { return { canvas : undefined , _actx : new AudioContext ( ) , name desc background : '#000' , name repeat : false , name desc autoRefresh : true } ; } } Movie . prototype . type = 'movie' ; Movie . prototype . publicExcludes = [ 'canvas' , 'cctx' , 'actx' , 'layers' , 'effects' ] ; Movie . prototype . propertyFilters = { } ;