etro 0.8.1 → 0.8.4

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.
@@ -1,9 +1,8 @@
1
- import { AudioContext, IAudioNode } from 'standardized-audio-context';
2
1
  import { Base, BaseOptions } from './base';
3
2
  declare type Constructor<T> = new (...args: unknown[]) => T;
4
3
  interface AudioSource extends Base {
5
4
  readonly source: HTMLMediaElement;
6
- readonly audioNode: IAudioNode<AudioContext>;
5
+ readonly audioNode: AudioNode;
7
6
  playbackRate: number;
8
7
  /** The audio source node for the media */
9
8
  sourceStartTime: number;
package/dist/movie.d.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * @module movie
3
3
  */
4
- import { AudioContext } from 'standardized-audio-context';
5
4
  import { Dynamic } from './util';
6
5
  import { Base as BaseLayer } from './layer/index';
7
6
  import { Base as BaseEffect } from './effect/index';
8
7
  declare global {
8
+ interface Window {
9
+ webkitAudioContext: typeof AudioContext;
10
+ }
9
11
  interface HTMLCanvasElement {
10
12
  captureStream(frameRate?: number): MediaStream;
11
13
  }
package/karma.conf.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Karma configuration
2
2
  // Generated on Thu Sep 19 2019 02:05:06 GMT-0400 (Eastern Daylight Time)
3
3
 
4
+ process.env.CHROME_BIN = require('puppeteer').executablePath()
5
+
4
6
  module.exports = function (config) {
5
7
  config.set({
6
8
 
@@ -49,7 +51,21 @@ module.exports = function (config) {
49
51
 
50
52
  // start these browsers
51
53
  // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
52
- browsers: ['ChromeHeadless'],
54
+ browsers: ['FirefoxHeadless'],
55
+
56
+ customLaunchers: {
57
+ 'FirefoxHeadless': {
58
+ base: 'Firefox',
59
+ flags: ['-headless'],
60
+ prefs: {
61
+ 'network.proxy.type': 0
62
+ }
63
+ }
64
+ },
65
+
66
+ client: {
67
+ captureConsole: true
68
+ },
53
69
 
54
70
  // Continuous Integration mode
55
71
  // if true, Karma captures browsers, runs the tests and exits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "etro",
3
- "version": "0.8.1",
3
+ "version": "0.8.4",
4
4
  "description": "An extendable video-editing framework for the browser and Node",
5
5
  "browser": "dist/etro-cjs.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,9 +9,6 @@
9
9
  "example": "examples",
10
10
  "test": "spec"
11
11
  },
12
- "dependencies": {
13
- "standardized-audio-context": "^25.1.13"
14
- },
15
12
  "devDependencies": {
16
13
  "@types/dom-mediacapture-record": "^1.0.7",
17
14
  "@typescript-eslint/eslint-plugin": "^4.15.2",
@@ -28,14 +25,13 @@
28
25
  "ev": "0.0.7",
29
26
  "http-server": "^0.12.3",
30
27
  "jasmine": "^3.4.0",
31
- "jsdoc": "^3.6.3",
32
- "jsdoc-export-default-interop": "^0.3.1",
33
28
  "karma": "^6.1.1",
34
- "karma-chrome-launcher": "^3.1.0",
35
29
  "karma-es6-shim": "^1.0.0",
30
+ "karma-firefox-launcher": "^2.1.2",
36
31
  "karma-jasmine": "^2.0.1",
37
32
  "karma-requirejs": "^1.1.0",
38
33
  "karma-super-dots-reporter": "^0.2.0",
34
+ "keep-a-changelog": "^0.10.4",
39
35
  "puppeteer": "^2.0.0",
40
36
  "resemblejs": "^3.2.5",
41
37
  "rollup": "^1.19.4",
@@ -44,12 +40,13 @@
44
40
  "rollup-plugin-node-resolve": "^5.2.0",
45
41
  "rollup-plugin-typescript2": "^0.29.0",
46
42
  "rollup-plugin-uglify-es": "^0.0.1",
47
- "typedoc": "^0.20.34",
43
+ "shipjs": "0.23.3",
44
+ "typedoc": "^0.22.11",
48
45
  "typescript": "^4.1.3"
49
46
  },
50
47
  "scripts": {
51
48
  "build": "rollup -c",
52
- "doc": "rm -rf docs && npx typedoc src/etro.ts --excludePrivate --readme none --theme minimal",
49
+ "doc": "rm -rf docs && npx typedoc src/etro.ts --excludePrivate --readme none",
53
50
  "assets": "git fetch origin example-assets:example-assets && git cherry-pick example-assets && git reset --soft HEAD^ && git reset HEAD examples/assets",
54
51
  "effects": "node scripts/save-effect-samples.js",
55
52
  "lint": "npm run --silent lint:main && npm run --silent lint:test && npm run --silent lint:examples",
@@ -57,7 +54,8 @@
57
54
  "lint:test": "eslint -c eslint.test-conf.js spec",
58
55
  "lint:examples": "eslint -c eslint.example-conf.js --ext .html examples",
59
56
  "start": "http-server",
60
- "test": "karma start"
57
+ "test": "karma start",
58
+ "release": "shipjs prepare"
61
59
  },
62
60
  "repository": {
63
61
  "type": "git",
@@ -43,26 +43,35 @@
43
43
  /**
44
44
  * Save an effect sample to the disk
45
45
  */
46
- function saveSample(original, effect, path) {
47
- // don't overwrite original's contents
48
- const buffer = document.createElement('canvas')
49
- buffer.width = original.width
50
- buffer.height = original.height
51
- const ctx = buffer.getContext('2d')
52
- ctx.drawImage(original, 0, 0)
53
- const movie = {
54
- canvas: buffer, cctx: ctx,
55
- width: original.width, height: original.height
56
- }
57
- // for util.cache()
58
- effect._target = { movie }
59
- // Run effect
60
- effect.apply(movie)
46
+ async function saveSample(original, effect, path) {
47
+ // Create movie (needed for layer to render)
48
+ const movie = new etro.Movie({
49
+ canvas: document.createElement('canvas'),
50
+ autoRefresh: false
51
+ })
52
+
53
+ // Convert canvas to image
54
+ const originalImg = new Image()
55
+ await new Promise(resolve => {
56
+ originalImg.onload = resolve
57
+ originalImg.src = original.toDataURL()
58
+ })
59
+
60
+ // Add an image layer with the effect to the movie
61
+ const layer = new etro.layer.Image({
62
+ startTime: 0,
63
+ duration: 1,
64
+ source: originalImg
65
+ })
66
+ layer.effects.push(effect)
67
+ movie.layers.push(layer)
61
68
 
62
- save(buffer, path)
69
+ // Render and save the layer
70
+ layer.render()
71
+ save(layer.canvas, path)
63
72
  }
64
73
 
65
- window.onload = () => {
74
+ window.onload = async () => {
66
75
  const original = genRandomNoise(16, 16)
67
76
  save(original, 'original.png')
68
77
 
@@ -91,7 +100,7 @@
91
100
 
92
101
  for (let path in samples) {
93
102
  const effect = samples[path]
94
- saveSample(original, effect, path)
103
+ await saveSample(original, effect, path)
95
104
  }
96
105
  window.done = true
97
106
  }
@@ -20,8 +20,13 @@ function createDirs(filePath) {
20
20
  }
21
21
 
22
22
  (async () => {
23
- const browser = await puppeteer.launch()
23
+ const browser = await puppeteer.launch({
24
+ args: ['--autoplay-policy=no-user-gesture-required']
25
+ })
24
26
  const page = await browser.newPage()
27
+ page.on('console', msg => {
28
+ console.log(`[CONSOLE] ${msg.text()}`)
29
+ })
25
30
 
26
31
  await page.goto(`file://${__dirname}/gen-effect-samples.html`)
27
32
  await page.waitForFunction(() => window.done);
package/ship.config.js ADDED
@@ -0,0 +1,80 @@
1
+ const { parser } = require('keep-a-changelog')
2
+ const fs = require('fs')
3
+ const semver = require('semver')
4
+
5
+ module.exports = {
6
+ updateChangelog: false,
7
+ formatCommitMessage: ({ version }) => `Release v${version}`,
8
+ formatPullRequestTitle: ({ version }) => `Release v${version}`,
9
+ getNextVersion: ({ currentVersion, dir }) => {
10
+ const changelog = new Changelog(`${dir}/CHANGELOG.md`)
11
+ return changelog.nextVersion(currentVersion)
12
+ },
13
+ versionUpdated: async ({ version, _releaseType, dir, _exec }) => {
14
+ const parsedVersion = semver.parse(version)
15
+ if (parsedVersion.prerelease.length)
16
+ return
17
+
18
+ // Release 'Unreleased' section in changelog
19
+ const changelogFile = `${dir}/CHANGELOG.md`
20
+ const oldChangelog = fs.readFileSync(changelogFile, 'utf8')
21
+ const parsed = parser(oldChangelog)
22
+ const release = parsed.findRelease() // get 'Unreleased' section
23
+ release.setVersion(version) // release
24
+ release.setDate(new Date()) // today
25
+ const newChangelog = parsed.toString()
26
+ fs.writeFileSync(changelogFile, newChangelog, 'utf8')
27
+ }
28
+ }
29
+
30
+ class Changelog {
31
+ constructor (path) {
32
+ const data = fs.readFileSync(path, 'utf8')
33
+ const lines = data.split(/\r?\n/)
34
+ const headings = []
35
+ let unreleased = false
36
+
37
+ this.releaseTag = 'latest'
38
+ lines.every((line) => {
39
+ if (line.startsWith('## [Unreleased]')) {
40
+ unreleased = true
41
+ const tagMatch = line.match(/## \[Unreleased\]\[(.*)\]/)
42
+ if (tagMatch)
43
+ this.releaseTag = tagMatch[1].trim()
44
+ } else if (line.startsWith('## ')) {
45
+ return false
46
+ }
47
+
48
+ if (unreleased)
49
+ if (line.startsWith('### ')) {
50
+ headings.push(line.match(/### (.*)/)[1].trim())
51
+ }
52
+
53
+ return true
54
+ })
55
+
56
+ if (headings.includes('Changed'))
57
+ this.releaseType = 'major'
58
+ else if (headings.includes('Added'))
59
+ this.releaseType = 'minor'
60
+ else
61
+ this.releaseType = 'patch'
62
+ }
63
+
64
+ nextVersion (version) {
65
+ const parsedVersion = semver.parse(version)
66
+
67
+ if (this.releaseTag !== 'latest')
68
+ if (parsedVersion.prerelease.length) {
69
+ parsedVersion.inc('prerelease', this.releaseTag)
70
+ } else {
71
+ parsedVersion.inc(this.releaseType)
72
+ parsedVersion.prerelease = [this.releaseTag, 0]
73
+ parsedVersion.format()
74
+ }
75
+ else
76
+ parsedVersion.inc(this.releaseType)
77
+
78
+ return parsedVersion.version
79
+ }
80
+ }
@@ -46,7 +46,7 @@ export class Stack extends Base {
46
46
 
47
47
  attach (movie: Movie): void {
48
48
  super.attach(movie)
49
- this.effects.forEach(effect => {
49
+ this.effects.filter(effect => !!effect).forEach(effect => {
50
50
  effect.detach()
51
51
  effect.attach(movie)
52
52
  })
@@ -54,7 +54,7 @@ export class Stack extends Base {
54
54
 
55
55
  detach (): void {
56
56
  super.detach()
57
- this.effects.forEach(effect => {
57
+ this.effects.filter(effect => !!effect).forEach(effect => {
58
58
  effect.detach()
59
59
  })
60
60
  }
@@ -62,6 +62,7 @@ export class Stack extends Base {
62
62
  apply (target: Movie | Visual, reltime: number): void {
63
63
  for (let i = 0; i < this.effects.length; i++) {
64
64
  const effect = this.effects[i]
65
+ if (!effect) continue
65
66
  effect.apply(target, reltime)
66
67
  }
67
68
  }
@@ -1,4 +1,3 @@
1
- import { AudioContext, IAudioNode, IAudioDestinationNode } from 'standardized-audio-context'
2
1
  import { Movie } from '../movie'
3
2
  import { subscribe } from '../event'
4
3
  import { applyOptions, val } from '../util'
@@ -8,7 +7,7 @@ type Constructor<T> = new (...args: unknown[]) => T
8
7
 
9
8
  interface AudioSource extends Base {
10
9
  readonly source: HTMLMediaElement
11
- readonly audioNode: IAudioNode<AudioContext>
10
+ readonly audioNode: AudioNode
12
11
  playbackRate: number
13
12
  /** The audio source node for the media */
14
13
  sourceStartTime: number
@@ -41,7 +40,7 @@ function AudioSourceMixin<OptionsSuperclass extends BaseOptions> (superclass: Co
41
40
  readonly source: HTMLMediaElement
42
41
 
43
42
  private __startTime: number
44
- private _audioNode: IAudioNode<AudioContext>
43
+ private _audioNode: AudioNode
45
44
  private _sourceStartTime: number
46
45
  private _unstretchedDuration: number
47
46
  private _playbackRate: number
@@ -117,12 +116,12 @@ function AudioSourceMixin<OptionsSuperclass extends BaseOptions> (superclass: Co
117
116
  // Spy on connect and disconnect to remember if it connected to
118
117
  // actx.destination (for Movie#record).
119
118
  const oldConnect = this._audioNode.connect.bind(this.audioNode)
120
- this._audioNode.connect = <T extends IAudioDestinationNode<AudioContext>>(destination: T, outputIndex?: number, inputIndex?: number): AudioNode => {
119
+ this._audioNode.connect = <T extends AudioDestinationNode>(destination: T, outputIndex?: number, inputIndex?: number): AudioNode => {
121
120
  this._connectedToDestination = destination === movie.actx.destination
122
121
  return oldConnect(destination, outputIndex, inputIndex)
123
122
  }
124
123
  const oldDisconnect = this._audioNode.disconnect.bind(this.audioNode)
125
- this._audioNode.disconnect = <T extends IAudioDestinationNode<AudioContext>>(destination?: T | number, output?: number, input?: number): AudioNode => {
124
+ this._audioNode.disconnect = <T extends AudioDestinationNode>(destination?: T | number, output?: number, input?: number): AudioNode => {
126
125
  if (this._connectedToDestination &&
127
126
  destination === movie.actx.destination)
128
127
  this._connectedToDestination = false
@@ -83,6 +83,12 @@ class Visual extends Base {
83
83
  * Render visual output
84
84
  */
85
85
  render (): void {
86
+ // Prevent empty canvas errors if the width or height is 0
87
+ const width = val(this, 'width', this.currentTime)
88
+ const height = val(this, 'height', this.currentTime)
89
+ if (width === 0 || height === 0)
90
+ return
91
+
86
92
  this.beginRender()
87
93
  this.doRender()
88
94
  this.endRender()
@@ -125,7 +131,7 @@ class Visual extends Base {
125
131
  _applyEffects (): void {
126
132
  for (let i = 0; i < this.effects.length; i++) {
127
133
  const effect = this.effects[i]
128
- if (effect.enabled)
134
+ if (effect && effect.enabled)
129
135
  // Pass relative time
130
136
  effect.apply(this, this.movie.currentTime - this.startTime)
131
137
  }
package/src/movie.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  * @module movie
3
3
  */
4
4
 
5
- import { AudioContext } from 'standardized-audio-context'
6
5
  import { subscribe, publish } from './event'
7
6
  import { Dynamic, val, clearCachedValues, applyOptions, watchPublic } from './util'
8
7
  import { Base as BaseLayer, Audio as AudioLayer, Video as VideoLayer, Visual } from './layer/index' // `Media` mixins
@@ -10,6 +9,10 @@ import { AudioSource } from './layer/audio-source' // not exported from ./layer/
10
9
  import { Base as BaseEffect } from './effect/index'
11
10
 
12
11
  declare global {
12
+ interface Window {
13
+ webkitAudioContext: typeof AudioContext
14
+ }
15
+
13
16
  interface HTMLCanvasElement {
14
17
  captureStream(frameRate?: number): MediaStream
15
18
  }
@@ -75,7 +78,11 @@ export class Movie {
75
78
  constructor (options: MovieOptions) {
76
79
  // TODO: move into multiple methods!
77
80
  // Set actx option manually, because it's readonly.
78
- this.actx = options.actx || options.audioContext || new AudioContext()
81
+ this.actx = options.actx ||
82
+ options.audioContext ||
83
+ new AudioContext() ||
84
+ // eslint-disable-next-line new-cap
85
+ new window.webkitAudioContext()
79
86
  delete options.actx
80
87
 
81
88
  // Proxy that will be returned by constructor
@@ -255,6 +262,10 @@ export class Movie {
255
262
  if (!this.paused)
256
263
  throw new Error('Cannot record movie while already playing or recording')
257
264
 
265
+ const mimeType = options.type || 'video/webm'
266
+ if (MediaRecorder && MediaRecorder.isTypeSupported && !MediaRecorder.isTypeSupported(mimeType))
267
+ throw new Error('Please pass a valid MIME type for the exported video')
268
+
258
269
  return new Promise((resolve, reject) => {
259
270
  const canvasCache = this.canvas
260
271
  // Record on a temporary canvas context
@@ -285,7 +296,11 @@ export class Movie {
285
296
  )
286
297
  }
287
298
  const stream = new MediaStream(tracks)
288
- const mediaRecorder = new MediaRecorder(stream, options.mediaRecorderOptions)
299
+ const mediaRecorderOptions = {
300
+ ...(options.mediaRecorderOptions || {}),
301
+ mimeType
302
+ }
303
+ const mediaRecorder = new MediaRecorder(stream, mediaRecorderOptions)
289
304
  mediaRecorder.ondataavailable = event => {
290
305
  // if (this._paused) reject(new Error("Recording was interrupted"));
291
306
  if (event.data.size > 0)
@@ -293,6 +308,7 @@ export class Movie {
293
308
  }
294
309
  // TODO: publish to movie, not layers
295
310
  mediaRecorder.onstop = () => {
311
+ this._paused = true
296
312
  this._ended = true
297
313
  this._canvas = canvasCache
298
314
  this._cctx = this.canvas.getContext('2d')
@@ -303,7 +319,7 @@ export class Movie {
303
319
  // Construct the exported video out of all the frame blobs.
304
320
  resolve(
305
321
  new Blob(recordedChunks, {
306
- type: options.type || 'video/webm'
322
+ type: mimeType
307
323
  })
308
324
  )
309
325
  }
@@ -324,11 +340,13 @@ export class Movie {
324
340
  pause (): Movie {
325
341
  this._paused = true
326
342
  // 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
- }
343
+ for (let i = 0; i < this.layers.length; i++)
344
+ if (Object.prototype.hasOwnProperty.call(this.layers, i)) {
345
+ const layer = this.layers[i]
346
+ layer.stop()
347
+ layer.active = false
348
+ }
349
+
332
350
  publish(this, 'movie.pause', {})
333
351
  return this
334
352
  }
@@ -370,27 +388,31 @@ export class Movie {
370
388
  const end = this.duration
371
389
  const ended = this.currentTime > end
372
390
  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
391
  this._lastPlayed = performance.now()
378
392
  this._lastPlayedOffset = 0 // this.currentTime
379
393
  this._renderingFrame = false
380
394
  if (!this.repeat || this.recording) {
395
+ this._paused = true
381
396
  this._ended = true
382
397
  // 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
- }
398
+ for (let i = 0; i < this.layers.length; i++)
399
+ if (Object.prototype.hasOwnProperty.call(this.layers, i)) {
400
+ const layer = this.layers[i]
401
+ // A layer that has been deleted before layers.length has been updated
402
+ // (see the layers proxy in the constructor).
403
+ if (!layer)
404
+ continue
405
+
406
+ layer.stop()
407
+ layer.active = false
408
+ }
393
409
  }
410
+
411
+ publish(this, 'movie.ended', { movie: this, repeat: this.repeat })
412
+
413
+ // TODO: only reset currentTime if repeating
414
+ this._currentTime = 0 // don't use setter
415
+ publish(this, 'movie.timeupdate', { movie: this })
394
416
  }
395
417
 
396
418
  // Stop playback or recording if done
@@ -421,8 +443,8 @@ export class Movie {
421
443
  return
422
444
  }
423
445
 
424
- window.requestAnimationFrame(timestamp => {
425
- this._render(repeat, timestamp)
446
+ window.requestAnimationFrame(() => {
447
+ this._render(repeat, undefined, done)
426
448
  }) // TODO: research performance cost
427
449
  }
428
450
 
@@ -456,6 +478,8 @@ export class Movie {
456
478
  private _renderLayers () {
457
479
  let frameFullyLoaded = true
458
480
  for (let i = 0; i < this.layers.length; i++) {
481
+ if (!Object.prototype.hasOwnProperty.call(this.layers, i)) continue
482
+
459
483
  const layer = this.layers[i]
460
484
  // A layer that has been deleted before layers.length has been updated
461
485
  // (see the layers proxy in the constructor).
@@ -532,7 +556,8 @@ export class Movie {
532
556
  */
533
557
  private _publishToLayers (type, event) {
534
558
  for (let i = 0; i < this.layers.length; i++)
535
- publish(this.layers[i], type, event)
559
+ if (Object.prototype.hasOwnProperty.call(this.layers, i))
560
+ publish(this.layers[i], type, event)
536
561
  }
537
562
 
538
563
  /**
@@ -606,7 +631,8 @@ export class Movie {
606
631
  this._currentTime = time
607
632
  publish(this, 'movie.seek', {})
608
633
  // Render single frame to match new time
609
- this.refresh()
634
+ if (this.autoRefresh)
635
+ this.refresh()
610
636
  }
611
637
 
612
638
  /**
package/src/util.ts CHANGED
@@ -425,7 +425,7 @@ export function watchPublic (target: EtroObject): EtroObject {
425
425
  publish(proxy, `${target.type}.change.modify`, { property: getPath(receiver, prop), newValue: val })
426
426
  }
427
427
  const canWatch = (receiver, prop) => !prop.startsWith('_') &&
428
- (target.publicExcludes === undefined || !target.publicExcludes.includes(prop))
428
+ (receiver.publicExcludes === undefined || !receiver.publicExcludes.includes(prop))
429
429
 
430
430
  // The path to each child property (each is a unique proxy)
431
431
  const paths = new WeakMap()