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.
- package/.github/workflows/nodejs.yml +4 -2
- package/CHANGELOG.md +85 -4
- package/CODE_OF_CONDUCT.md +5 -5
- package/CONTRIBUTING.md +33 -79
- package/README.md +93 -26
- package/dist/effect/base.d.ts +51 -0
- package/dist/effect/brightness.d.ts +16 -0
- package/dist/effect/channels.d.ts +23 -0
- package/dist/effect/chroma-key.d.ts +23 -0
- package/dist/effect/contrast.d.ts +15 -0
- package/dist/effect/elliptical-mask.d.ts +31 -0
- package/dist/effect/gaussian-blur.d.ts +60 -0
- package/dist/effect/grayscale.d.ts +7 -0
- package/dist/effect/index.d.ts +15 -0
- package/dist/effect/pixelate.d.ts +18 -0
- package/dist/effect/shader.d.ts +99 -0
- package/dist/effect/stack.d.ts +23 -0
- package/dist/effect/transform.d.ts +73 -0
- package/dist/etro-cjs.js +9387 -0
- package/dist/etro-iife.js +9390 -0
- package/dist/etro.d.ts +7 -0
- package/dist/event.d.ts +35 -0
- package/dist/index.d.ts +6 -0
- package/dist/layer/audio-source.d.ts +24 -0
- package/dist/layer/audio.d.ts +14 -0
- package/dist/layer/base.d.ts +82 -0
- package/dist/layer/image.d.ts +6 -0
- package/dist/layer/index.d.ts +11 -0
- package/dist/layer/text.d.ts +60 -0
- package/dist/layer/video.d.ts +11 -0
- package/dist/layer/visual-source.d.ts +32 -0
- package/dist/layer/visual.d.ts +58 -0
- package/dist/movie.d.ts +192 -0
- package/dist/object.d.ts +12 -0
- package/dist/util.d.ts +125 -0
- package/eslint.conf.js +2 -9
- package/eslint.example-conf.js +9 -0
- package/eslint.test-conf.js +1 -0
- package/eslint.typescript-conf.js +5 -0
- package/examples/application/readme-screenshot.html +16 -17
- package/examples/application/video-player.html +10 -11
- package/examples/application/webcam.html +6 -6
- package/examples/introduction/audio.html +30 -18
- package/examples/introduction/effects.html +37 -14
- package/examples/introduction/export.html +40 -27
- package/examples/introduction/functions.html +6 -4
- package/examples/introduction/hello-world1.html +9 -5
- package/examples/introduction/hello-world2.html +5 -5
- package/examples/introduction/keyframes.html +35 -23
- package/examples/introduction/media.html +26 -18
- package/examples/introduction/text.html +9 -5
- package/karma.conf.js +6 -4
- package/package.json +34 -13
- package/rollup.config.js +19 -3
- package/scripts/gen-effect-samples.html +27 -26
- package/scripts/save-effect-samples.js +14 -15
- package/src/effect/base.ts +115 -0
- package/src/effect/brightness.ts +43 -0
- package/src/effect/channels.ts +50 -0
- package/src/effect/chroma-key.ts +82 -0
- package/src/effect/contrast.ts +42 -0
- package/src/effect/elliptical-mask.ts +75 -0
- package/src/effect/gaussian-blur.ts +232 -0
- package/src/effect/grayscale.ts +34 -0
- package/src/effect/index.ts +22 -0
- package/src/effect/pixelate.ts +58 -0
- package/src/effect/shader.ts +557 -0
- package/src/effect/stack.ts +77 -0
- package/src/effect/transform.ts +193 -0
- package/src/etro.ts +26 -0
- package/src/event.ts +112 -0
- package/src/index.ts +8 -0
- package/src/layer/audio-source.ts +219 -0
- package/src/layer/audio.ts +34 -0
- package/src/layer/base.ts +175 -0
- package/src/layer/image.ts +8 -0
- package/src/layer/index.ts +13 -0
- package/src/layer/text.ts +138 -0
- package/src/layer/video.ts +15 -0
- package/src/layer/visual-source.ts +150 -0
- package/src/layer/visual.ts +197 -0
- package/src/movie.ts +701 -0
- package/src/object.ts +14 -0
- package/src/util.ts +466 -0
- package/tsconfig.json +8 -0
- package/dist/etro.js +0 -3397
- package/docs/effect.js.html +0 -1215
- package/docs/event.js.html +0 -145
- package/docs/index.html +0 -81
- package/docs/index.js.html +0 -92
- package/docs/layer.js.html +0 -888
- package/docs/module-effect-GaussianBlurComponent.html +0 -345
- package/docs/module-effect.Brightness.html +0 -339
- package/docs/module-effect.Channels.html +0 -319
- package/docs/module-effect.ChromaKey.html +0 -611
- package/docs/module-effect.Contrast.html +0 -339
- package/docs/module-effect.EllipticalMask.html +0 -200
- package/docs/module-effect.GaussianBlur.html +0 -202
- package/docs/module-effect.GaussianBlurHorizontal.html +0 -242
- package/docs/module-effect.GaussianBlurVertical.html +0 -242
- package/docs/module-effect.Pixelate.html +0 -330
- package/docs/module-effect.Shader.html +0 -1227
- package/docs/module-effect.Stack.html +0 -406
- package/docs/module-effect.Transform.Matrix.html +0 -193
- package/docs/module-effect.Transform.html +0 -1174
- package/docs/module-effect.html +0 -148
- package/docs/module-event.html +0 -473
- package/docs/module-index.html +0 -186
- package/docs/module-layer-Media.html +0 -1116
- package/docs/module-layer-MediaMixin.html +0 -164
- package/docs/module-layer.Audio.html +0 -1188
- package/docs/module-layer.Base.html +0 -629
- package/docs/module-layer.Image.html +0 -1421
- package/docs/module-layer.Text.html +0 -1731
- package/docs/module-layer.Video.html +0 -1938
- package/docs/module-layer.Visual.html +0 -1698
- package/docs/module-layer.html +0 -137
- package/docs/module-movie.html +0 -3118
- package/docs/module-util.Color.html +0 -702
- package/docs/module-util.Font.html +0 -395
- package/docs/module-util.html +0 -845
- package/docs/movie.js.html +0 -689
- package/docs/scripts/collapse.js +0 -20
- package/docs/scripts/linenumber.js +0 -25
- package/docs/scripts/nav.js +0 -12
- package/docs/scripts/polyfill.js +0 -4
- package/docs/scripts/prettify/Apache-License-2.0.txt +0 -202
- package/docs/scripts/prettify/lang-css.js +0 -2
- package/docs/scripts/prettify/prettify.js +0 -28
- package/docs/scripts/search.js +0 -83
- package/docs/styles/jsdoc.css +0 -671
- package/docs/styles/prettify.css +0 -79
- package/docs/util.js.html +0 -503
- package/screenshots/2019-08-17_0.png +0 -0
- package/spec/assets/effect/gaussian-blur-horizontal.png +0 -0
- package/spec/assets/effect/gaussian-blur-vertical.png +0 -0
- package/spec/assets/effect/original.png +0 -0
- package/spec/assets/effect/pixelate.png +0 -0
- package/spec/assets/effect/transform/multiply.png +0 -0
- package/spec/assets/effect/transform/rotate.png +0 -0
- package/spec/assets/effect/transform/scale-fraction.png +0 -0
- package/spec/assets/effect/transform/scale.png +0 -0
- package/spec/assets/effect/transform/translate-fraction.png +0 -0
- package/spec/assets/effect/transform/translate.png +0 -0
- package/spec/assets/layer/audio.wav +0 -0
- package/spec/assets/layer/image.jpg +0 -0
- package/spec/effect.spec.js +0 -352
- package/spec/event.spec.js +0 -25
- package/spec/layer.spec.js +0 -128
- package/spec/movie.spec.js +0 -154
- package/spec/util.spec.js +0 -285
- package/src/effect.js +0 -1265
- package/src/event.js +0 -78
- package/src/index.js +0 -23
- package/src/layer.js +0 -875
- package/src/movie.js +0 -636
- 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
|
+
}
|