etro 0.7.0 → 0.8.2

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