etro 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.github/workflows/nodejs.yml +1 -1
  2. package/CHANGELOG.md +45 -1
  3. package/CONTRIBUTING.md +23 -19
  4. package/README.md +81 -26
  5. package/dist/effect/base.d.ts +38 -0
  6. package/dist/effect/brightness.d.ts +16 -0
  7. package/dist/effect/channels.d.ts +23 -0
  8. package/dist/effect/chroma-key.d.ts +23 -0
  9. package/dist/effect/contrast.d.ts +15 -0
  10. package/dist/effect/elliptical-mask.d.ts +31 -0
  11. package/dist/effect/gaussian-blur.d.ts +60 -0
  12. package/dist/effect/grayscale.d.ts +7 -0
  13. package/dist/effect/index.d.ts +15 -0
  14. package/dist/effect/pixelate.d.ts +18 -0
  15. package/dist/effect/shader.d.ts +99 -0
  16. package/dist/effect/stack.d.ts +23 -0
  17. package/dist/effect/transform.d.ts +73 -0
  18. package/dist/etro-cjs.js +9337 -3331
  19. package/dist/etro-iife.js +9279 -3273
  20. package/dist/etro.d.ts +7 -0
  21. package/dist/event.d.ts +35 -0
  22. package/dist/index.d.ts +6 -0
  23. package/dist/layer/audio-source.d.ts +24 -0
  24. package/dist/layer/audio.d.ts +14 -0
  25. package/dist/layer/base.d.ts +69 -0
  26. package/dist/layer/image.d.ts +6 -0
  27. package/dist/layer/index.d.ts +11 -0
  28. package/dist/layer/text.d.ts +60 -0
  29. package/dist/layer/video.d.ts +11 -0
  30. package/dist/layer/visual-source.d.ts +32 -0
  31. package/dist/layer/visual.d.ts +58 -0
  32. package/dist/movie.d.ts +192 -0
  33. package/dist/object.d.ts +12 -0
  34. package/dist/util.d.ts +125 -0
  35. package/eslint.conf.js +0 -8
  36. package/eslint.example-conf.js +9 -0
  37. package/eslint.typescript-conf.js +5 -0
  38. package/examples/application/readme-screenshot.html +12 -9
  39. package/examples/application/video-player.html +7 -7
  40. package/examples/application/webcam.html +6 -6
  41. package/examples/introduction/audio.html +30 -18
  42. package/examples/introduction/effects.html +14 -10
  43. package/examples/introduction/export.html +32 -25
  44. package/examples/introduction/functions.html +6 -4
  45. package/examples/introduction/hello-world1.html +9 -5
  46. package/examples/introduction/hello-world2.html +5 -5
  47. package/examples/introduction/keyframes.html +35 -23
  48. package/examples/introduction/media.html +26 -18
  49. package/examples/introduction/text.html +9 -5
  50. package/karma.conf.js +1 -1
  51. package/package.json +29 -13
  52. package/rollup.config.js +15 -4
  53. package/scripts/gen-effect-samples.html +29 -25
  54. package/scripts/save-effect-samples.js +14 -15
  55. package/spec/assets/effect/gaussian-blur-horizontal.png +0 -0
  56. package/spec/assets/effect/gaussian-blur-vertical.png +0 -0
  57. package/spec/assets/effect/grayscale.png +0 -0
  58. package/spec/assets/effect/original.png +0 -0
  59. package/spec/assets/effect/pixelate.png +0 -0
  60. package/spec/assets/effect/transform/multiply.png +0 -0
  61. package/spec/assets/effect/transform/rotate.png +0 -0
  62. package/spec/assets/effect/transform/scale-fraction.png +0 -0
  63. package/spec/assets/effect/transform/scale.png +0 -0
  64. package/spec/assets/effect/transform/translate-fraction.png +0 -0
  65. package/spec/assets/effect/transform/translate.png +0 -0
  66. package/spec/effect.spec.js +126 -57
  67. package/spec/event.spec.js +14 -0
  68. package/spec/layer.spec.js +175 -18
  69. package/spec/movie.spec.js +191 -7
  70. package/spec/util.spec.js +14 -5
  71. package/src/effect/base.ts +96 -0
  72. package/src/effect/brightness.ts +43 -0
  73. package/src/effect/channels.ts +50 -0
  74. package/src/effect/chroma-key.ts +82 -0
  75. package/src/effect/contrast.ts +42 -0
  76. package/src/effect/elliptical-mask.ts +75 -0
  77. package/src/effect/gaussian-blur.ts +232 -0
  78. package/src/effect/grayscale.ts +34 -0
  79. package/src/effect/index.ts +22 -0
  80. package/src/effect/pixelate.ts +59 -0
  81. package/src/effect/shader.ts +561 -0
  82. package/src/effect/stack.ts +74 -0
  83. package/src/effect/transform.ts +194 -0
  84. package/src/etro.ts +26 -0
  85. package/src/event.ts +118 -0
  86. package/src/index.ts +8 -0
  87. package/src/layer/audio-source.ts +217 -0
  88. package/src/layer/audio.ts +35 -0
  89. package/src/layer/base.ts +156 -0
  90. package/src/layer/image.ts +8 -0
  91. package/src/layer/index.ts +13 -0
  92. package/src/layer/text.ts +138 -0
  93. package/src/layer/video.ts +15 -0
  94. package/src/layer/visual-source.ts +150 -0
  95. package/src/layer/visual.ts +198 -0
  96. package/src/movie.ts +709 -0
  97. package/src/object.ts +14 -0
  98. package/src/util.ts +473 -0
  99. package/tsconfig.json +8 -0
  100. package/screenshots/2019-08-17_0.png +0 -0
  101. package/src/effect.js +0 -1268
  102. package/src/event.js +0 -78
  103. package/src/index.js +0 -23
  104. package/src/layer.js +0 -897
  105. package/src/movie.js +0 -637
  106. package/src/util.js +0 -505
package/src/movie.ts ADDED
@@ -0,0 +1,709 @@
1
+ /**
2
+ * @module movie
3
+ */
4
+
5
+ import { AudioContext } from 'standardized-audio-context'
6
+ import { subscribe, publish } from './event'
7
+ import { Dynamic, val, clearCachedValues, applyOptions, watchPublic } from './util'
8
+ import { Base as BaseLayer, Audio as AudioLayer, Video as VideoLayer, Visual } from './layer/index' // `Media` mixins
9
+ import { AudioSource } from './layer/audio-source' // not exported from ./layer/index
10
+ import { Base as BaseEffect } from './effect/index'
11
+
12
+ declare global {
13
+ interface HTMLCanvasElement {
14
+ captureStream(frameRate?: number): MediaStream
15
+ }
16
+ }
17
+
18
+ export class MovieOptions {
19
+ /** The html canvas element to use for playback */
20
+ canvas: HTMLCanvasElement
21
+ /** The audio context to use for playback, defaults to a new audio context */
22
+ actx?: AudioContext
23
+ /** @deprecated Use <code>actx</code> instead */
24
+ audioContext?: AudioContext
25
+ /** The background color of the movie as a cSS string */
26
+ background?: Dynamic<string>
27
+ repeat?: boolean
28
+ /** Call `refresh` when the user changes a property on the movie or any of its layers or effects */
29
+ autoRefresh?: boolean
30
+ }
31
+
32
+ /**
33
+ * The movie contains everything included in the render.
34
+ *
35
+ * Implements a pub/sub system.
36
+ */
37
+ // TODO: Implement event "durationchange", and more
38
+ // TODO: Add width and height options
39
+ // TODO: Make record option to make recording video output to the user while
40
+ // it's recording
41
+ // TODO: rename renderingFrame -> refreshing
42
+ export class Movie {
43
+ type: string
44
+ publicExcludes: string[]
45
+ propertyFilters: Record<string, <T>(value: T) => T>
46
+
47
+ repeat: boolean
48
+ /** Call `refresh` when the user changes a property on the movie or any of its layers or effects */
49
+ autoRefresh: boolean
50
+ /** The background color of the movie as a cSS string */
51
+ background: Dynamic<string>
52
+ /** The audio context to which audio output is sent during playback */
53
+ readonly actx: AudioContext
54
+ // Readonly because it's a proxy (so it can't be overwritten).
55
+ readonly effects: BaseEffect[]
56
+ // Readonly because it's a proxy (so it can't be overwritten).
57
+ readonly layers: BaseLayer[]
58
+
59
+ private _canvas: HTMLCanvasElement;
60
+ private _cctx: CanvasRenderingContext2D
61
+ private _effectsBack: BaseEffect[]
62
+ private _layersBack: BaseLayer[]
63
+ private _currentTime: number
64
+ private _paused: boolean
65
+ private _ended: boolean
66
+ private _renderingFrame: boolean
67
+ private _recordEndTime: number
68
+ private _mediaRecorder: MediaRecorder
69
+ private _lastPlayed: number
70
+ private _lastPlayedOffset: number
71
+
72
+ /**
73
+ * Creates a new movie.
74
+ */
75
+ constructor (options: MovieOptions) {
76
+ // TODO: move into multiple methods!
77
+ // Set actx option manually, because it's readonly.
78
+ this.actx = options.actx || options.audioContext || new AudioContext()
79
+ delete options.actx
80
+
81
+ // Proxy that will be returned by constructor
82
+ const newThis: Movie = watchPublic(this) as Movie
83
+ // Set canvas option manually, because it's readonly.
84
+ this._canvas = options.canvas
85
+ delete options.canvas
86
+ // Don't send updates when initializing, so use this instead of newThis:
87
+ this._cctx = this.canvas.getContext('2d') // TODO: make private?
88
+ applyOptions(options, this)
89
+
90
+ const that: Movie = newThis
91
+
92
+ this._effectsBack = []
93
+ this.effects = new Proxy(newThis._effectsBack, {
94
+ deleteProperty (target, property): boolean {
95
+ // Refresh screen when effect is removed, if the movie isn't playing
96
+ // already.
97
+ const value = target[property]
98
+ value.detach()
99
+ delete target[property]
100
+ publish(that, 'movie.change.effect.remove', { effect: value })
101
+ return true
102
+ },
103
+ set (target, property, value): boolean {
104
+ // Check if property is an number (an index)
105
+ if (!isNaN(Number(property))) {
106
+ if (target[property]) {
107
+ publish(that, 'movie.change.effect.remove', {
108
+ effect: target[property]
109
+ })
110
+ target[property].detach()
111
+ }
112
+ // Attach effect to movie
113
+ value.attach(that)
114
+ target[property] = value
115
+ // Refresh screen when effect is set, if the movie isn't playing
116
+ // already.
117
+ publish(that, 'movie.change.effect.add', { effect: value })
118
+ } else {
119
+ target[property] = value
120
+ }
121
+ return true
122
+ }
123
+ })
124
+
125
+ this._layersBack = []
126
+ this.layers = new Proxy(newThis._layersBack, {
127
+ deleteProperty (target, property): boolean {
128
+ const oldDuration = this.duration
129
+ const value = target[property]
130
+ value.detach(that)
131
+ delete target[property]
132
+ const current = that.currentTime >= value.startTime && that.currentTime < value.startTime + value.duration
133
+ if (current) {
134
+ publish(that, 'movie.change.layer.remove', { layer: value })
135
+ }
136
+ publish(that, 'movie.change.duration', { oldDuration })
137
+ return true
138
+ },
139
+ set (target, property, value): boolean {
140
+ const oldDuration = this.duration
141
+ // Check if property is an number (an index)
142
+ if (!isNaN(Number(property))) {
143
+ if (target[property]) {
144
+ publish(that, 'movie.change.layer.remove', {
145
+ layer: target[property]
146
+ })
147
+ target[property].detach()
148
+ }
149
+ // Attach layer to movie
150
+ value.attach(that)
151
+ target[property] = value
152
+ // Refresh screen when a relevant layer is added or removed
153
+ const current = that.currentTime >= value.startTime && that.currentTime < value.startTime + value.duration
154
+ if (current) {
155
+ publish(that, 'movie.change.layer.add', { layer: value })
156
+ }
157
+ publish(that, 'movie.change.duration', { oldDuration })
158
+ } else {
159
+ target[property] = value
160
+ }
161
+ return true
162
+ }
163
+ })
164
+ this._paused = true
165
+ this._ended = false
166
+ // This variable helps prevent multiple frame-rendering loops at the same
167
+ // time (see `render`). It's only applicable when rendering.
168
+ this._renderingFrame = false
169
+ this.currentTime = 0
170
+
171
+ // For recording
172
+ this._mediaRecorder = null
173
+
174
+ // -1 works well in inequalities
175
+ // The last time `play` was called
176
+ this._lastPlayed = -1
177
+ // What was `currentTime` when `play` was called
178
+ this._lastPlayedOffset = -1
179
+ // newThis._updateInterval = 0.1; // time in seconds between each "timeupdate" event
180
+ // newThis._lastUpdate = -1;
181
+
182
+ if (newThis.autoRefresh) {
183
+ newThis.refresh() // render single frame on creation
184
+ }
185
+
186
+ // Subscribe to own event "change" (child events propogate up)
187
+ subscribe(newThis, 'movie.change', () => {
188
+ if (newThis.autoRefresh && !newThis.rendering) {
189
+ newThis.refresh()
190
+ }
191
+ })
192
+
193
+ // Subscribe to own event "ended"
194
+ subscribe(newThis, 'movie.recordended', () => {
195
+ if (newThis.recording) {
196
+ newThis._mediaRecorder.requestData()
197
+ newThis._mediaRecorder.stop()
198
+ }
199
+ })
200
+
201
+ return newThis
202
+ }
203
+
204
+ /**
205
+ * Plays the movie
206
+ * @return fulfilled when the movie is done playing, never fails
207
+ */
208
+ play (): Promise<void> {
209
+ return new Promise(resolve => {
210
+ if (!this.paused) {
211
+ throw new Error('Already playing')
212
+ }
213
+
214
+ this._paused = this._ended = false
215
+ this._lastPlayed = performance.now()
216
+ this._lastPlayedOffset = this.currentTime
217
+
218
+ if (!this.renderingFrame) {
219
+ // Not rendering (and not playing), so play.
220
+ this._render(true, undefined, resolve)
221
+ }
222
+ // Stop rendering frame if currently doing so, because playing has higher
223
+ // priority. This will effect the next _render call.
224
+ this._renderingFrame = false
225
+
226
+ publish(this, 'movie.play', {})
227
+ })
228
+ }
229
+
230
+ /**
231
+ * Plays the movie in the background and records it
232
+ *
233
+ * @param options
234
+ * @param frameRate
235
+ * @param [options.video=true] - whether to include video in recording
236
+ * @param [options.audio=true] - whether to include audio in recording
237
+ * @param [options.mediaRecorderOptions=undefined] - options to pass to the <code>MediaRecorder</code>
238
+ * @param [options.type='video/webm'] - MIME type for exported video
239
+ * constructor
240
+ * @return resolves when done recording, rejects when internal media recorder errors
241
+ */
242
+ // TEST: *support recording that plays back with audio!*
243
+ // TODO: figure out how to do offline recording (faster than realtime).
244
+ // TODO: improve recording performance to increase frame rate?
245
+ record (options: {
246
+ frameRate: number,
247
+ duration?: number,
248
+ type?: string,
249
+ video?: boolean,
250
+ audio?: boolean,
251
+ mediaRecorderOptions?: Record<string, unknown>
252
+ }): Promise<Blob> {
253
+ if (options.video === false && options.audio === false) {
254
+ throw new Error('Both video and audio cannot be disabled')
255
+ }
256
+
257
+ if (!this.paused) {
258
+ throw new Error('Cannot record movie while already playing or recording')
259
+ }
260
+ return new Promise((resolve, reject) => {
261
+ const canvasCache = this.canvas
262
+ // Record on a temporary canvas context
263
+ this._canvas = document.createElement('canvas')
264
+ this.canvas.width = canvasCache.width
265
+ this.canvas.height = canvasCache.height
266
+ this._cctx = this.canvas.getContext('2d')
267
+
268
+ // frame blobs
269
+ const recordedChunks = []
270
+ // Combine image + audio, or just pick one
271
+ let tracks = []
272
+ if (options.video !== false) {
273
+ const visualStream = this.canvas.captureStream(options.frameRate)
274
+ tracks = tracks.concat(visualStream.getTracks())
275
+ }
276
+ // Check if there's a layer that's an instance of an AudioSourceMixin
277
+ // (Audio or Video)
278
+ const hasMediaTracks = this.layers.some(layer => layer instanceof AudioLayer || layer instanceof VideoLayer)
279
+ // If no media tracks present, don't include an audio stream, because
280
+ // Chrome doesn't record silence when an audio stream is present.
281
+ if (hasMediaTracks && options.audio !== false) {
282
+ const audioDestination = this.actx.createMediaStreamDestination()
283
+ const audioStream = audioDestination.stream
284
+ tracks = tracks.concat(audioStream.getTracks())
285
+ publish(this, 'movie.audiodestinationupdate',
286
+ { movie: this, destination: audioDestination }
287
+ )
288
+ }
289
+ const stream = new MediaStream(tracks)
290
+ const mediaRecorder = new MediaRecorder(stream, options.mediaRecorderOptions)
291
+ mediaRecorder.ondataavailable = event => {
292
+ // if (this._paused) reject(new Error("Recording was interrupted"));
293
+ if (event.data.size > 0) {
294
+ recordedChunks.push(event.data)
295
+ }
296
+ }
297
+ // TODO: publish to movie, not layers
298
+ mediaRecorder.onstop = () => {
299
+ this._ended = true
300
+ this._canvas = canvasCache
301
+ this._cctx = this.canvas.getContext('2d')
302
+ publish(this, 'movie.audiodestinationupdate',
303
+ { movie: this, destination: this.actx.destination }
304
+ )
305
+ this._mediaRecorder = null
306
+ // Construct the exported video out of all the frame blobs.
307
+ resolve(
308
+ new Blob(recordedChunks, {
309
+ type: options.type || 'video/webm'
310
+ })
311
+ )
312
+ }
313
+ mediaRecorder.onerror = reject
314
+
315
+ mediaRecorder.start()
316
+ this._mediaRecorder = mediaRecorder
317
+ this._recordEndTime = options.duration ? this.currentTime + options.duration : this.duration
318
+ this.play()
319
+ publish(this, 'movie.record', { options })
320
+ })
321
+ }
322
+
323
+ /**
324
+ * Stops the movie, without reseting the playback position
325
+ * @return the movie (for chaining)
326
+ */
327
+ pause (): Movie {
328
+ this._paused = true
329
+ // Deactivate all layers
330
+ for (let i = 0; i < this.layers.length; i++) {
331
+ const layer = this.layers[i]
332
+ layer.stop()
333
+ layer.active = false
334
+ }
335
+ publish(this, 'movie.pause', {})
336
+ return this
337
+ }
338
+
339
+ /**
340
+ * Stops playback and resets the playback position
341
+ * @return the movie (for chaining)
342
+ */
343
+ stop (): Movie {
344
+ this.pause()
345
+ this.currentTime = 0
346
+ return this
347
+ }
348
+
349
+ /**
350
+ * @param [timestamp=performance.now()]
351
+ * @param [done=undefined] - called when done playing or when the current frame is loaded
352
+ * @private
353
+ */
354
+ private _render (repeat, timestamp = performance.now(), done = undefined) {
355
+ clearCachedValues(this)
356
+
357
+ if (!this.rendering) {
358
+ // (!this.paused || this._renderingFrame) is true so it's playing or it's
359
+ // rendering a single frame.
360
+ if (done) {
361
+ done()
362
+ }
363
+ return
364
+ }
365
+
366
+ this._updateCurrentTime(timestamp)
367
+ const recordingEnd = this.recording ? this._recordEndTime : this.duration
368
+ const recordingEnded = this.currentTime > recordingEnd
369
+ if (recordingEnded) {
370
+ publish(this, 'movie.recordended', { movie: this })
371
+ }
372
+
373
+ // Bad for performance? (remember, it's calling Array.reduce)
374
+ const end = this.duration
375
+ const ended = this.currentTime > end
376
+ if (ended) {
377
+ publish(this, 'movie.ended', { movie: this, repeat: this.repeat })
378
+ // TODO: only reset currentTime if repeating
379
+ this._currentTime = 0 // don't use setter
380
+ publish(this, 'movie.timeupdate', { movie: this })
381
+ this._lastPlayed = performance.now()
382
+ this._lastPlayedOffset = 0 // this.currentTime
383
+ this._renderingFrame = false
384
+ if (!this.repeat || this.recording) {
385
+ this._ended = true
386
+ // Deactivate all layers
387
+ for (let i = 0; i < this.layers.length; i++) {
388
+ const layer = this.layers[i]
389
+ // A layer that has been deleted before layers.length has been updated
390
+ // (see the layers proxy in the constructor).
391
+ if (!layer) {
392
+ continue
393
+ }
394
+ layer.stop()
395
+ layer.active = false
396
+ }
397
+ }
398
+ }
399
+
400
+ // Stop playback or recording if done
401
+ if (recordingEnded || (ended && !this.repeat)) {
402
+ if (done) {
403
+ done()
404
+ }
405
+ return
406
+ }
407
+
408
+ // Do render
409
+ this._renderBackground(timestamp)
410
+ const frameFullyLoaded = this._renderLayers()
411
+ this._applyEffects()
412
+
413
+ if (frameFullyLoaded) {
414
+ publish(this, 'movie.loadeddata', { movie: this })
415
+ }
416
+
417
+ // If didn't load in this instant, repeatedly frame-render until frame is
418
+ // loaded.
419
+ // If the expression below is false, don't publish an event, just silently
420
+ // stop render loop.
421
+ if (!repeat || (this._renderingFrame && frameFullyLoaded)) {
422
+ this._renderingFrame = false
423
+ if (done) {
424
+ done()
425
+ }
426
+ return
427
+ }
428
+
429
+ window.requestAnimationFrame(timestamp => {
430
+ this._render(repeat, timestamp)
431
+ }) // TODO: research performance cost
432
+ }
433
+
434
+ private _updateCurrentTime (timestamp) {
435
+ // If we're only instant-rendering (current frame only), it doens't matter
436
+ // if it's paused or not.
437
+ if (!this._renderingFrame) {
438
+ // if ((timestamp - this._lastUpdate) >= this._updateInterval) {
439
+ const sinceLastPlayed = (timestamp - this._lastPlayed) / 1000
440
+ this._currentTime = this._lastPlayedOffset + sinceLastPlayed // don't use setter
441
+ publish(this, 'movie.timeupdate', { movie: this })
442
+ // this._lastUpdate = timestamp;
443
+ // }
444
+ }
445
+ }
446
+
447
+ private _renderBackground (timestamp) {
448
+ this.cctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
449
+ const background = val(this, 'background', timestamp)
450
+ if (background) { // TODO: check val'd result
451
+ this.cctx.fillStyle = background
452
+ this.cctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
453
+ }
454
+ }
455
+
456
+ /**
457
+ * @return whether or not video frames are loaded
458
+ * @param [timestamp=performance.now()]
459
+ * @private
460
+ */
461
+ private _renderLayers () {
462
+ let frameFullyLoaded = true
463
+ for (let i = 0; i < this.layers.length; i++) {
464
+ const layer = this.layers[i]
465
+ // A layer that has been deleted before layers.length has been updated
466
+ // (see the layers proxy in the constructor).
467
+ if (!layer) {
468
+ continue
469
+ }
470
+ const reltime = this.currentTime - layer.startTime
471
+ // Cancel operation if layer disabled or outside layer time interval
472
+ if (!val(layer, 'enabled', reltime) ||
473
+ // TODO > or >= ?
474
+ this.currentTime < layer.startTime || this.currentTime > layer.startTime + layer.duration) {
475
+ // Layer is not active.
476
+ // If only rendering this frame, we are not "starting" the layer.
477
+ if (layer.active && !this._renderingFrame) {
478
+ // TODO: make a `deactivate()` method?
479
+ layer.stop()
480
+ layer.active = false
481
+ }
482
+ continue
483
+ }
484
+ // If only rendering this frame, we are not "starting" the layer
485
+ if (!layer.active && val(layer, 'enabled', reltime) && !this._renderingFrame) {
486
+ // TODO: make an `activate()` method?
487
+ layer.start()
488
+ layer.active = true
489
+ }
490
+
491
+ // if the layer has an input file
492
+ if ('source' in layer) {
493
+ frameFullyLoaded = frameFullyLoaded && (layer as unknown as AudioSource).source.readyState >= 2
494
+ }
495
+ layer.render()
496
+
497
+ // if the layer has visual component
498
+ if (layer instanceof Visual) {
499
+ const canvas = (layer as Visual).canvas
500
+ // layer.canvas.width and layer.canvas.height should already be interpolated
501
+ // if the layer has an area (else InvalidStateError from canvas)
502
+ if (canvas.width * canvas.height > 0) {
503
+ this.cctx.drawImage(canvas,
504
+ val(layer, 'x', reltime), val(layer, 'y', reltime), canvas.width, canvas.height
505
+ )
506
+ }
507
+ }
508
+ }
509
+
510
+ return frameFullyLoaded
511
+ }
512
+
513
+ private _applyEffects () {
514
+ for (let i = 0; i < this.effects.length; i++) {
515
+ const effect = this.effects[i]
516
+ // An effect that has been deleted before effects.length has been updated
517
+ // (see the effectsproxy in the constructor).
518
+ if (!effect) {
519
+ continue
520
+ }
521
+ effect.apply(this, this.currentTime)
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Refreshes the screen (only use this if auto-refresh is disabled)
527
+ * @return - resolves when the frame is loaded
528
+ */
529
+ refresh (): Promise<null> {
530
+ return new Promise(resolve => {
531
+ this._renderingFrame = true
532
+ this._render(false, undefined, resolve)
533
+ })
534
+ }
535
+
536
+ /**
537
+ * Convienence method
538
+ */
539
+ private _publishToLayers (type, event) {
540
+ for (let i = 0; i < this.layers.length; i++) {
541
+ publish(this.layers[i], type, event)
542
+ }
543
+ }
544
+
545
+ /**
546
+ * If the movie is playing, recording or refreshing
547
+ */
548
+ get rendering (): boolean {
549
+ return !this.paused || this._renderingFrame
550
+ }
551
+
552
+ /**
553
+ * If the movie is refreshing current frame
554
+ */
555
+ get renderingFrame (): boolean {
556
+ return this._renderingFrame
557
+ }
558
+
559
+ /**
560
+ * If the movie is recording
561
+ */
562
+ get recording (): boolean {
563
+ return !!this._mediaRecorder
564
+ }
565
+
566
+ /**
567
+ * The combined duration of all layers
568
+ */
569
+ // TODO: dirty flag?
570
+ get duration (): number {
571
+ return this.layers.reduce((end, layer) => Math.max(layer.startTime + layer.duration, end), 0)
572
+ }
573
+
574
+ /**
575
+ * Convienence method for <code>layers.push()</code>
576
+ * @param layer
577
+ * @return the movie
578
+ */
579
+ addLayer (layer: BaseLayer): Movie {
580
+ this.layers.push(layer); return this
581
+ }
582
+
583
+ /**
584
+ * Convienence method for <code>effects.push()</code>
585
+ * @param effect
586
+ * @return the movie
587
+ */
588
+ addEffect (effect: BaseEffect): Movie {
589
+ this.effects.push(effect); return this
590
+ }
591
+
592
+ /**
593
+ */
594
+ get paused (): boolean {
595
+ return this._paused
596
+ }
597
+
598
+ /**
599
+ * If the playback position is at the end of the movie
600
+ */
601
+ get ended (): boolean {
602
+ return this._ended
603
+ }
604
+
605
+ /**
606
+ * The current playback position
607
+ */
608
+ get currentTime (): number {
609
+ return this._currentTime
610
+ }
611
+
612
+ set currentTime (time: number) {
613
+ this._currentTime = time
614
+ publish(this, 'movie.seek', {})
615
+ // Render single frame to match new time
616
+ this.refresh()
617
+ }
618
+
619
+ /**
620
+ * Sets the current playback position. This is a more powerful version of
621
+ * `set currentTime`.
622
+ *
623
+ * @param time - the new cursor's time value in seconds
624
+ * @param [refresh=true] - whether to render a single frame
625
+ * @return resolves when the current frame is rendered if
626
+ * <code>refresh</code> is true, otherwise resolves immediately
627
+ *
628
+ */
629
+ // TODO: Refresh if only auto-refreshing is enabled
630
+ setCurrentTime (time: number, refresh = true): Promise<void> {
631
+ return new Promise((resolve, reject) => {
632
+ this._currentTime = time
633
+ publish(this, 'movie.seek', {})
634
+ if (refresh) {
635
+ // Pass promise callbacks to `refresh`
636
+ this.refresh().then(resolve).catch(reject)
637
+ } else {
638
+ resolve()
639
+ }
640
+ })
641
+ }
642
+
643
+ /**
644
+ * The rendering canvas
645
+ */
646
+ get canvas (): HTMLCanvasElement {
647
+ return this._canvas
648
+ }
649
+
650
+ /**
651
+ * The rendering canvas's context
652
+ */
653
+ get cctx (): CanvasRenderingContext2D {
654
+ return this._cctx
655
+ }
656
+
657
+ /**
658
+ * The width of the rendering canvas
659
+ */
660
+ get width (): number {
661
+ return this.canvas.width
662
+ }
663
+
664
+ set width (width: number) {
665
+ this.canvas.width = width
666
+ }
667
+
668
+ /**
669
+ * The height of the rendering canvas
670
+ */
671
+ get height (): number {
672
+ return this.canvas.height
673
+ }
674
+
675
+ set height (height: number) {
676
+ this.canvas.height = height
677
+ }
678
+
679
+ get movie (): Movie {
680
+ return this
681
+ }
682
+
683
+ getDefaultOptions (): MovieOptions & { _actx: AudioContext } {
684
+ return {
685
+ canvas: undefined, // required
686
+ _actx: new AudioContext(),
687
+ /**
688
+ * @name module:movie#background
689
+ * @desc The css color for the background, or <code>null</code> for transparency
690
+ */
691
+ background: '#000',
692
+ /**
693
+ * @name module:movie#repeat
694
+ */
695
+ repeat: false,
696
+ /**
697
+ * @name module:movie#autoRefresh
698
+ * @desc Whether to refresh when changes are made that would effect the current frame
699
+ */
700
+ autoRefresh: true
701
+ }
702
+ }
703
+ }
704
+
705
+ // id for events (independent of instance, but easy to access when on prototype chain)
706
+ Movie.prototype.type = 'movie'
707
+ // TODO: refactor so we don't need to explicitly exclude some of these
708
+ Movie.prototype.publicExcludes = ['canvas', 'cctx', 'actx', 'layers', 'effects']
709
+ Movie.prototype.propertyFilters = {}