etro 0.6.0 → 0.8.1

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 (157) hide show
  1. package/.github/workflows/nodejs.yml +4 -2
  2. package/CHANGELOG.md +85 -4
  3. package/CODE_OF_CONDUCT.md +5 -5
  4. package/CONTRIBUTING.md +33 -79
  5. package/README.md +93 -26
  6. package/dist/effect/base.d.ts +51 -0
  7. package/dist/effect/brightness.d.ts +16 -0
  8. package/dist/effect/channels.d.ts +23 -0
  9. package/dist/effect/chroma-key.d.ts +23 -0
  10. package/dist/effect/contrast.d.ts +15 -0
  11. package/dist/effect/elliptical-mask.d.ts +31 -0
  12. package/dist/effect/gaussian-blur.d.ts +60 -0
  13. package/dist/effect/grayscale.d.ts +7 -0
  14. package/dist/effect/index.d.ts +15 -0
  15. package/dist/effect/pixelate.d.ts +18 -0
  16. package/dist/effect/shader.d.ts +99 -0
  17. package/dist/effect/stack.d.ts +23 -0
  18. package/dist/effect/transform.d.ts +73 -0
  19. package/dist/etro-cjs.js +9387 -0
  20. package/dist/etro-iife.js +9390 -0
  21. package/dist/etro.d.ts +7 -0
  22. package/dist/event.d.ts +35 -0
  23. package/dist/index.d.ts +6 -0
  24. package/dist/layer/audio-source.d.ts +24 -0
  25. package/dist/layer/audio.d.ts +14 -0
  26. package/dist/layer/base.d.ts +82 -0
  27. package/dist/layer/image.d.ts +6 -0
  28. package/dist/layer/index.d.ts +11 -0
  29. package/dist/layer/text.d.ts +60 -0
  30. package/dist/layer/video.d.ts +11 -0
  31. package/dist/layer/visual-source.d.ts +32 -0
  32. package/dist/layer/visual.d.ts +58 -0
  33. package/dist/movie.d.ts +192 -0
  34. package/dist/object.d.ts +12 -0
  35. package/dist/util.d.ts +125 -0
  36. package/eslint.conf.js +2 -9
  37. package/eslint.example-conf.js +9 -0
  38. package/eslint.test-conf.js +1 -0
  39. package/eslint.typescript-conf.js +5 -0
  40. package/examples/application/readme-screenshot.html +16 -17
  41. package/examples/application/video-player.html +10 -11
  42. package/examples/application/webcam.html +6 -6
  43. package/examples/introduction/audio.html +30 -18
  44. package/examples/introduction/effects.html +37 -14
  45. package/examples/introduction/export.html +40 -27
  46. package/examples/introduction/functions.html +6 -4
  47. package/examples/introduction/hello-world1.html +9 -5
  48. package/examples/introduction/hello-world2.html +5 -5
  49. package/examples/introduction/keyframes.html +35 -23
  50. package/examples/introduction/media.html +26 -18
  51. package/examples/introduction/text.html +9 -5
  52. package/karma.conf.js +6 -4
  53. package/package.json +34 -13
  54. package/rollup.config.js +19 -3
  55. package/scripts/gen-effect-samples.html +27 -26
  56. package/scripts/save-effect-samples.js +14 -15
  57. package/src/effect/base.ts +115 -0
  58. package/src/effect/brightness.ts +43 -0
  59. package/src/effect/channels.ts +50 -0
  60. package/src/effect/chroma-key.ts +82 -0
  61. package/src/effect/contrast.ts +42 -0
  62. package/src/effect/elliptical-mask.ts +75 -0
  63. package/src/effect/gaussian-blur.ts +232 -0
  64. package/src/effect/grayscale.ts +34 -0
  65. package/src/effect/index.ts +22 -0
  66. package/src/effect/pixelate.ts +58 -0
  67. package/src/effect/shader.ts +557 -0
  68. package/src/effect/stack.ts +77 -0
  69. package/src/effect/transform.ts +193 -0
  70. package/src/etro.ts +26 -0
  71. package/src/event.ts +112 -0
  72. package/src/index.ts +8 -0
  73. package/src/layer/audio-source.ts +219 -0
  74. package/src/layer/audio.ts +34 -0
  75. package/src/layer/base.ts +175 -0
  76. package/src/layer/image.ts +8 -0
  77. package/src/layer/index.ts +13 -0
  78. package/src/layer/text.ts +138 -0
  79. package/src/layer/video.ts +15 -0
  80. package/src/layer/visual-source.ts +150 -0
  81. package/src/layer/visual.ts +197 -0
  82. package/src/movie.ts +701 -0
  83. package/src/object.ts +14 -0
  84. package/src/util.ts +466 -0
  85. package/tsconfig.json +8 -0
  86. package/dist/etro.js +0 -3397
  87. package/docs/effect.js.html +0 -1215
  88. package/docs/event.js.html +0 -145
  89. package/docs/index.html +0 -81
  90. package/docs/index.js.html +0 -92
  91. package/docs/layer.js.html +0 -888
  92. package/docs/module-effect-GaussianBlurComponent.html +0 -345
  93. package/docs/module-effect.Brightness.html +0 -339
  94. package/docs/module-effect.Channels.html +0 -319
  95. package/docs/module-effect.ChromaKey.html +0 -611
  96. package/docs/module-effect.Contrast.html +0 -339
  97. package/docs/module-effect.EllipticalMask.html +0 -200
  98. package/docs/module-effect.GaussianBlur.html +0 -202
  99. package/docs/module-effect.GaussianBlurHorizontal.html +0 -242
  100. package/docs/module-effect.GaussianBlurVertical.html +0 -242
  101. package/docs/module-effect.Pixelate.html +0 -330
  102. package/docs/module-effect.Shader.html +0 -1227
  103. package/docs/module-effect.Stack.html +0 -406
  104. package/docs/module-effect.Transform.Matrix.html +0 -193
  105. package/docs/module-effect.Transform.html +0 -1174
  106. package/docs/module-effect.html +0 -148
  107. package/docs/module-event.html +0 -473
  108. package/docs/module-index.html +0 -186
  109. package/docs/module-layer-Media.html +0 -1116
  110. package/docs/module-layer-MediaMixin.html +0 -164
  111. package/docs/module-layer.Audio.html +0 -1188
  112. package/docs/module-layer.Base.html +0 -629
  113. package/docs/module-layer.Image.html +0 -1421
  114. package/docs/module-layer.Text.html +0 -1731
  115. package/docs/module-layer.Video.html +0 -1938
  116. package/docs/module-layer.Visual.html +0 -1698
  117. package/docs/module-layer.html +0 -137
  118. package/docs/module-movie.html +0 -3118
  119. package/docs/module-util.Color.html +0 -702
  120. package/docs/module-util.Font.html +0 -395
  121. package/docs/module-util.html +0 -845
  122. package/docs/movie.js.html +0 -689
  123. package/docs/scripts/collapse.js +0 -20
  124. package/docs/scripts/linenumber.js +0 -25
  125. package/docs/scripts/nav.js +0 -12
  126. package/docs/scripts/polyfill.js +0 -4
  127. package/docs/scripts/prettify/Apache-License-2.0.txt +0 -202
  128. package/docs/scripts/prettify/lang-css.js +0 -2
  129. package/docs/scripts/prettify/prettify.js +0 -28
  130. package/docs/scripts/search.js +0 -83
  131. package/docs/styles/jsdoc.css +0 -671
  132. package/docs/styles/prettify.css +0 -79
  133. package/docs/util.js.html +0 -503
  134. package/screenshots/2019-08-17_0.png +0 -0
  135. package/spec/assets/effect/gaussian-blur-horizontal.png +0 -0
  136. package/spec/assets/effect/gaussian-blur-vertical.png +0 -0
  137. package/spec/assets/effect/original.png +0 -0
  138. package/spec/assets/effect/pixelate.png +0 -0
  139. package/spec/assets/effect/transform/multiply.png +0 -0
  140. package/spec/assets/effect/transform/rotate.png +0 -0
  141. package/spec/assets/effect/transform/scale-fraction.png +0 -0
  142. package/spec/assets/effect/transform/scale.png +0 -0
  143. package/spec/assets/effect/transform/translate-fraction.png +0 -0
  144. package/spec/assets/effect/transform/translate.png +0 -0
  145. package/spec/assets/layer/audio.wav +0 -0
  146. package/spec/assets/layer/image.jpg +0 -0
  147. package/spec/effect.spec.js +0 -352
  148. package/spec/event.spec.js +0 -25
  149. package/spec/layer.spec.js +0 -128
  150. package/spec/movie.spec.js +0 -154
  151. package/spec/util.spec.js +0 -285
  152. package/src/effect.js +0 -1265
  153. package/src/event.js +0 -78
  154. package/src/index.js +0 -23
  155. package/src/layer.js +0 -875
  156. package/src/movie.js +0 -636
  157. package/src/util.js +0 -487
package/src/movie.ts ADDED
@@ -0,0 +1,701 @@
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
+ const layer = this.layers[i]
329
+ layer.stop()
330
+ layer.active = false
331
+ }
332
+ publish(this, 'movie.pause', {})
333
+ return this
334
+ }
335
+
336
+ /**
337
+ * Stops playback and resets the playback position
338
+ * @return the movie (for chaining)
339
+ */
340
+ stop (): Movie {
341
+ this.pause()
342
+ this.currentTime = 0
343
+ return this
344
+ }
345
+
346
+ /**
347
+ * @param [timestamp=performance.now()]
348
+ * @param [done=undefined] - called when done playing or when the current frame is loaded
349
+ * @private
350
+ */
351
+ private _render (repeat, timestamp = performance.now(), done = undefined) {
352
+ clearCachedValues(this)
353
+
354
+ if (!this.rendering) {
355
+ // (!this.paused || this._renderingFrame) is true so it's playing or it's
356
+ // rendering a single frame.
357
+ if (done)
358
+ done()
359
+
360
+ return
361
+ }
362
+
363
+ this._updateCurrentTime(timestamp)
364
+ const recordingEnd = this.recording ? this._recordEndTime : this.duration
365
+ const recordingEnded = this.currentTime > recordingEnd
366
+ if (recordingEnded)
367
+ publish(this, 'movie.recordended', { movie: this })
368
+
369
+ // Bad for performance? (remember, it's calling Array.reduce)
370
+ const end = this.duration
371
+ const ended = this.currentTime > end
372
+ if (ended) {
373
+ publish(this, 'movie.ended', { movie: this, repeat: this.repeat })
374
+ // TODO: only reset currentTime if repeating
375
+ this._currentTime = 0 // don't use setter
376
+ publish(this, 'movie.timeupdate', { movie: this })
377
+ this._lastPlayed = performance.now()
378
+ this._lastPlayedOffset = 0 // this.currentTime
379
+ this._renderingFrame = false
380
+ if (!this.repeat || this.recording) {
381
+ this._ended = true
382
+ // Deactivate all layers
383
+ for (let i = 0; i < this.layers.length; i++) {
384
+ const layer = this.layers[i]
385
+ // A layer that has been deleted before layers.length has been updated
386
+ // (see the layers proxy in the constructor).
387
+ if (!layer)
388
+ continue
389
+
390
+ layer.stop()
391
+ layer.active = false
392
+ }
393
+ }
394
+ }
395
+
396
+ // Stop playback or recording if done
397
+ if (recordingEnded || (ended && !this.repeat)) {
398
+ if (done)
399
+ done()
400
+
401
+ return
402
+ }
403
+
404
+ // Do render
405
+ this._renderBackground(timestamp)
406
+ const frameFullyLoaded = this._renderLayers()
407
+ this._applyEffects()
408
+
409
+ if (frameFullyLoaded)
410
+ publish(this, 'movie.loadeddata', { movie: this })
411
+
412
+ // If didn't load in this instant, repeatedly frame-render until frame is
413
+ // loaded.
414
+ // If the expression below is false, don't publish an event, just silently
415
+ // stop render loop.
416
+ if (!repeat || (this._renderingFrame && frameFullyLoaded)) {
417
+ this._renderingFrame = false
418
+ if (done)
419
+ done()
420
+
421
+ return
422
+ }
423
+
424
+ window.requestAnimationFrame(timestamp => {
425
+ this._render(repeat, timestamp)
426
+ }) // TODO: research performance cost
427
+ }
428
+
429
+ private _updateCurrentTime (timestamp) {
430
+ // If we're only instant-rendering (current frame only), it doens't matter
431
+ // if it's paused or not.
432
+ if (!this._renderingFrame) {
433
+ // if ((timestamp - this._lastUpdate) >= this._updateInterval) {
434
+ const sinceLastPlayed = (timestamp - this._lastPlayed) / 1000
435
+ this._currentTime = this._lastPlayedOffset + sinceLastPlayed // don't use setter
436
+ publish(this, 'movie.timeupdate', { movie: this })
437
+ // this._lastUpdate = timestamp;
438
+ // }
439
+ }
440
+ }
441
+
442
+ private _renderBackground (timestamp) {
443
+ this.cctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
444
+ const background = val(this, 'background', timestamp)
445
+ if (background) { // TODO: check val'd result
446
+ this.cctx.fillStyle = background
447
+ this.cctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
448
+ }
449
+ }
450
+
451
+ /**
452
+ * @return whether or not video frames are loaded
453
+ * @param [timestamp=performance.now()]
454
+ * @private
455
+ */
456
+ private _renderLayers () {
457
+ let frameFullyLoaded = true
458
+ for (let i = 0; i < this.layers.length; 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)
463
+ continue
464
+
465
+ const reltime = this.currentTime - layer.startTime
466
+ // Cancel operation if layer disabled or outside layer time interval
467
+ if (!val(layer, 'enabled', reltime) ||
468
+ // TODO > or >= ?
469
+ this.currentTime < layer.startTime || this.currentTime > layer.startTime + layer.duration) {
470
+ // Layer is not active.
471
+ // If only rendering this frame, we are not "starting" the layer.
472
+ if (layer.active && !this._renderingFrame) {
473
+ // TODO: make a `deactivate()` method?
474
+ layer.stop()
475
+ layer.active = false
476
+ }
477
+ continue
478
+ }
479
+ // If only rendering this frame, we are not "starting" the layer
480
+ if (!layer.active && val(layer, 'enabled', reltime) && !this._renderingFrame) {
481
+ // TODO: make an `activate()` method?
482
+ layer.start()
483
+ layer.active = true
484
+ }
485
+
486
+ // if the layer has an input file
487
+ if ('source' in layer)
488
+ frameFullyLoaded = frameFullyLoaded && (layer as unknown as AudioSource).source.readyState >= 2
489
+
490
+ layer.render()
491
+
492
+ // if the layer has visual component
493
+ if (layer instanceof Visual) {
494
+ const canvas = (layer as Visual).canvas
495
+ // layer.canvas.width and layer.canvas.height should already be interpolated
496
+ // if the layer has an area (else InvalidStateError from canvas)
497
+ if (canvas.width * canvas.height > 0)
498
+ this.cctx.drawImage(canvas,
499
+ val(layer, 'x', reltime), val(layer, 'y', reltime), canvas.width, canvas.height
500
+ )
501
+ }
502
+ }
503
+
504
+ return frameFullyLoaded
505
+ }
506
+
507
+ private _applyEffects () {
508
+ for (let i = 0; i < this.effects.length; i++) {
509
+ const effect = this.effects[i]
510
+ // An effect that has been deleted before effects.length has been updated
511
+ // (see the effectsproxy in the constructor).
512
+ if (!effect)
513
+ continue
514
+
515
+ effect.apply(this, this.currentTime)
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Refreshes the screen (only use this if auto-refresh is disabled)
521
+ * @return - resolves when the frame is loaded
522
+ */
523
+ refresh (): Promise<null> {
524
+ return new Promise(resolve => {
525
+ this._renderingFrame = true
526
+ this._render(false, undefined, resolve)
527
+ })
528
+ }
529
+
530
+ /**
531
+ * Convienence method
532
+ */
533
+ private _publishToLayers (type, event) {
534
+ for (let i = 0; i < this.layers.length; i++)
535
+ publish(this.layers[i], type, event)
536
+ }
537
+
538
+ /**
539
+ * If the movie is playing, recording or refreshing
540
+ */
541
+ get rendering (): boolean {
542
+ return !this.paused || this._renderingFrame
543
+ }
544
+
545
+ /**
546
+ * If the movie is refreshing current frame
547
+ */
548
+ get renderingFrame (): boolean {
549
+ return this._renderingFrame
550
+ }
551
+
552
+ /**
553
+ * If the movie is recording
554
+ */
555
+ get recording (): boolean {
556
+ return !!this._mediaRecorder
557
+ }
558
+
559
+ /**
560
+ * The combined duration of all layers
561
+ */
562
+ // TODO: dirty flag?
563
+ get duration (): number {
564
+ return this.layers.reduce((end, layer) => Math.max(layer.startTime + layer.duration, end), 0)
565
+ }
566
+
567
+ /**
568
+ * Convienence method for <code>layers.push()</code>
569
+ * @param layer
570
+ * @return the movie
571
+ */
572
+ addLayer (layer: BaseLayer): Movie {
573
+ this.layers.push(layer); return this
574
+ }
575
+
576
+ /**
577
+ * Convienence method for <code>effects.push()</code>
578
+ * @param effect
579
+ * @return the movie
580
+ */
581
+ addEffect (effect: BaseEffect): Movie {
582
+ this.effects.push(effect); return this
583
+ }
584
+
585
+ /**
586
+ */
587
+ get paused (): boolean {
588
+ return this._paused
589
+ }
590
+
591
+ /**
592
+ * If the playback position is at the end of the movie
593
+ */
594
+ get ended (): boolean {
595
+ return this._ended
596
+ }
597
+
598
+ /**
599
+ * The current playback position
600
+ */
601
+ get currentTime (): number {
602
+ return this._currentTime
603
+ }
604
+
605
+ set currentTime (time: number) {
606
+ this._currentTime = time
607
+ publish(this, 'movie.seek', {})
608
+ // Render single frame to match new time
609
+ this.refresh()
610
+ }
611
+
612
+ /**
613
+ * Sets the current playback position. This is a more powerful version of
614
+ * `set currentTime`.
615
+ *
616
+ * @param time - the new cursor's time value in seconds
617
+ * @param [refresh=true] - whether to render a single frame
618
+ * @return resolves when the current frame is rendered if
619
+ * <code>refresh</code> is true, otherwise resolves immediately
620
+ *
621
+ */
622
+ // TODO: Refresh if only auto-refreshing is enabled
623
+ setCurrentTime (time: number, refresh = true): Promise<void> {
624
+ return new Promise((resolve, reject) => {
625
+ this._currentTime = time
626
+ publish(this, 'movie.seek', {})
627
+ if (refresh)
628
+ // Pass promise callbacks to `refresh`
629
+ this.refresh().then(resolve).catch(reject)
630
+ else
631
+ resolve()
632
+ })
633
+ }
634
+
635
+ /**
636
+ * The rendering canvas
637
+ */
638
+ get canvas (): HTMLCanvasElement {
639
+ return this._canvas
640
+ }
641
+
642
+ /**
643
+ * The rendering canvas's context
644
+ */
645
+ get cctx (): CanvasRenderingContext2D {
646
+ return this._cctx
647
+ }
648
+
649
+ /**
650
+ * The width of the rendering canvas
651
+ */
652
+ get width (): number {
653
+ return this.canvas.width
654
+ }
655
+
656
+ set width (width: number) {
657
+ this.canvas.width = width
658
+ }
659
+
660
+ /**
661
+ * The height of the rendering canvas
662
+ */
663
+ get height (): number {
664
+ return this.canvas.height
665
+ }
666
+
667
+ set height (height: number) {
668
+ this.canvas.height = height
669
+ }
670
+
671
+ get movie (): Movie {
672
+ return this
673
+ }
674
+
675
+ getDefaultOptions (): MovieOptions & { _actx: AudioContext } {
676
+ return {
677
+ canvas: undefined, // required
678
+ _actx: new AudioContext(),
679
+ /**
680
+ * @name module:movie#background
681
+ * @desc The css color for the background, or <code>null</code> for transparency
682
+ */
683
+ background: '#000',
684
+ /**
685
+ * @name module:movie#repeat
686
+ */
687
+ repeat: false,
688
+ /**
689
+ * @name module:movie#autoRefresh
690
+ * @desc Whether to refresh when changes are made that would effect the current frame
691
+ */
692
+ autoRefresh: true
693
+ }
694
+ }
695
+ }
696
+
697
+ // id for events (independent of instance, but easy to access when on prototype chain)
698
+ Movie.prototype.type = 'movie'
699
+ // TODO: refactor so we don't need to explicitly exclude some of these
700
+ Movie.prototype.publicExcludes = ['canvas', 'cctx', 'actx', 'layers', 'effects']
701
+ Movie.prototype.propertyFilters = {}
package/src/object.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { Movie } from './movie'
2
+
3
+ /** A movie, layer or effect */
4
+ export default interface EtroObject {
5
+ /** Used in etro internals */
6
+ type: string
7
+ /** Which properties to not watch for changes, for `Movie#autoRefresh` */
8
+ publicExcludes: string[]
9
+ /** Map of property name to function to run on result of `val` */
10
+ propertyFilters: Record<string, <T>(value: T) => T>
11
+ movie: Movie
12
+
13
+ getDefaultOptions(): object // eslint-disable-line @typescript-eslint/ban-types
14
+ }