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.
- package/CHANGELOG.md +43 -0
- package/CONTRIBUTING.md +23 -20
- package/README.md +3 -2
- package/dist/custom-array.d.ts +10 -0
- package/dist/effect/base.d.ts +10 -1
- package/dist/effect/shader.d.ts +11 -1
- package/dist/effect/stack.d.ts +6 -2
- package/dist/etro-cjs.js +1156 -575
- package/dist/etro-iife.js +1156 -575
- package/dist/event.d.ts +10 -5
- package/dist/layer/audio-source.d.ts +9 -4
- package/dist/layer/audio.d.ts +15 -2
- package/dist/layer/base.d.ts +49 -3
- package/dist/layer/image.d.ts +15 -1
- package/dist/layer/text.d.ts +3 -0
- package/dist/layer/video.d.ts +13 -1
- package/dist/layer/visual.d.ts +6 -2
- package/dist/movie/effects.d.ts +6 -0
- package/dist/movie/index.d.ts +1 -0
- package/dist/movie/layers.d.ts +6 -0
- package/dist/movie/movie.d.ts +260 -0
- package/dist/object.d.ts +9 -6
- package/dist/util.d.ts +4 -10
- package/eslint.conf.js +2 -2
- package/karma.conf.js +4 -7
- package/package.json +8 -7
- package/src/custom-array.ts +43 -0
- package/src/effect/base.ts +23 -22
- package/src/effect/gaussian-blur.ts +11 -6
- package/src/effect/pixelate.ts +3 -3
- package/src/effect/shader.ts +33 -27
- package/src/effect/stack.ts +43 -30
- package/src/effect/transform.ts +14 -7
- package/src/event.ts +111 -21
- package/src/layer/audio-source.ts +60 -20
- package/src/layer/audio.ts +25 -3
- package/src/layer/base.ts +79 -26
- package/src/layer/image.ts +26 -2
- package/src/layer/text.ts +7 -0
- package/src/layer/video.ts +31 -4
- package/src/layer/visual-source.ts +43 -1
- package/src/layer/visual.ts +50 -28
- package/src/movie/effects.ts +26 -0
- package/src/movie/index.ts +1 -0
- package/src/movie/layers.ts +26 -0
- package/src/movie/movie.ts +855 -0
- package/src/object.ts +9 -6
- package/src/util.ts +68 -89
- package/dist/movie.d.ts +0 -201
- package/src/movie.ts +0 -744
- /package/scripts/{gen-effect-samples.html → effect/gen-effect-samples.html} +0 -0
- /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.')
|