etro 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/.env +2 -0
  2. package/.github/workflows/nodejs.yml +27 -0
  3. package/CHANGELOG.md +109 -0
  4. package/CODE_OF_CONDUCT.md +77 -0
  5. package/CONTRIBUTING.md +155 -0
  6. package/LICENSE +674 -0
  7. package/README.md +57 -0
  8. package/dist/etro.js +3397 -0
  9. package/docs/effect.js.html +1215 -0
  10. package/docs/event.js.html +145 -0
  11. package/docs/index.html +81 -0
  12. package/docs/index.js.html +92 -0
  13. package/docs/layer.js.html +888 -0
  14. package/docs/module-effect-GaussianBlurComponent.html +345 -0
  15. package/docs/module-effect.Brightness.html +339 -0
  16. package/docs/module-effect.Channels.html +319 -0
  17. package/docs/module-effect.ChromaKey.html +611 -0
  18. package/docs/module-effect.Contrast.html +339 -0
  19. package/docs/module-effect.EllipticalMask.html +200 -0
  20. package/docs/module-effect.GaussianBlur.html +202 -0
  21. package/docs/module-effect.GaussianBlurHorizontal.html +242 -0
  22. package/docs/module-effect.GaussianBlurVertical.html +242 -0
  23. package/docs/module-effect.Pixelate.html +330 -0
  24. package/docs/module-effect.Shader.html +1227 -0
  25. package/docs/module-effect.Stack.html +406 -0
  26. package/docs/module-effect.Transform.Matrix.html +193 -0
  27. package/docs/module-effect.Transform.html +1174 -0
  28. package/docs/module-effect.html +148 -0
  29. package/docs/module-event.html +473 -0
  30. package/docs/module-index.html +186 -0
  31. package/docs/module-layer-Media.html +1116 -0
  32. package/docs/module-layer-MediaMixin.html +164 -0
  33. package/docs/module-layer.Audio.html +1188 -0
  34. package/docs/module-layer.Base.html +629 -0
  35. package/docs/module-layer.Image.html +1421 -0
  36. package/docs/module-layer.Text.html +1731 -0
  37. package/docs/module-layer.Video.html +1938 -0
  38. package/docs/module-layer.Visual.html +1698 -0
  39. package/docs/module-layer.html +137 -0
  40. package/docs/module-movie.html +3118 -0
  41. package/docs/module-util.Color.html +702 -0
  42. package/docs/module-util.Font.html +395 -0
  43. package/docs/module-util.html +845 -0
  44. package/docs/movie.js.html +689 -0
  45. package/docs/scripts/collapse.js +20 -0
  46. package/docs/scripts/linenumber.js +25 -0
  47. package/docs/scripts/nav.js +12 -0
  48. package/docs/scripts/polyfill.js +4 -0
  49. package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
  50. package/docs/scripts/prettify/lang-css.js +2 -0
  51. package/docs/scripts/prettify/prettify.js +28 -0
  52. package/docs/scripts/search.js +83 -0
  53. package/docs/styles/jsdoc.css +671 -0
  54. package/docs/styles/prettify.css +79 -0
  55. package/docs/util.js.html +503 -0
  56. package/eslint.conf.js +28 -0
  57. package/eslint.test-conf.js +4 -0
  58. package/examples/application/readme-screenshot.html +86 -0
  59. package/examples/application/video-player.html +131 -0
  60. package/examples/application/webcam.html +28 -0
  61. package/examples/introduction/audio.html +52 -0
  62. package/examples/introduction/effects.html +56 -0
  63. package/examples/introduction/export.html +70 -0
  64. package/examples/introduction/functions.html +35 -0
  65. package/examples/introduction/hello-world1.html +33 -0
  66. package/examples/introduction/hello-world2.html +32 -0
  67. package/examples/introduction/keyframes.html +67 -0
  68. package/examples/introduction/media.html +55 -0
  69. package/examples/introduction/text.html +27 -0
  70. package/jsdoc.conf.json +3 -0
  71. package/karma.conf.js +60 -0
  72. package/package.json +63 -0
  73. package/private-todo.txt +70 -0
  74. package/rename-file.sh +18 -0
  75. package/rename-versions.sh +14 -0
  76. package/rename.sh +22 -0
  77. package/rollup.config.js +31 -0
  78. package/screenshots/2019-08-17_0.png +0 -0
  79. package/scripts/gen-effect-samples.html +99 -0
  80. package/scripts/save-effect-samples.js +43 -0
  81. package/spec/assets/effect/gaussian-blur-horizontal.png +0 -0
  82. package/spec/assets/effect/gaussian-blur-vertical.png +0 -0
  83. package/spec/assets/effect/original.png +0 -0
  84. package/spec/assets/effect/pixelate.png +0 -0
  85. package/spec/assets/effect/transform/multiply.png +0 -0
  86. package/spec/assets/effect/transform/rotate.png +0 -0
  87. package/spec/assets/effect/transform/scale-fraction.png +0 -0
  88. package/spec/assets/effect/transform/scale.png +0 -0
  89. package/spec/assets/effect/transform/translate-fraction.png +0 -0
  90. package/spec/assets/effect/transform/translate.png +0 -0
  91. package/spec/assets/layer/audio.wav +0 -0
  92. package/spec/assets/layer/image.jpg +0 -0
  93. package/spec/effect.spec.js +352 -0
  94. package/spec/event.spec.js +25 -0
  95. package/spec/layer.spec.js +128 -0
  96. package/spec/movie.spec.js +154 -0
  97. package/spec/util.spec.js +285 -0
  98. package/src/effect.js +1265 -0
  99. package/src/event.js +78 -0
  100. package/src/index.js +23 -0
  101. package/src/layer.js +875 -0
  102. package/src/movie.js +636 -0
  103. package/src/util.js +487 -0
package/src/layer.js ADDED
@@ -0,0 +1,875 @@
1
+ /**
2
+ * @module layer
3
+ * @todo Add aligning options, like horizontal and vertical align modes
4
+ */
5
+
6
+ import { publish, subscribe } from './event.js'
7
+ import { watchPublic, val, applyOptions } from './util.js'
8
+
9
+ /**
10
+ * A layer is a piece of content for the movie
11
+ */
12
+ export class Base {
13
+ /**
14
+ * Creates a new empty layer
15
+ *
16
+ * @param {number} startTime - when to start the layer on the movie's timeline
17
+ * @param {number} duration - how long the layer should last on the movie's timeline
18
+ * @param {object} [options] - no options, here for consistency
19
+ */
20
+ constructor (startTime, duration, options = {}) { // rn, options isn't used but I'm keeping it here
21
+ const newThis = watchPublic(this) // proxy that will be returned by constructor
22
+ // Don't send updates when initializing, so use this instead of newThis:
23
+ applyOptions(options, this) // no options rn, but just to stick to protocol
24
+
25
+ this._startTime = startTime
26
+ this._duration = duration
27
+
28
+ this._active = false // whether newThis layer is currently being rendered
29
+ this.enabled = true
30
+
31
+ this._movie = null
32
+
33
+ // Propogate up to target
34
+ subscribe(newThis, 'layer.change', event => {
35
+ const typeOfChange = event.type.substring(event.type.lastIndexOf('.') + 1)
36
+ const type = `movie.change.layer.${typeOfChange}`
37
+ publish(newThis._movie, type, { ...event, target: newThis._movie, type })
38
+ })
39
+
40
+ return newThis
41
+ }
42
+
43
+ attach (movie) {
44
+ this._movie = movie
45
+ }
46
+
47
+ detach () {
48
+ this._movie = null
49
+ }
50
+
51
+ /**
52
+ * Called when the layer is activated
53
+ */
54
+ start () {}
55
+
56
+ /**
57
+ * Called when the movie renders and the layer is active
58
+ */
59
+ render () {}
60
+
61
+ /**
62
+ * Called when the layer is deactivated
63
+ */
64
+ stop () {}
65
+
66
+ get parent () {
67
+ return this._movie
68
+ }
69
+
70
+ /**
71
+ * If the attached movie's playback position is in this layer
72
+ * @type boolean
73
+ */
74
+ get active () {
75
+ return this._active
76
+ }
77
+
78
+ /**
79
+ * @type number
80
+ */
81
+ get startTime () {
82
+ return this._startTime
83
+ }
84
+
85
+ set startTime (val) {
86
+ this._startTime = val
87
+ }
88
+
89
+ /**
90
+ * The current time of the movie relative to this layer
91
+ * @type number
92
+ */
93
+ get currentTime () {
94
+ return this._movie ? this._movie.currentTime - this.startTime
95
+ : undefined
96
+ }
97
+
98
+ /**
99
+ * @type number
100
+ */
101
+ get duration () {
102
+ return this._duration
103
+ }
104
+
105
+ set duration (val) {
106
+ this._duration = val
107
+ }
108
+
109
+ get movie () {
110
+ return this._movie
111
+ }
112
+
113
+ getDefaultOptions () {
114
+ return {}
115
+ }
116
+ }
117
+ // id for events (independent of instance, but easy to access when on prototype chain)
118
+ Base.prototype.type = 'layer'
119
+ Base.prototype.publicExcludes = []
120
+ Base.prototype.propertyFilters = {}
121
+
122
+ /** Any layer that renders to a canvas */
123
+ export class Visual extends Base {
124
+ /**
125
+ * Creates a visual layer
126
+ *
127
+ * @param {number} startTime - when to start the layer on the movie's timeline
128
+ * @param {number} duration - how long the layer should last on the movie's timeline
129
+ * @param {object} [options] - various optional arguments
130
+ * @param {number} [options.width=null] - the width of the entire layer
131
+ * @param {number} [options.height=null] - the height of the entire layer
132
+ * @param {number} [options.x=0] - the offset of the layer relative to the movie
133
+ * @param {number} [options.y=0] - the offset of the layer relative to the movie
134
+ * @param {string} [options.background=null] - the background color of the layer, or <code>null</code>
135
+ * for a transparent background
136
+ * @param {object} [options.border=null] - the layer's outline, or <code>null</code> for no outline
137
+ * @param {string} [options.border.color] - the outline's color; required for a border
138
+ * @param {string} [options.border.thickness=1] - the outline's weight
139
+ * @param {number} [options.opacity=1] - the layer's opacity; <code>1</cod> for full opacity
140
+ * and <code>0</code> for full transparency
141
+ */
142
+ constructor (startTime, duration, options = {}) {
143
+ super(startTime, duration, options)
144
+ // only validate extra if not subclassed, because if subclcass, there will be extraneous options
145
+ applyOptions(options, this)
146
+
147
+ this._canvas = document.createElement('canvas')
148
+ this._cctx = this.canvas.getContext('2d')
149
+
150
+ this._effectsBack = []
151
+ const that = this
152
+ this._effects = new Proxy(this._effectsBack, {
153
+ apply: function (target, thisArg, argumentsList) {
154
+ return thisArg[target].apply(this, argumentsList)
155
+ },
156
+ deleteProperty: function (target, property) {
157
+ const value = target[property]
158
+ value.detach()
159
+ delete target[property]
160
+ return true
161
+ },
162
+ set: function (target, property, value, receiver) {
163
+ target[property] = value
164
+ if (!isNaN(property)) { // if property is an number (index)
165
+ value.attach(that)
166
+ }
167
+ return true
168
+ }
169
+ })
170
+ }
171
+
172
+ /**
173
+ * Render visual output
174
+ */
175
+ render (reltime) {
176
+ this.beginRender(reltime)
177
+ this.doRender(reltime)
178
+ this.endRender(reltime)
179
+ }
180
+
181
+ beginRender (reltime) {
182
+ // if this.width or this.height is null, that means "take all available screen space", so set it to
183
+ // this._move.width or this._movie.height, respectively
184
+ const w = val(this, 'width', reltime) || val(this._movie, 'width', this.startTime + reltime)
185
+ const h = val(this, 'height', reltime) || val(this._movie, 'height', this.startTime + reltime)
186
+ this.canvas.width = w
187
+ this.canvas.height = h
188
+ this.cctx.globalAlpha = val(this, 'opacity', reltime)
189
+ }
190
+
191
+ doRender (reltime) {
192
+ // if this.width or this.height is null, that means "take all available screen space", so set it to
193
+ // this._move.width or this._movie.height, respectively
194
+ // canvas.width & canvas.height are already interpolated
195
+ if (this.background) {
196
+ this.cctx.fillStyle = val(this, 'background', reltime)
197
+ this.cctx.fillRect(0, 0, this.canvas.width, this.canvas.height) // (0, 0) relative to layer
198
+ }
199
+ if (this.border && this.border.color) {
200
+ this.cctx.strokeStyle = val(this, 'border.color', reltime)
201
+ this.cctx.lineWidth = val(this, 'border.thickness', reltime) || 1 // this is optional.. TODO: integrate this with defaultOptions
202
+ }
203
+ }
204
+
205
+ endRender (reltime) {
206
+ const w = val(this, 'width', reltime) || val(this._movie, 'width', this.startTime + reltime)
207
+ const h = val(this, 'height', reltime) || val(this._movie, 'height', this.startTime + reltime)
208
+ if (w * h > 0) {
209
+ this._applyEffects()
210
+ }
211
+ // else InvalidStateError for drawing zero-area image in some effects, right?
212
+ }
213
+
214
+ _applyEffects () {
215
+ for (let i = 0; i < this.effects.length; i++) {
216
+ const effect = this.effects[i]
217
+ if (effect.enabled) {
218
+ effect.apply(this, this._movie.currentTime - this.startTime) // pass relative time
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Convienence method for <code>effects.push()</code>
225
+ * @param {BaseEffect} effect
226
+ * @return {module:layer.Visual} the layer (for chaining)
227
+ */
228
+ addEffect (effect) {
229
+ this.effects.push(effect); return this
230
+ }
231
+
232
+ /**
233
+ * The intermediate rendering canvas
234
+ * @type HTMLCanvasElement
235
+ */
236
+ get canvas () {
237
+ return this._canvas
238
+ }
239
+
240
+ /**
241
+ * The context of {@link module:layer.Visual#canvas}
242
+ * @type CanvasRenderingContext2D
243
+ */
244
+ get cctx () {
245
+ return this._cctx
246
+ }
247
+
248
+ /**
249
+ * @type effect.Base[]
250
+ */
251
+ get effects () {
252
+ return this._effects // priavte (because it's a proxy)
253
+ }
254
+
255
+ getDefaultOptions () {
256
+ return {
257
+ ...Base.prototype.getDefaultOptions(),
258
+ /**
259
+ * @name module:layer.Visual#x
260
+ * @type number
261
+ * @desc The offset of the layer relative to the movie
262
+ */
263
+ x: 0,
264
+ /**
265
+ * @name module:layer.Visual#y
266
+ * @type number
267
+ * @desc The offset of the layer relative to the movie
268
+ */
269
+ y: 0,
270
+ /**
271
+ * @name module:layer.Visual#width
272
+ * @type number
273
+ */
274
+ width: null,
275
+ /**
276
+ * @name module:layer.Visual#height
277
+ * @type number
278
+ */
279
+ height: null,
280
+ /**
281
+ * @name module:layer.Visual#background
282
+ * @type string
283
+ * @desc The css color code for the background, or <code>null</code> for transparency
284
+ */
285
+ background: null,
286
+ /**
287
+ * @name module:layer.Visual#border
288
+ * @type string
289
+ * @desc The css border style, or <code>null</code> for no border
290
+ */
291
+ border: null,
292
+ /**
293
+ * @name module:layer.Visual#opacity
294
+ * @type number
295
+ */
296
+ opacity: 1
297
+ }
298
+ }
299
+ }
300
+ Visual.prototype.publicExcludes = Base.prototype.publicExcludes.concat(['canvas', 'cctx', 'effects'])
301
+ Visual.prototype.propertyFilters = {
302
+ ...Base.propertyFilters,
303
+ width: function (width) {
304
+ return width !== undefined ? width : this._movie.width
305
+ },
306
+ height: function (height) {
307
+ return height !== undefined ? height : this._movie.height
308
+ }
309
+ }
310
+
311
+ export class Text extends Visual {
312
+ // TODO: is textX necessary? it seems inconsistent, because you can't define width/height directly for a text layer
313
+ /**
314
+ * Creates a new text layer
315
+ *
316
+ * @param {number} startTime
317
+ * @param {number} duration
318
+ * @param {string} text - the text to display
319
+ * @param {number} width - the width of the entire layer
320
+ * @param {number} height - the height of the entire layer
321
+ * @param {object} [options] - various optional arguments
322
+ * @param {number} [options.x=0] - the horizontal position of the layer (relative to the movie)
323
+ * @param {number} [options.y=0] - the vertical position of the layer (relative to the movie)
324
+ * @param {string} [options.background=null] - the background color of the layer, or <code>null</code>
325
+ * for a transparent background
326
+ * @param {object} [options.border=null] - the layer's outline, or <code>null</code> for no outline
327
+ * @param {string} [options.border.color] - the outline"s color; required for a border
328
+ * @param {string} [options.border.thickness=1] - the outline"s weight
329
+ * @param {number} [options.opacity=1] - the layer"s opacity; <code>1</cod> for full opacity
330
+ * and <code>0</code> for full transparency
331
+ * @param {string} [options.font="10px sans-serif"]
332
+ * @param {string} [options.color="#fff"]
333
+ * @param {number} [options.textX=0] - the text's horizontal offset relative to the layer
334
+ * @param {number} [options.textY=0] - the text's vertical offset relative to the layer
335
+ * @param {number} [options.maxWidth=null] - the maximum width of a line of text
336
+ * @param {string} [options.textAlign="start"] - horizontal align
337
+ * @param {string} [options.textBaseline="top"] - vertical align
338
+ * @param {string} [options.textDirection="ltr"] - the text direction
339
+ *
340
+ * @todo add padding options
341
+ */
342
+ constructor (startTime, duration, text, options = {}) {
343
+ // default to no (transparent) background
344
+ super(startTime, duration, { background: null, ...options }) // fill in zeros in |doRender|
345
+ applyOptions(options, this)
346
+
347
+ /**
348
+ * @type string
349
+ */
350
+ this.text = text
351
+
352
+ // this._prevText = undefined;
353
+ // // because the canvas context rounds font size, but we need to be more accurate
354
+ // // rn, this doesn't make a difference, because we can only measure metrics by integer font sizes
355
+ // this._lastFont = undefined;
356
+ // this._prevMaxWidth = undefined;
357
+ }
358
+
359
+ doRender (reltime) {
360
+ super.doRender(reltime)
361
+ const text = val(this, 'text', reltime); const font = val(this, 'font', reltime)
362
+ const maxWidth = this.maxWidth ? val(this, 'maxWidth', reltime) : undefined
363
+ // // properties that affect metrics
364
+ // if (this._prevText !== text || this._prevFont !== font || this._prevMaxWidth !== maxWidth)
365
+ // this._updateMetrics(text, font, maxWidth);
366
+
367
+ this.cctx.font = font
368
+ this.cctx.fillStyle = val(this, 'color', reltime)
369
+ this.cctx.textAlign = val(this, 'textAlign', reltime)
370
+ this.cctx.textBaseline = val(this, 'textBaseline', reltime)
371
+ this.cctx.textDirection = val(this, 'textDirection', reltime)
372
+ this.cctx.fillText(
373
+ text, val(this, 'textX', reltime), val(this, 'textY', reltime),
374
+ maxWidth
375
+ )
376
+
377
+ this._prevText = text
378
+ this._prevFont = font
379
+ this._prevMaxWidth = maxWidth
380
+ }
381
+
382
+ // _updateMetrics(text, font, maxWidth) {
383
+ // // TODO calculate / measure for non-integer font.size values
384
+ // let metrics = Text._measureText(text, font, maxWidth);
385
+ // // TODO: allow user-specified/overwritten width/height
386
+ // this.width = /*this.width || */metrics.width;
387
+ // this.height = /*this.height || */metrics.height;
388
+ // }
389
+
390
+ // TODO: implement setters and getters that update dimensions!
391
+
392
+ /* static _measureText(text, font, maxWidth) {
393
+ // TODO: fix too much bottom padding
394
+ const s = document.createElement("span");
395
+ s.textContent = text;
396
+ s.style.font = font;
397
+ s.style.padding = "0";
398
+ if (maxWidth) s.style.maxWidth = maxWidth;
399
+ document.body.appendChild(s);
400
+ const metrics = {width: s.offsetWidth, height: s.offsetHeight};
401
+ document.body.removeChild(s);
402
+ return metrics;
403
+ } */
404
+
405
+ getDefaultOptions () {
406
+ return {
407
+ ...Visual.prototype.getDefaultOptions(),
408
+ background: null,
409
+ /**
410
+ * @name module:layer.Text#font
411
+ * @type string
412
+ * @desc The css font to render with
413
+ */
414
+ font: '10px sans-serif',
415
+ /**
416
+ * @name module:layer.Text#font
417
+ * @type string
418
+ * @desc The css color to render with
419
+ */
420
+ color: '#fff',
421
+ /**
422
+ * @name module:layer.Text#textX
423
+ * @type number
424
+ * @desc Offset of the text relative to the layer
425
+ */
426
+ textX: 0,
427
+ /**
428
+ * @name module:layer.Text#textY
429
+ * @type number
430
+ * @desc Offset of the text relative to the layer
431
+ */
432
+ textY: 0,
433
+ /**
434
+ * @name module:layer.Text#maxWidth
435
+ * @type number
436
+ */
437
+ maxWidth: null,
438
+ /**
439
+ * @name module:layer.Text#textAlign
440
+ * @type string
441
+ * @desc The horizontal alignment
442
+ * @see [<code>CanvasRenderingContext2D#textAlign</code>]{@link https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textAlign}
443
+ */
444
+ textAlign: 'start',
445
+ /**
446
+ * @name module:layer.Text#textAlign
447
+ * @type string
448
+ * @desc the vertical alignment
449
+ * @see [<code>CanvasRenderingContext2D#textBaseline</code>]{@link https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textBaseline}
450
+ */
451
+ textBaseline: 'top',
452
+ /**
453
+ * @name module:layer.Text#textDirection
454
+ * @type string
455
+ * @see [<code>CanvasRenderingContext2D#direction</code>]{@link https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textBaseline}
456
+ */
457
+ textDirection: 'ltr'
458
+ }
459
+ }
460
+ }
461
+
462
+ export class Image extends Visual {
463
+ /**
464
+ * Creates a new image layer
465
+ *
466
+ * @param {number} startTime
467
+ * @param {number} duration
468
+ * @param {HTMLImageElement} image
469
+ * @param {object} [options]
470
+ * @param {number} [options.x=0] - the offset of the layer relative to the movie
471
+ * @param {number} [options.y=0] - the offset of the layer relative to the movie
472
+ * @param {string} [options.background=null] - the background color of the layer, or <code>null</code>
473
+ * for transparency
474
+ * @param {object} [options.border=null] - the layer"s outline, or <code>null</code> for no outline
475
+ * @param {string} [options.border.color] - the outline"s color; required for a border
476
+ * @param {string} [options.border.thickness=1] - the outline"s weight
477
+ * @param {number} [options.opacity=1] - the layer"s opacity; <code>1</cod> for full opacity
478
+ * and <code>0</code> for full transparency
479
+ * @param {number} [options.clipX=0] - image source x
480
+ * @param {number} [options.clipY=0] - image source y
481
+ * @param {number} [options.clipWidth=undefined] - image source width, or <code>undefined</code> to fill the entire layer
482
+ * @param {number} [options.clipHeight=undefined] - image source height, or <code>undefined</code> to fill the entire layer
483
+ * @param {number} [options.imageX=0] - offset of the image relative to the layer
484
+ * @param {number} [options.imageY=0] - offset of the image relative to the layer
485
+ */
486
+ constructor (startTime, duration, image, options = {}) {
487
+ super(startTime, duration, options) // wait to set width & height
488
+ applyOptions(options, this)
489
+ // clipX... => how much to show of this.image
490
+ // imageX... => how to project this.image onto the canvas
491
+ this._image = image
492
+
493
+ const load = () => {
494
+ this.width = this.imageWidth = this.width || this.image.width
495
+ this.height = this.imageHeight = this.height || this.image.height
496
+ this.clipWidth = this.clipWidth || image.width
497
+ this.clipHeight = this.clipHeight || image.height
498
+ }
499
+ if (image.complete) {
500
+ load()
501
+ } else {
502
+ image.addEventListener('load', load)
503
+ }
504
+ }
505
+
506
+ doRender (reltime) {
507
+ super.doRender(reltime) // clear/fill background
508
+ this.cctx.drawImage(
509
+ this.image,
510
+ val(this, 'clipX', reltime), val(this, 'clipY', reltime),
511
+ val(this, 'clipWidth', reltime), val(this, 'clipHeight', reltime),
512
+ // this.imageX and this.imageY are relative to layer
513
+ val(this, 'imageX', reltime), val(this, 'imageY', reltime),
514
+ val(this, 'imageWidth', reltime), val(this, 'imageHeight', reltime)
515
+ )
516
+ }
517
+
518
+ /**
519
+ * @type HTMLImageElement
520
+ */
521
+ get image () {
522
+ return this._image
523
+ }
524
+
525
+ getDefaultOptions () {
526
+ return {
527
+ ...Visual.prototype.getDefaultOptions(),
528
+ /**
529
+ * @name module:layer.Image#clipX
530
+ * @type number
531
+ * @desc Image source x
532
+ */
533
+ clipX: 0,
534
+ /**
535
+ * @name module:layer.Image#clipY
536
+ * @type number
537
+ * @desc Image source y
538
+ */
539
+ clipY: 0,
540
+ /**
541
+ * @name module:layer.Image#clipWidth
542
+ * @type number
543
+ * @desc Image source width, or <code>undefined</code> to fill the entire layer
544
+ */
545
+ clipWidth: undefined,
546
+ /**
547
+ * @name module:layer.Image#clipHeight
548
+ * @type number
549
+ * @desc Image source height, or <code>undefined</code> to fill the entire layer
550
+ */
551
+ clipHeight: undefined,
552
+ /**
553
+ * @name module:layer.Image#imageX
554
+ * @type number
555
+ * @desc Offset of the image relative to the layer
556
+ */
557
+ imageX: 0,
558
+ /**
559
+ * @name module:layer.Image#imageX
560
+ * @type number
561
+ * @desc Offset of the image relative to the layer
562
+ */
563
+ imageY: 0
564
+ }
565
+ }
566
+ }
567
+
568
+ // https://web.archive.org/web/20190111044453/http://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/
569
+ /**
570
+ * Video or audio
571
+ * @mixin MediaMixin
572
+ * @todo implement playback rate
573
+ */
574
+ export const MediaMixin = superclass => {
575
+ if (superclass !== Base && superclass !== Visual) {
576
+ throw new Error('Media can only extend Base and Visual')
577
+ }
578
+
579
+ class Media extends superclass {
580
+ /**
581
+ * @param {number} startTime
582
+ * @param {HTMLVideoElement} media
583
+ * @param {object} [options]
584
+ * @param {number} [options.mediaStartTime=0] - at what time in the audio the layer starts
585
+ * @param {numer} [options.duration=media.duration-options.mediaStartTime]
586
+ * @param {boolean} [options.muted=false]
587
+ * @param {number} [options.volume=1]
588
+ * @param {number} [options.playbackRate=1]
589
+ */
590
+ constructor (startTime, media, onload, options = {}) {
591
+ super(startTime, 0, options) // works with both Base and Visual
592
+ this._initialized = false
593
+ this._media = media
594
+ this._mediaStartTime = options.mediaStartTime || 0
595
+ applyOptions(options, this)
596
+
597
+ const load = () => {
598
+ // TODO: && ?
599
+ if ((options.duration || (media.duration - this.mediaStartTime)) < 0) {
600
+ throw new Error('Invalid options.duration or options.mediaStartTime')
601
+ }
602
+ this.duration = options.duration || (media.duration - this.mediaStartTime)
603
+ // onload will use `this`, and can't bind itself because it's before super()
604
+ onload && onload.bind(this)(media, options)
605
+ }
606
+ if (media.readyState >= 2) {
607
+ // this frame's data is available now
608
+ load()
609
+ } else {
610
+ // when this frame's data is available
611
+ media.addEventListener('canplay', load)
612
+ }
613
+ media.addEventListener('durationchange', () => {
614
+ this.duration = options.duration || (media.duration - this.mediaStartTime)
615
+ })
616
+
617
+ // TODO: on unattach?
618
+ subscribe(this, 'movie.audiodestinationupdate', event => {
619
+ // reset destination
620
+ this.source.disconnect()
621
+ this.source.connect(event.destination)
622
+ })
623
+ }
624
+
625
+ attach (movie) {
626
+ super.attach(movie)
627
+
628
+ subscribe(movie, 'movie.seek', e => {
629
+ const time = e.movie.currentTime
630
+ if (time < this.startTime || time >= this.startTime + this.duration) {
631
+ return
632
+ }
633
+ this.media.currentTime = time - this.startTime
634
+ })
635
+ // connect to audiocontext
636
+ this._source = movie.actx.createMediaElementSource(this.media)
637
+ this.source.connect(movie.actx.destination)
638
+ }
639
+
640
+ start (reltime) {
641
+ this.media.currentTime = reltime + this.mediaStartTime
642
+ this.media.play()
643
+ }
644
+
645
+ render (reltime) {
646
+ super.render(reltime)
647
+ // even interpolate here
648
+ // TODO: implement Issue: Create built-in audio node to support built-in audio nodes, as this does nothing rn
649
+ this.media.muted = val(this, 'muted', reltime)
650
+ this.media.volume = val(this, 'volume', reltime)
651
+ this.media.playbackRate = val(this, 'playbackRate', reltime)
652
+ }
653
+
654
+ stop () {
655
+ this.media.pause()
656
+ }
657
+
658
+ /**
659
+ * The raw html media element
660
+ * @type HTMLMediaElement
661
+ */
662
+ get media () {
663
+ return this._media
664
+ }
665
+
666
+ /**
667
+ * The audio source node for the media
668
+ * @type MediaStreamAudioSourceNode
669
+ */
670
+ get source () {
671
+ return this._source
672
+ }
673
+
674
+ get startTime () {
675
+ return this._startTime
676
+ }
677
+
678
+ set startTime (val) {
679
+ this._startTime = val
680
+ if (this._initialized) {
681
+ const mediaProgress = this._movie.currentTime - this.startTime
682
+ this.media.currentTime = this.mediaStartTime + mediaProgress
683
+ }
684
+ }
685
+
686
+ set mediaStartTime (val) {
687
+ this._mediaStartTime = val
688
+ if (this._initialized) {
689
+ const mediaProgress = this._movie.currentTime - this.startTime
690
+ this.media.currentTime = mediaProgress + this.mediaStartTime
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Where in the media the layer starts at
696
+ * @type number
697
+ */
698
+ get mediaStartTime () {
699
+ return this._mediaStartTime
700
+ }
701
+
702
+ getDefaultOptions () {
703
+ return {
704
+ ...superclass.prototype.getDefaultOptions(),
705
+ /**
706
+ * @name module:layer~Media#mediaStartTime
707
+ * @type number
708
+ * @desc Where in the media the layer starts at
709
+ */
710
+ mediaStartTime: 0,
711
+ /**
712
+ * @name module:layer~Media#duration
713
+ * @type number
714
+ */
715
+ duration: undefined, // important to include undefined keys, for applyOptions
716
+ /**
717
+ * @name module:layer~Media#muted
718
+ * @type boolean
719
+ */
720
+ muted: false,
721
+ /**
722
+ * @name module:layer~Media#volume
723
+ * @type number
724
+ */
725
+ volume: 1,
726
+ /**
727
+ * @name module:layer~Media#playbackRate
728
+ * @type number
729
+ * @todo <strong>Implement</strong>
730
+ */
731
+ playbackRate: 1
732
+ }
733
+ }
734
+ };
735
+
736
+ return Media // custom mixin class
737
+ }
738
+
739
+ // use mixins instead of `extend`ing two classes (which doens't work); see below class def
740
+ /**
741
+ * @extends module:layer~Media
742
+ */
743
+ export class Video extends MediaMixin(Visual) {
744
+ /**
745
+ * Creates a new video layer
746
+ *
747
+ * @param {number} startTime
748
+ * @param {HTMLVideoElement} media
749
+ * @param {object} [options]
750
+ * @param {number} startTime
751
+ * @param {HTMLVideoElement} media
752
+ * @param {object} [options]
753
+ * @param {number} [options.mediaStartTime=0] - at what time in the audio the layer starts
754
+ * @param {numer} [options.duration=media.duration-options.mediaStartTime]
755
+ * @param {boolean} [options.muted=false]
756
+ * @param {number} [options.volume=1]
757
+ * @param {number} [options.speed=1] - the audio's playerback rate
758
+ * @param {number} [options.mediaStartTime=0] - at what time in the video the layer starts
759
+ * @param {numer} [options.duration=media.duration-options.mediaStartTime]
760
+ * @param {number} [options.clipX=0] - video source x
761
+ * @param {number} [options.clipY=0] - video source y
762
+ * @param {number} [options.clipWidth=0] - video destination width
763
+ * @param {number} [options.clipHeight=0] - video destination height
764
+ * @param {number} [options.mediaX=0] - video offset relative to the layer
765
+ * @param {number} [options.mediaY=0] - video offset relative to the layer
766
+ */
767
+ constructor (startTime, media, options = {}) {
768
+ // fill in the zeros once loaded
769
+ super(startTime, media, function () {
770
+ this.width = this.mediaWidth = options.width || media.videoWidth
771
+ this.height = this.mediaHeight = options.height || media.videoHeight
772
+ this.clipWidth = options.clipWidth || media.videoWidth
773
+ this.clipHeight = options.clipHeight || media.videoHeight
774
+ }, options)
775
+ // clipX... => how much to show of this.media
776
+ // mediaX... => how to project this.media onto the canvas
777
+ applyOptions(options, this)
778
+ if (this.duration === undefined) {
779
+ this.duration = media.duration - this.mediaStartTime
780
+ }
781
+ }
782
+
783
+ doRender (reltime) {
784
+ super.doRender()
785
+ this.cctx.drawImage(this.media,
786
+ val(this, 'clipX', reltime), val(this, 'clipY', reltime),
787
+ val(this, 'clipWidth', reltime), val(this, 'clipHeight', reltime),
788
+ val(this, 'mediaX', reltime), val(this, 'mediaY', reltime), // relative to layer
789
+ val(this, 'mediaWidth', reltime), val(this, 'mediaHeight', reltime))
790
+ }
791
+
792
+ getDefaultOptions () {
793
+ return {
794
+ ...Object.getPrototypeOf(this).getDefaultOptions(), // let's not call MediaMixin again
795
+ /**
796
+ * @name module:layer.Video#clipX
797
+ * @type number
798
+ * @desc Video source x
799
+ */
800
+ clipX: 0,
801
+ /**
802
+ * @name module:layer.Video#clipY
803
+ * @type number
804
+ * @desc Video source y
805
+ */
806
+ clipY: 0,
807
+ /**
808
+ * @name module:layer.Video#mediaX
809
+ * @type number
810
+ * @desc Video offset relative to layer
811
+ */
812
+ mediaX: 0,
813
+ /**
814
+ * @name module:layer.Video#mediaY
815
+ * @type number
816
+ * @desc Video offset relative to layer
817
+ */
818
+ mediaY: 0,
819
+ /**
820
+ * @name module:layer.Video#mediaWidth
821
+ * @type number
822
+ * @desc Video destination width
823
+ */
824
+ mediaWidth: undefined,
825
+ /**
826
+ * @name module:layer.Video#mediaHeight
827
+ * @type number
828
+ * @desc Video destination height
829
+ */
830
+ mediaHeight: undefined
831
+ }
832
+ }
833
+ }
834
+
835
+ /**
836
+ * @extends module:layer~Media
837
+ */
838
+ export class Audio extends MediaMixin(Base) {
839
+ /**
840
+ * Creates an audio layer
841
+ *
842
+ * @param {number} startTime
843
+ * @param {HTMLAudioElement} media
844
+ * @param {object} [options]
845
+ * @param {number} startTime
846
+ * @param {HTMLVideoElement} media
847
+ * @param {object} [options]
848
+ * @param {number} [options.mediaStartTime=0] - at what time in the audio the layer starts
849
+ * @param {numer} [options.duration=media.duration-options.mediaStartTime]
850
+ * @param {boolean} [options.muted=false]
851
+ * @param {number} [options.volume=1]
852
+ * @param {number} [options.speed=1] - the audio's playerback rate
853
+ */
854
+ constructor (startTime, media, options = {}) {
855
+ // fill in the zero once loaded, no width or height (will raise error)
856
+ super(startTime, media, null, options)
857
+ applyOptions(options, this)
858
+ if (this.duration === undefined) {
859
+ this.duration = media.duration - this.mediaStartTime
860
+ }
861
+ }
862
+
863
+ getDefaultOptions () {
864
+ return {
865
+ ...Object.getPrototypeOf(this).getDefaultOptions(), // let's not call MediaMixin again
866
+ /**
867
+ * @name module:layer.Audio#mediaStartTime
868
+ * @type number
869
+ * @desc Where in the media to start playing when the layer starts
870
+ */
871
+ mediaStartTime: 0,
872
+ duration: undefined
873
+ }
874
+ }
875
+ }