etro 0.9.1 → 0.10.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 (52) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CONTRIBUTING.md +23 -20
  3. package/README.md +3 -2
  4. package/dist/custom-array.d.ts +10 -0
  5. package/dist/effect/base.d.ts +10 -1
  6. package/dist/effect/shader.d.ts +11 -1
  7. package/dist/effect/stack.d.ts +6 -2
  8. package/dist/etro-cjs.js +1156 -575
  9. package/dist/etro-iife.js +1156 -575
  10. package/dist/event.d.ts +10 -5
  11. package/dist/layer/audio-source.d.ts +9 -4
  12. package/dist/layer/audio.d.ts +15 -2
  13. package/dist/layer/base.d.ts +49 -3
  14. package/dist/layer/image.d.ts +15 -1
  15. package/dist/layer/text.d.ts +3 -0
  16. package/dist/layer/video.d.ts +13 -1
  17. package/dist/layer/visual.d.ts +6 -2
  18. package/dist/movie/effects.d.ts +6 -0
  19. package/dist/movie/index.d.ts +1 -0
  20. package/dist/movie/layers.d.ts +6 -0
  21. package/dist/movie/movie.d.ts +260 -0
  22. package/dist/object.d.ts +9 -6
  23. package/dist/util.d.ts +4 -10
  24. package/eslint.conf.js +2 -2
  25. package/karma.conf.js +4 -7
  26. package/package.json +8 -7
  27. package/src/custom-array.ts +43 -0
  28. package/src/effect/base.ts +23 -22
  29. package/src/effect/gaussian-blur.ts +11 -6
  30. package/src/effect/pixelate.ts +3 -3
  31. package/src/effect/shader.ts +33 -27
  32. package/src/effect/stack.ts +43 -30
  33. package/src/effect/transform.ts +14 -7
  34. package/src/event.ts +111 -21
  35. package/src/layer/audio-source.ts +60 -20
  36. package/src/layer/audio.ts +25 -3
  37. package/src/layer/base.ts +79 -26
  38. package/src/layer/image.ts +26 -2
  39. package/src/layer/text.ts +7 -0
  40. package/src/layer/video.ts +31 -4
  41. package/src/layer/visual-source.ts +43 -1
  42. package/src/layer/visual.ts +50 -28
  43. package/src/movie/effects.ts +26 -0
  44. package/src/movie/index.ts +1 -0
  45. package/src/movie/layers.ts +26 -0
  46. package/src/movie/movie.ts +855 -0
  47. package/src/object.ts +9 -6
  48. package/src/util.ts +68 -89
  49. package/dist/movie.d.ts +0 -201
  50. package/src/movie.ts +0 -744
  51. /package/scripts/{gen-effect-samples.html → effect/gen-effect-samples.html} +0 -0
  52. /package/scripts/{save-effect-samples.js → effect/save-effect-samples.js} +0 -0
@@ -0,0 +1,855 @@
1
+ /**
2
+ * @module movie
3
+ */
4
+
5
+ import { publish, deprecate } from '../event'
6
+ import { Dynamic, val, clearCachedValues, applyOptions, Color, parseColor } from '../util'
7
+ import { Base as BaseLayer, Audio as AudioLayer, Video as VideoLayer, Visual } from '../layer/index' // `Media` mixins
8
+ import { Base as BaseEffect } from '../effect/index'
9
+ import { MovieEffects } from './effects'
10
+ import { MovieLayers } from './layers'
11
+
12
+ declare global {
13
+ interface Window {
14
+ webkitAudioContext: typeof AudioContext
15
+ }
16
+
17
+ interface HTMLCanvasElement {
18
+ captureStream(frameRate?: number): MediaStream
19
+ }
20
+ }
21
+
22
+ export class MovieOptions {
23
+ /** The html canvas element to use for playback */
24
+ canvas: HTMLCanvasElement
25
+ /** The audio context to use for playback, defaults to a new audio context */
26
+ actx?: AudioContext
27
+ /** @deprecated Use <code>actx</code> instead */
28
+ audioContext?: AudioContext
29
+ /** The background color of the movie as a cSS string */
30
+ background?: Dynamic<Color>
31
+
32
+ /**
33
+ * If set to true, the movie will repeat when it reaches the end (unless it's
34
+ * recording)
35
+ */
36
+ repeat?: boolean
37
+ }
38
+
39
+ /**
40
+ * The movie contains everything included in the render.
41
+ *
42
+ * Implements a pub/sub system.
43
+ */
44
+ // TODO: rename renderingFrame -> refreshing
45
+ export class Movie {
46
+ type: string
47
+ /**
48
+ * @deprecated Auto-refresh will be removed in the future. If you want to
49
+ * refresh the canvas, call `refresh`. See
50
+ * {@link https://github.com/etro-js/etro/issues/130}
51
+ */
52
+ publicExcludes: string[]
53
+ propertyFilters: Record<string, <T>(value: T) => T>
54
+
55
+ repeat: boolean
56
+ /** The background color of the movie as a cSS string */
57
+ background: Dynamic<Color>
58
+ /** The audio context to which audio output is sent during playback */
59
+ readonly actx: AudioContext
60
+ // Readonly because it's a proxy (so it can't be overwritten).
61
+ readonly effects: MovieEffects
62
+ // Readonly because it's a proxy (so it can't be overwritten).
63
+ readonly layers: MovieLayers
64
+
65
+ /** The canvas that we are currently rendering to */
66
+ private _canvas: HTMLCanvasElement
67
+ private _visibleCanvas: HTMLCanvasElement
68
+ private _cctx: CanvasRenderingContext2D
69
+ private _recorder: MediaRecorder
70
+ private _currentTime: number
71
+ private _paused: boolean
72
+ private _ended: boolean
73
+ private _renderingFrame: boolean
74
+ private _recording = false
75
+ private _currentStream: MediaStream
76
+ private _endTime: number
77
+ private _lastPlayed: number
78
+ private _lastPlayedOffset: number
79
+
80
+ /**
81
+ * Creates a new movie.
82
+ */
83
+ constructor (options: MovieOptions) {
84
+ // Set actx option manually, because it's readonly.
85
+ this.actx = options.actx ||
86
+ options.audioContext ||
87
+ new AudioContext() ||
88
+ // eslint-disable-next-line new-cap
89
+ new window.webkitAudioContext()
90
+ delete options.actx
91
+
92
+ // Check if required file canvas is provided
93
+ if (!options.canvas) {
94
+ throw new Error('Required option "canvas" not provided to Movie')
95
+ }
96
+
97
+ // Set canvas option manually, because it's readonly.
98
+ this._canvas = this._visibleCanvas = options.canvas
99
+ delete options.canvas
100
+ this._cctx = this.canvas.getContext('2d') // TODO: make private?
101
+
102
+ // Set options on the movie
103
+ applyOptions(options, this)
104
+
105
+ this.effects = new MovieEffects([], this)
106
+ this.layers = new MovieLayers([], this)
107
+
108
+ this._paused = true
109
+ this._ended = false
110
+ // This lock prevents multiple refresh loops at the same time (see
111
+ // `render`). It's only valid while rendering.
112
+ this._renderingFrame = false
113
+ this.currentTime = 0
114
+
115
+ // The last time `play` was called, -1 works well in comparisons
116
+ this._lastPlayed = -1
117
+ // What `currentTime` was when `play` was called
118
+ this._lastPlayedOffset = -1
119
+ }
120
+
121
+ private async _whenReady (): Promise<void> {
122
+ await Promise.all([
123
+ Promise.all(this.layers.map(layer => layer.whenReady())),
124
+ Promise.all(this.effects.map(effect => effect.whenReady()))
125
+ ])
126
+ }
127
+
128
+ /**
129
+ * Plays the movie
130
+ *
131
+ * @param [options]
132
+ * @param [options.onStart] Called when the movie starts playing
133
+ *
134
+ * @return Fulfilled when the movie is done playing, never fails
135
+ */
136
+ async play (options: {
137
+ onStart?: () => void,
138
+ } = {}): Promise<void> {
139
+ await this._whenReady()
140
+
141
+ if (!this.paused) {
142
+ throw new Error('Already playing')
143
+ }
144
+
145
+ this._paused = this._ended = false
146
+ this._lastPlayed = performance.now()
147
+ this._lastPlayedOffset = this.currentTime
148
+
149
+ options.onStart?.()
150
+
151
+ // For backwards compatibility
152
+ publish(this, 'movie.play', {})
153
+
154
+ // Repeatedly render frames until the movie ends
155
+ await new Promise<void>(resolve => {
156
+ if (!this.renderingFrame) {
157
+ // Not rendering (and not playing), so play.
158
+ this._render(true, undefined, resolve)
159
+ }
160
+
161
+ // Stop rendering frame if currently doing so, because playing has higher
162
+ // priority. This will affect the next _render call.
163
+ this._renderingFrame = false
164
+ })
165
+ }
166
+
167
+ /**
168
+ * Updates the rendering canvas and audio destination to the visible canvas
169
+ * and the audio context destination.
170
+ */
171
+ private _show (): void {
172
+ this._canvas = this._visibleCanvas
173
+ this._cctx = this.canvas.getContext('2d')
174
+
175
+ publish(this, 'audiodestinationupdate',
176
+ { movie: this, destination: this.actx.destination }
177
+ )
178
+ }
179
+
180
+ /**
181
+ * Streams the movie to a MediaStream
182
+ *
183
+ * @param options Options for the stream
184
+ * @param options.frameRate The frame rate of the stream's video
185
+ * @param options.duration The duration of the stream in seconds
186
+ * @param options.video Whether to stream video. Defaults to true.
187
+ * @param options.audio Whether to stream audio. Defaults to true.
188
+ * @param options.onStart Called when the stream is started
189
+ * @return Fulfilled when the stream is done, never fails
190
+ */
191
+ async stream (options: {
192
+ frameRate: number,
193
+ duration?: number,
194
+ video?: boolean,
195
+ audio?: boolean,
196
+ onStart (stream: MediaStream): void,
197
+ }): Promise<void> {
198
+ // Validate options
199
+ if (!options || !options.frameRate) {
200
+ throw new Error('Required option "frameRate" not provided to Movie.stream')
201
+ }
202
+
203
+ if (options.video === false && options.audio === false) {
204
+ throw new Error('Both video and audio cannot be disabled')
205
+ }
206
+
207
+ if (!this.paused) {
208
+ throw new Error("Cannot stream movie while it's already playing")
209
+ }
210
+
211
+ // Wait until all resources are loaded
212
+ await this._whenReady()
213
+
214
+ // Create a temporary canvas to stream from
215
+ this._canvas = document.createElement('canvas')
216
+ this.canvas.width = this._visibleCanvas.width
217
+ this.canvas.height = this._visibleCanvas.height
218
+ this._cctx = this.canvas.getContext('2d')
219
+
220
+ // Create the stream
221
+ let tracks = []
222
+ // Add video track
223
+ if (options.video !== false) {
224
+ const visualStream = this.canvas.captureStream(options.frameRate)
225
+ tracks = tracks.concat(visualStream.getTracks())
226
+ }
227
+ // Check if there's a layer that's an instance of an AudioSourceMixin
228
+ // (Audio or Video). If so, add an audio track.
229
+ const hasMediaTracks = this.layers.some(layer => layer instanceof AudioLayer || layer instanceof VideoLayer)
230
+ // If no media tracks present, don't include an audio stream, because
231
+ // Chrome doesn't record silence when an audio stream is present.
232
+ if (hasMediaTracks && options.audio !== false) {
233
+ const audioDestination = this.actx.createMediaStreamDestination()
234
+ const audioStream = audioDestination.stream
235
+ tracks = tracks.concat(audioStream.getTracks())
236
+
237
+ // Notify layers and any other listeners of the new audio destination
238
+ publish(this, 'audiodestinationupdate',
239
+ { movie: this, destination: audioDestination }
240
+ )
241
+ }
242
+
243
+ // Create the stream
244
+ this._currentStream = new MediaStream(tracks)
245
+
246
+ // Play the movie
247
+ this._endTime = options.duration ? this.currentTime + options.duration : this.duration
248
+ await this.play({
249
+ onStart: () => {
250
+ // Call the user's onStart callback
251
+ options.onStart(this._currentStream)
252
+ }
253
+ })
254
+
255
+ // Clear the stream after the movie is done playing
256
+ this._currentStream.getTracks().forEach(track => {
257
+ track.stop()
258
+ })
259
+ this._currentStream = null
260
+ this._show()
261
+ }
262
+
263
+ /**
264
+ * Plays the movie in the background and records it
265
+ *
266
+ * @param options
267
+ * @param [options.frameRate] - Video frame rate
268
+ * @param [options.video=true] - whether to include video in recording
269
+ * @param [options.audio=true] - whether to include audio in recording
270
+ * @param [options.mediaRecorderOptions=undefined] - Options to pass to the
271
+ * `MediaRecorder` constructor
272
+ * @param [options.type='video/webm'] - MIME type for exported video
273
+ * @param [options.onStart] - Called when the recording starts
274
+ * @return Resolves when done recording, rejects when media recorder errors
275
+ */
276
+ // TODO: Improve recording performance to increase frame rate
277
+ async record (options: {
278
+ frameRate: number,
279
+ duration?: number,
280
+ type?: string,
281
+ video?: boolean,
282
+ audio?: boolean,
283
+ mediaRecorderOptions?: Record<string, unknown>,
284
+ onStart?: (recorder: MediaRecorder) => void,
285
+ }): Promise<Blob> {
286
+ // Validate options
287
+ if (options.video === false && options.audio === false) {
288
+ throw new Error('Both video and audio cannot be disabled')
289
+ }
290
+
291
+ if (!this.paused) {
292
+ throw new Error("Cannot record movie while it's already playing")
293
+ }
294
+
295
+ const mimeType = options.type || 'video/webm'
296
+ if (MediaRecorder && MediaRecorder.isTypeSupported && !MediaRecorder.isTypeSupported(mimeType)) {
297
+ throw new Error('Please pass a valid MIME type for the exported video')
298
+ }
299
+
300
+ // Start streaming in the background
301
+ const stream = await new Promise<MediaStream>(resolve => {
302
+ this.stream({
303
+ frameRate: options.frameRate,
304
+ duration: options.duration,
305
+ video: options.video,
306
+ audio: options.audio,
307
+ onStart: resolve
308
+ }).then(() => {
309
+ // Stop the media recorder when the movie is done playing
310
+ this._recorder.requestData()
311
+ this._recorder.stop()
312
+ })
313
+ })
314
+
315
+ // The array to store the recorded chunks
316
+ const recordedChunks = []
317
+
318
+ // Create the media recorder
319
+ const mediaRecorderOptions = {
320
+ ...(options.mediaRecorderOptions || {}),
321
+ mimeType
322
+ }
323
+ this._recorder = new MediaRecorder(stream, mediaRecorderOptions)
324
+ this._recorder.ondataavailable = event => {
325
+ // if (this._paused) reject(new Error("Recording was interrupted"));
326
+ if (event.data.size > 0) {
327
+ recordedChunks.push(event.data)
328
+ }
329
+ }
330
+
331
+ // Start recording
332
+ this._recorder.start()
333
+ this._recording = true
334
+
335
+ // Notify caller that the media recorder has started
336
+ options.onStart?.(this._recorder)
337
+
338
+ // For backwards compatibility
339
+ publish(this, 'movie.record', { options })
340
+
341
+ // Wait until the media recorder is done recording and processing
342
+ await new Promise<void>((resolve, reject) => {
343
+ this._recorder.onstop = () => {
344
+ resolve()
345
+ }
346
+
347
+ this._recorder.onerror = reject
348
+ })
349
+
350
+ // Clean up
351
+ this._paused = true
352
+ this._ended = true
353
+ this._recording = false
354
+
355
+ // Construct the exported video out of all the frame blobs.
356
+ return new Blob(recordedChunks, {
357
+ type: mimeType
358
+ })
359
+ }
360
+
361
+ /**
362
+ * Stops the movie without resetting the playback position
363
+ * @return The movie
364
+ */
365
+ pause (): Movie {
366
+ // Update state
367
+ this._paused = true
368
+
369
+ // Deactivate all layers
370
+ for (let i = 0; i < this.layers.length; i++) {
371
+ if (Object.prototype.hasOwnProperty.call(this.layers, i)) {
372
+ const layer = this.layers[i]
373
+
374
+ if(layer.active) {
375
+ layer.stop()
376
+ layer.active = false
377
+ }
378
+ }
379
+ }
380
+
381
+ // For backwards compatibility, notify event listeners that the movie has
382
+ // paused
383
+ publish(this, 'movie.pause', {})
384
+
385
+ return this
386
+ }
387
+
388
+ /**
389
+ * Stops playback and resets the playback position
390
+ * @return The movie
391
+ */
392
+ stop (): Movie {
393
+ this.pause()
394
+ this.currentTime = 0
395
+ return this
396
+ }
397
+
398
+ /**
399
+ * @param [timestamp=performance.now()]
400
+ * @param [done=undefined] - Called when done playing or when the current
401
+ * frame is loaded
402
+ */
403
+ private _render (repeat, timestamp = performance.now(), done = undefined) {
404
+ clearCachedValues(this)
405
+
406
+ if (!this.rendering) {
407
+ // (this.paused && !this._renderingFrame) is true so it's playing or it's
408
+ // rendering a single frame.
409
+ if (done) {
410
+ done()
411
+ }
412
+
413
+ return
414
+ }
415
+
416
+ if (this.ready) {
417
+ publish(this, 'movie.loadeddata', { movie: this })
418
+
419
+ // If the movie is streaming or recording, resume the media recorder
420
+ if (this._recording && this._recorder.state === 'paused') {
421
+ this._recorder.resume()
422
+ }
423
+
424
+ // If the movie is streaming or recording, end at the specified duration.
425
+ // Otherwise, end at the movie's duration, because play() does not
426
+ // support playing a portion of the movie yet.
427
+ // TODO: Is calling duration every frame bad for performance? (remember,
428
+ // it's calling Array.reduce)
429
+ const end = this._currentStream ? this._endTime : this.duration
430
+
431
+ this._updateCurrentTime(timestamp, end)
432
+
433
+ if (this.currentTime === end) {
434
+ if (this.recording) {
435
+ publish(this, 'movie.recordended', { movie: this })
436
+ }
437
+
438
+ if (this.currentTime === this.duration) {
439
+ publish(this, 'movie.ended', { movie: this, repeat: this.repeat })
440
+ }
441
+
442
+ // Don't use setter, which publishes 'seek'. Instead, update the
443
+ // value and publish a 'imeupdate' event.
444
+ this._currentTime = 0
445
+ publish(this, 'movie.timeupdate', { movie: this })
446
+
447
+ this._lastPlayed = performance.now()
448
+ this._lastPlayedOffset = 0 // this.currentTime
449
+ this._renderingFrame = false
450
+
451
+ // Stop playback or recording if done (except if it's playing and repeat
452
+ // is true)
453
+ if (!(!this.recording && this.repeat)) {
454
+ this._paused = true
455
+ this._ended = true
456
+ // Deactivate all layers
457
+ for (let i = 0; i < this.layers.length; i++) {
458
+ if (Object.prototype.hasOwnProperty.call(this.layers, i)) {
459
+ const layer = this.layers[i]
460
+ // A layer that has been deleted before layers.length has been updated
461
+ // (see the layers proxy in the constructor).
462
+ if (!layer || !layer.active) {
463
+ continue
464
+ }
465
+
466
+ layer.stop()
467
+ layer.active = false
468
+ }
469
+ }
470
+
471
+ publish(this, 'movie.pause', {})
472
+
473
+ if (done) {
474
+ done()
475
+ }
476
+
477
+ return
478
+ }
479
+ }
480
+
481
+ // Do render
482
+ this._renderBackground(timestamp)
483
+ this._renderLayers()
484
+ this._applyEffects()
485
+ } else {
486
+ // If we are recording, pause the media recorder until the movie is
487
+ // ready.
488
+ if (this.recording && this._recorder.state === 'recording') {
489
+ this._recorder.pause()
490
+ }
491
+ }
492
+
493
+ // If the frame didn't load this instant, repeatedly frame-render until it
494
+ // is loaded.
495
+ // If the expression below is true, don't publish an event, just silently
496
+ // stop the render loop.
497
+ if (this._renderingFrame && this.ready) {
498
+ this._renderingFrame = false
499
+ if (done) {
500
+ done()
501
+ }
502
+
503
+ return
504
+ }
505
+
506
+ // TODO: Is making a new arrow function every frame bad for performance?
507
+ window.requestAnimationFrame(() => {
508
+ this._render(repeat, undefined, done)
509
+ })
510
+ }
511
+
512
+ private _updateCurrentTime (timestampMs: number, end: number) {
513
+ // If we're only frame-rendering (current frame only), it doesn't matter if
514
+ // it's paused or not.
515
+ if (!this._renderingFrame) {
516
+ const sinceLastPlayed = (timestampMs - this._lastPlayed) / 1000
517
+ const currentTime = this._lastPlayedOffset + sinceLastPlayed
518
+ if (this.currentTime !== currentTime) {
519
+ // Update the current time (don't use setter)
520
+ this._currentTime = currentTime
521
+
522
+ // For backwards compatibility, publish a 'movie.timeupdate' event.
523
+ publish(this, 'movie.timeupdate', { movie: this })
524
+ }
525
+
526
+ if (this.currentTime > end) {
527
+ this._currentTime = end
528
+ }
529
+ }
530
+ }
531
+
532
+ private _renderBackground (timestamp) {
533
+ this.cctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
534
+
535
+ // Evaluate background color (since it's a dynamic property)
536
+ const background = val(this, 'background', timestamp)
537
+ if (background) {
538
+ this.cctx.fillStyle = background
539
+ this.cctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
540
+ }
541
+ }
542
+
543
+ /**
544
+ * @param [timestamp=performance.now()]
545
+ */
546
+ private _renderLayers () {
547
+ for (let i = 0; i < this.layers.length; i++) {
548
+ if (!Object.prototype.hasOwnProperty.call(this.layers, i)) {
549
+ continue
550
+ }
551
+
552
+ const layer = this.layers[i]
553
+ // A layer that has been deleted before layers.length has been updated
554
+ // (see the layers proxy in the constructor).
555
+ if (!layer) {
556
+ continue
557
+ }
558
+
559
+ // Cancel operation if layer disabled or outside layer time interval
560
+ const reltime = this.currentTime - layer.startTime
561
+ if (!val(layer, 'enabled', reltime) ||
562
+ // TODO > or >= ?
563
+ this.currentTime < layer.startTime || this.currentTime > layer.startTime + layer.duration) {
564
+ // Layer is not active.
565
+ // If only rendering this frame, we are not "starting" the layer.
566
+ if (layer.active && !this._renderingFrame) {
567
+ layer.stop()
568
+ layer.active = false
569
+ }
570
+ continue
571
+ }
572
+
573
+ // If we are playing (not refreshing), update the layer's progress
574
+ if (!this._renderingFrame) {
575
+ layer.progress(reltime)
576
+ }
577
+
578
+ // If only rendering this frame, we are not "starting" the layer
579
+ if (!layer.active && val(layer, 'enabled', reltime) && !this._renderingFrame) {
580
+ layer.start()
581
+ layer.active = true
582
+ }
583
+
584
+ layer.render()
585
+
586
+ // if the layer has visual component
587
+ if (layer instanceof Visual) {
588
+ const canvas = (layer as Visual).canvas
589
+ if (canvas.width * canvas.height > 0) {
590
+ this.cctx.drawImage(canvas,
591
+ val(layer, 'x', reltime), val(layer, 'y', reltime), canvas.width, canvas.height
592
+ )
593
+ }
594
+ }
595
+ }
596
+ }
597
+
598
+ private _applyEffects () {
599
+ for (let i = 0; i < this.effects.length; i++) {
600
+ const effect = this.effects[i]
601
+
602
+ // An effect that has been deleted before effects.length has been updated
603
+ // (see the effectsproxy in the constructor).
604
+ if (!effect) {
605
+ continue
606
+ }
607
+
608
+ effect.apply(this, this.currentTime)
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Refreshes the screen
614
+ *
615
+ * Only use this if auto-refresh is disabled
616
+ *
617
+ * @return - Promise that resolves when the frame is loaded
618
+ */
619
+ refresh (): Promise<null> {
620
+ // Refreshing while playing can interrupt playback
621
+ if (!this.paused) {
622
+ throw new Error('Already playing')
623
+ }
624
+
625
+ return new Promise(resolve => {
626
+ this._renderingFrame = true
627
+ this._render(false, undefined, resolve)
628
+ })
629
+ }
630
+
631
+ /**
632
+ * Convienence method (TODO: remove)
633
+ */
634
+ private _publishToLayers (type, event) {
635
+ for (let i = 0; i < this.layers.length; i++) {
636
+ if (Object.prototype.hasOwnProperty.call(this.layers, i)) {
637
+ publish(this.layers[i], type, event)
638
+ }
639
+ }
640
+ }
641
+
642
+ /**
643
+ * `true` if the movie is playing, recording or refreshing
644
+ */
645
+ get rendering (): boolean {
646
+ return !this.paused || this._renderingFrame
647
+ }
648
+
649
+ /**
650
+ * `true` if the movie is refreshing the current frame
651
+ */
652
+ get renderingFrame (): boolean {
653
+ return this._renderingFrame
654
+ }
655
+
656
+ /**
657
+ * `true` if the movie is recording
658
+ */
659
+ get recording (): boolean {
660
+ return this._recording
661
+ }
662
+
663
+ /**
664
+ * The duration of the movie in seconds
665
+ *
666
+ * Calculated from the end time of the last layer
667
+ */
668
+ // TODO: dirty flag?
669
+ get duration (): number {
670
+ return this.layers.reduce((end, layer) => Math.max(layer.startTime + layer.duration, end), 0)
671
+ }
672
+
673
+ /**
674
+ * Convenience method for `layers.push()`
675
+ * @param layer
676
+ * @return The movie
677
+ */
678
+ addLayer (layer: BaseLayer): Movie {
679
+ this.layers.push(layer); return this
680
+ }
681
+
682
+ /**
683
+ * Convenience method for `effects.push()`
684
+ * @param effect
685
+ * @return the movie
686
+ */
687
+ addEffect (effect: BaseEffect): Movie {
688
+ this.effects.push(effect); return this
689
+ }
690
+
691
+ /**
692
+ * `true` if the movie is paused
693
+ */
694
+ get paused (): boolean {
695
+ return this._paused
696
+ }
697
+
698
+ /**
699
+ * `true` if the playback position is at the end of the movie
700
+ */
701
+ get ended (): boolean {
702
+ return this._ended
703
+ }
704
+
705
+ /**
706
+ * Skips to the provided playback position, updating {@link currentTime}.
707
+ *
708
+ * @param time - The new playback position (in seconds)
709
+ */
710
+ seek (time: number) {
711
+ this._currentTime = time
712
+
713
+ // Call `seek` on every layer
714
+ for (let i = 0; i < this.layers.length; i++) {
715
+ const layer = this.layers[i]
716
+ if (layer) {
717
+ const relativeTime = time - layer.startTime
718
+ if (relativeTime >= 0 && relativeTime <= layer.duration) {
719
+ layer.seek(relativeTime)
720
+ } else {
721
+ layer.seek(undefined)
722
+ }
723
+ }
724
+ }
725
+
726
+ // For backwards compatibility, publish a `seek` event
727
+ publish(this, 'movie.seek', {})
728
+ }
729
+
730
+ /**
731
+ * The current playback position in seconds
732
+ */
733
+ get currentTime (): number {
734
+ return this._currentTime
735
+ }
736
+
737
+ /**
738
+ * Skips to the provided playback position, updating {@link currentTime}.
739
+ *
740
+ * @param time - The new playback position (in seconds)
741
+ *
742
+ * @deprecated Use `seek` instead
743
+ */
744
+ set currentTime (time: number) {
745
+ this.seek(time)
746
+ }
747
+
748
+ /**
749
+ * Skips to the provided playback position, updating {@link currentTime}.
750
+ *
751
+ * @param time - The new time (in seconds)
752
+ * @param [refresh=true] - Render a single frame?
753
+ * @return Promise that resolves when the current frame is rendered if
754
+ * `refresh` is true; otherwise resolves immediately.
755
+ *
756
+ * @deprecated Call {@link seek} and {@link refresh} separately
757
+ */
758
+ // TODO: Refresh only if auto-refreshing is enabled
759
+ setCurrentTime (time: number, refresh = true): Promise<void> {
760
+ return new Promise((resolve, reject) => {
761
+ this.seek(time)
762
+
763
+ if (refresh) {
764
+ // Pass promise callbacks to `refresh`
765
+ this.refresh().then(resolve).catch(reject)
766
+ } else {
767
+ resolve()
768
+ }
769
+ })
770
+ }
771
+
772
+ /**
773
+ * `true` if the movie is ready for playback
774
+ */
775
+ get ready (): boolean {
776
+ const layersReady = this.layers.every(layer => layer.ready)
777
+ const effectsReady = this.effects.every(effect => effect.ready)
778
+ return layersReady && effectsReady
779
+ }
780
+
781
+ /**
782
+ * The HTML canvas element used for rendering
783
+ */
784
+ get canvas (): HTMLCanvasElement {
785
+ return this._canvas
786
+ }
787
+
788
+ /**
789
+ * The canvas context used for rendering
790
+ */
791
+ get cctx (): CanvasRenderingContext2D {
792
+ return this._cctx
793
+ }
794
+
795
+ /**
796
+ * The width of the output canvas
797
+ */
798
+ get width (): number {
799
+ return this.canvas.width
800
+ }
801
+
802
+ set width (width: number) {
803
+ this.canvas.width = width
804
+ }
805
+
806
+ /**
807
+ * The height of the output canvas
808
+ */
809
+ get height (): number {
810
+ return this.canvas.height
811
+ }
812
+
813
+ set height (height: number) {
814
+ this.canvas.height = height
815
+ }
816
+
817
+ /**
818
+ * @return The movie
819
+ */
820
+ get movie (): Movie {
821
+ return this
822
+ }
823
+
824
+ /**
825
+ * @deprecated See {@link https://github.com/etro-js/etro/issues/131}
826
+ */
827
+ getDefaultOptions (): MovieOptions {
828
+ return {
829
+ canvas: undefined, // required
830
+ /**
831
+ * @name module:movie#background
832
+ * @desc The color for the background, or <code>null</code> for transparency
833
+ */
834
+ background: parseColor('#000'),
835
+ /**
836
+ * @name module:movie#repeat
837
+ */
838
+ repeat: false
839
+ }
840
+ }
841
+ }
842
+
843
+ // Id for events
844
+ Movie.prototype.type = 'movie'
845
+ Movie.prototype.propertyFilters = {}
846
+
847
+ deprecate('movie.audiodestinationupdate', 'audiodestinationupdate')
848
+ deprecate('movie.ended', undefined)
849
+ deprecate('movie.loadeddata', undefined)
850
+ deprecate('movie.pause', undefined, 'Wait for `play()`, `stream()`, or `record()` to resolve instead.')
851
+ deprecate('movie.play', undefined, 'Provide an `onStart` callback to `play()`, `stream()`, or `record()` instead.')
852
+ deprecate('movie.record', undefined, 'Provide an `onStart` callback to `record()` instead.')
853
+ deprecate('movie.recordended', undefined, 'Wait for `record()` to resolve instead.')
854
+ deprecate('movie.seek', undefined, 'Override the `seek` method on layers instead.')
855
+ deprecate('movie.timeupdate', undefined, 'Override the `progress` method on layers instead.')