multiplane 0.3.6 → 0.3.8

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 (80) hide show
  1. package/LICENSE +202 -202
  2. package/README.en.md +3 -0
  3. package/README.md +3 -0
  4. package/THIRD_PARTY_NOTICES.md +30 -30
  5. package/bin/multiplane +24 -14
  6. package/examples/alpha.html +18 -18
  7. package/examples/animation.html +17 -17
  8. package/examples/assets/OFL.txt +93 -93
  9. package/examples/assets/card.svg +6 -6
  10. package/examples/three-cards.html +17 -17
  11. package/examples/typography.html +17 -17
  12. package/licenses/GPL-3.0.txt +232 -232
  13. package/licenses/LGPL-3.0.txt +304 -304
  14. package/licenses/acorn-LICENSE +21 -21
  15. package/licenses/colour-LICENSE.md +82 -82
  16. package/licenses/detect-libc-LICENSE +201 -201
  17. package/licenses/entities-LICENSE +11 -11
  18. package/licenses/nanoid-LICENSE +20 -20
  19. package/licenses/parse5-LICENSE +19 -19
  20. package/licenses/picocolors-LICENSE +15 -15
  21. package/licenses/playwright-NOTICE +5 -5
  22. package/licenses/playwright-ThirdPartyNotices.txt +14 -14
  23. package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
  24. package/licenses/postcss-LICENSE +20 -20
  25. package/licenses/postcss-value-parser-LICENSE +22 -22
  26. package/licenses/semver-LICENSE +15 -15
  27. package/licenses/sharp-LICENSE +191 -191
  28. package/licenses/sharp-win32-x64-NOTICES.md +45 -45
  29. package/licenses/source-map-js-LICENSE +28 -28
  30. package/licenses/three-LICENSE +21 -21
  31. package/package.json +92 -92
  32. package/skills/multiplane/SKILL.md +3 -0
  33. package/src/alpha.mjs +69 -69
  34. package/src/artifacts.mjs +61 -61
  35. package/src/browser-recovery.mjs +26 -26
  36. package/src/capture-watchdog.mjs +29 -0
  37. package/src/capture.mjs +34 -18
  38. package/src/encoder.mjs +16 -16
  39. package/src/environment.mjs +56 -56
  40. package/src/errors.mjs +1 -0
  41. package/src/events.mjs +12 -10
  42. package/src/ffmpeg.mjs +32 -32
  43. package/src/format.mjs +27 -27
  44. package/src/frame-rate.mjs +28 -28
  45. package/src/home-migration.mjs +54 -54
  46. package/src/hosted.mjs +3 -3
  47. package/src/index.mjs +1 -0
  48. package/src/input-records.mjs +33 -0
  49. package/src/jobs.mjs +3 -1
  50. package/src/lint.mjs +248 -248
  51. package/src/output-alpha.mjs +36 -36
  52. package/src/output-check.mjs +110 -110
  53. package/src/platform.mjs +114 -114
  54. package/src/png-native.mjs +12 -12
  55. package/src/png.mjs +74 -74
  56. package/src/post.mjs +56 -39
  57. package/src/preview.mjs +23 -23
  58. package/src/process.mjs +9 -9
  59. package/src/render-pool.mjs +41 -14
  60. package/src/render-worker.mjs +14 -3
  61. package/src/render.mjs +148 -52
  62. package/src/resource-path.mjs +29 -29
  63. package/src/resources.mjs +27 -27
  64. package/src/resume.mjs +23 -0
  65. package/src/runtime/camera.js +37 -37
  66. package/src/runtime/shot.css +9 -9
  67. package/src/segment-encoder.mjs +91 -0
  68. package/src/segments.mjs +123 -0
  69. package/src/server.mjs +11 -7
  70. package/src/setup.mjs +51 -51
  71. package/src/skills.mjs +128 -128
  72. package/src/stills.mjs +1 -1
  73. package/src/three/index.js +39 -31
  74. package/src/verify.mjs +2 -2
  75. package/src/video-pipe.mjs +6 -6
  76. package/tools/bench/shots/canvas-2d.html +1 -1
  77. package/tools/bench/shots/dom-light.html +1 -1
  78. package/tools/bench/shots/scene.js +44 -44
  79. package/tools/bench/shots/webgl.html +1 -1
  80. package/tools/install-browser.mjs +18 -18
package/src/capture.mjs CHANGED
@@ -119,10 +119,10 @@ async function openShotOnce(shot, scale = 1, options = {}) {
119
119
  }
120
120
  if (alpha && cdp) await cdp.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } });
121
121
  const timings = { renderCallMs: 0, screenshotMs: 0 };
122
- let closing, sampleContextUsed = false, motionProbeUsed = false, dofSamples = 0;
123
- const draw = async (t, { motionBlur = 1, motionBlurBase = motionBlur, motionProbeTime, dofMotionSamples } = {}) => {
122
+ let closing, sampleContextUsed = false, motionProbeUsed = false, dofSamples = 0;
123
+ const draw = async (t, { motionBlur = 1, motionBlurBase = motionBlur, motionProbeTime, dofMotionSamples, dofMotionIndex } = {}) => {
124
124
  const renderStart = performance.now();
125
- const changed = await page.evaluate(({ t, motionBlur, motionBlurBase, motionProbeTime, dofMotionSamples, alpha }) => {
125
+ const changed = await page.evaluate(({ t, motionBlur, motionBlurBase, motionProbeTime, dofMotionSamples, dofMotionIndex, alpha }) => {
126
126
  globalThis.__multiplaneStatic?.suspend();
127
127
  if (alpha) document.documentElement.style.setProperty('--multiplane-page-background', 'transparent');
128
128
  const stage = document.querySelector('#stage');
@@ -139,7 +139,7 @@ async function openShotOnce(shot, scale = 1, options = {}) {
139
139
  SHOT.sampleContext = { time: t, motionBlurBase, get motionBlur() { contextUsed = true; return motionBlur; } };
140
140
  let failure;
141
141
  // Private renderer/layer handshake; never part of the shot contract.
142
- const probe = globalThis.__multiplaneMotionProbe = { time: motionProbeTime, used: false, motionSamples: dofMotionSamples, samples: 0 };
142
+ const probe = globalThis.__multiplaneMotionProbe = { time: motionProbeTime, used: false, motionSamples: dofMotionSamples, motionIndex: dofMotionIndex, samples: 0 };
143
143
  try { SHOT.render(t); } catch (error) { failure = String(error?.message ?? error); } finally {
144
144
  delete globalThis.__multiplaneMotionProbe;
145
145
  if (previous === undefined) delete SHOT.sampleContext;
@@ -147,11 +147,11 @@ async function openShotOnce(shot, scale = 1, options = {}) {
147
147
  }
148
148
  stage.getBoundingClientRect();
149
149
  const changed = globalThis.__multiplaneStatic?.read() ?? true;
150
- return { failure, contextUsed, probeUsed: probe.used, dofSamples: probe.samples, changed, reason: globalThis.__multiplaneStatic?.reason };
151
- }, { t, motionBlur, motionBlurBase, motionProbeTime, dofMotionSamples, alpha }).then(({ failure, changed, contextUsed, probeUsed, dofSamples: count, reason }) => {
150
+ return { failure, contextUsed, probeUsed: probe.used, dofSamples: probe.samples, changed, reason: globalThis.__multiplaneStatic?.reason };
151
+ }, { t, motionBlur, motionBlurBase, motionProbeTime, dofMotionSamples, dofMotionIndex, alpha }).then(({ failure, changed, contextUsed, probeUsed, dofSamples: count, reason }) => {
152
152
  sampleContextUsed = contextUsed;
153
- motionProbeUsed = probeUsed;
154
- dofSamples = count;
153
+ motionProbeUsed = probeUsed;
154
+ dofSamples = count;
155
155
  const index = observationReasons.indexOf(reason);
156
156
  if (index >= 0) Atomics.add(reuseState(options), index + 4, 1);
157
157
  if (failure !== undefined) throw new MultiplaneError('render_threw', undefined, { details: { t, reason: failure } });
@@ -175,10 +175,10 @@ async function openShotOnce(shot, scale = 1, options = {}) {
175
175
  return { png: measured };
176
176
  };
177
177
  return {
178
- page, browser, server, ...metadata, alpha, beginFrame, timings,
178
+ page, browser, server, ...metadata, alpha, beginFrame, timings, _inspectSample: draw,
179
179
  get sampleContextUsed() { return sampleContextUsed; },
180
- get motionProbeUsed() { return motionProbeUsed; },
181
- get dofSamples() { return dofSamples; },
180
+ get motionProbeUsed() { return motionProbeUsed; },
181
+ get dofSamples() { return dofSamples; },
182
182
  ...(reuseEnabled(options) ? { observe: draw } : {}),
183
183
  isCrash: error => browserClosed(error, browser, page, crashed),
184
184
  ...(metadata.hasBlurAt ? { blurAt: async t => {
@@ -195,13 +195,13 @@ async function openShotOnce(shot, scale = 1, options = {}) {
195
195
  // Recovery belongs to a complete output frame, never to an individual blur sample.
196
196
  export async function openShot(shot, scale = 1, options = {}) {
197
197
  const budget = options._browserRecovery ?? recoveryBudget();
198
- let current, closed = false;
199
- const recover = async (error, t) => {
198
+ let current, closed = false, stalled;
199
+ const recover = async (error, t, stallAttempt) => {
200
200
  options.check?.();
201
201
  if (closed) throw error;
202
202
  const failure = crashedError(error, t);
203
- if (Atomics.load(budget, 0) >= MAX_BROWSER_RESTARTS) throw failure;
204
- const attempt = Atomics.add(budget, 0, 1) + 1;
203
+ if (!stallAttempt && Atomics.load(budget, 0) >= MAX_BROWSER_RESTARTS) throw failure;
204
+ const attempt = stallAttempt ?? Atomics.add(budget, 0, 1) + 1;
205
205
  if (attempt > MAX_BROWSER_RESTARTS) throw failure;
206
206
  await current?.close().catch(() => {});
207
207
  try { current = await openShotOnce(shot, scale, options); }
@@ -211,7 +211,8 @@ export async function openShot(shot, scale = 1, options = {}) {
211
211
  restarted.details = { ...failure.details, ...restarted.details };
212
212
  throw restarted;
213
213
  }
214
- options.onWarning?.({ code: 'browser_restarted', message: 'Browser restarted; retrying the affected frame.', details: { ...failure.details, attempt } });
214
+ if (!stallAttempt) options.onWarning?.({ code: 'browser_restarted', message: 'Browser restarted; retrying the affected frame.', details: { ...failure.details, attempt } });
215
+ options.onRecovered?.();
215
216
  };
216
217
  try { current = await openShotOnce(shot, scale, options); }
217
218
  catch (error) { if (error.code !== 'browser_crashed') throw error; await recover(error); }
@@ -220,17 +221,32 @@ export async function openShot(shot, scale = 1, options = {}) {
220
221
  get page() { return current.page; },
221
222
  get browser() { return current.browser; },
222
223
  get sampleContextUsed() { return current.sampleContextUsed; },
223
- get motionProbeUsed() { return current.motionProbeUsed; },
224
- get dofSamples() { return current.dofSamples; },
224
+ get motionProbeUsed() { return current.motionProbeUsed; },
225
+ get dofSamples() { return current.dofSamples; },
225
226
  ...(current.observe ? { observe: (...args) => current.observe(...args) } : {}),
226
227
  beginFrame: (...args) => current.beginFrame(...args),
228
+ _inspectSample: (...args) => current._inspectSample(...args),
227
229
  frame: (...args) => current.frame(...args),
228
230
  ...(current.blurAt ? { blurAt: (...args) => current.blurAt(...args) } : {}),
231
+ // Closing the owned browser interrupts a blocked evaluate/CDP screenshot.
232
+ // Let the complete post frame unwind before retrying any of its samples.
233
+ async stall(details) {
234
+ if (closed || stalled) return;
235
+ stalled = new MultiplaneError('browser_crashed', 'Rendering worker stopped delivering frames.', { details });
236
+ await current.close();
237
+ },
229
238
  async retryFrame(t, operation) {
239
+ let stallAttempts = 0;
230
240
  for (;;) {
231
241
  try { return await operation(); }
232
242
  catch (error) {
233
243
  options.check?.();
244
+ if (!closed && stalled) {
245
+ const failure = stalled; stalled = undefined;
246
+ if (stallAttempts >= MAX_BROWSER_RESTARTS) throw failure;
247
+ await recover(failure, t, ++stallAttempts);
248
+ continue;
249
+ }
234
250
  if (closed || !current.isCrash(error)) throw error;
235
251
  await recover(error, t);
236
252
  }
package/src/encoder.mjs CHANGED
@@ -1,16 +1,16 @@
1
- import { invalidOption } from './errors.mjs';
2
- export const encoders = ['x264', 'nvenc-h264', 'nvenc-hevc'];
3
-
4
- export function encoderArgs(name = 'x264', quality, maxMbps) {
5
- if (!encoders.includes(name)) throw invalidOption('--encoder', '--encoder must be x264, nvenc-h264 or nvenc-hevc.');
6
- if (maxMbps !== undefined && !(Number.isFinite(maxMbps) && maxMbps > 0)) throw invalidOption('--max-bitrate', '--max-bitrate must be positive (Mbps).');
7
- const cap = maxMbps ? ['-maxrate', `${maxMbps}M`, '-bufsize', `${maxMbps * 2}M`] : [];
8
- if (name === 'x264') return ['-c:v', 'libx264', '-profile:v', 'high', '-crf', '16', ...cap];
9
- quality ??= name === 'nvenc-hevc' ? 20 : 21;
10
- if (!Number.isInteger(quality) || quality < 1 || quality > 51) throw invalidOption('NVENC', 'NVENC CQ must be an integer from 1 to 51.');
11
- return ['-c:v', name === 'nvenc-h264' ? 'h264_nvenc' : 'hevc_nvenc',
12
- '-preset', 'p7', '-tune', 'hq', '-rc', 'vbr', '-cq', String(quality), '-b:v', '0',
13
- ...(maxMbps ? cap : ['-qmin', '0', '-qmax', String(Math.min(51, quality + 2))]),
14
- '-multipass', 'fullres', '-spatial-aq', '1', '-temporal-aq', '1', '-rc-lookahead', '32', '-bf', '3',
15
- ...(name === 'nvenc-h264' ? ['-profile:v', 'high'] : ['-tag:v', 'hvc1'])];
16
- }
1
+ import { invalidOption } from './errors.mjs';
2
+ export const encoders = ['x264', 'nvenc-h264', 'nvenc-hevc'];
3
+
4
+ export function encoderArgs(name = 'x264', quality, maxMbps) {
5
+ if (!encoders.includes(name)) throw invalidOption('--encoder', '--encoder must be x264, nvenc-h264 or nvenc-hevc.');
6
+ if (maxMbps !== undefined && !(Number.isFinite(maxMbps) && maxMbps > 0)) throw invalidOption('--max-bitrate', '--max-bitrate must be positive (Mbps).');
7
+ const cap = maxMbps ? ['-maxrate', `${maxMbps}M`, '-bufsize', `${maxMbps * 2}M`] : [];
8
+ if (name === 'x264') return ['-c:v', 'libx264', '-profile:v', 'high', '-crf', '16', ...cap];
9
+ quality ??= name === 'nvenc-hevc' ? 20 : 21;
10
+ if (!Number.isInteger(quality) || quality < 1 || quality > 51) throw invalidOption('NVENC', 'NVENC CQ must be an integer from 1 to 51.');
11
+ return ['-c:v', name === 'nvenc-h264' ? 'h264_nvenc' : 'hevc_nvenc',
12
+ '-preset', 'p7', '-tune', 'hq', '-rc', 'vbr', '-cq', String(quality), '-b:v', '0',
13
+ ...(maxMbps ? cap : ['-qmin', '0', '-qmax', String(Math.min(51, quality + 2))]),
14
+ '-multipass', 'fullres', '-spatial-aq', '1', '-temporal-aq', '1', '-rc-lookahead', '32', '-bf', '3',
15
+ ...(name === 'nvenc-h264' ? ['-profile:v', 'high'] : ['-tag:v', 'hvc1'])];
16
+ }
@@ -1,56 +1,56 @@
1
- import { chromium } from 'playwright';
2
- import { browserOptions, browserHint } from './platform.mjs';
3
- import { MultiplaneError } from './errors.mjs';
4
- export async function launchBrowser(options = {}, launcher = chromium, platform = process.platform) {
5
- let browser;
6
- try {
7
- try {
8
- browser = await launcher.launch(browserOptions(options.raster ?? 'gpu', options, platform));
9
- if (platform !== 'linux' || options.raster === 'software' || (await environment(browser)).gpu.hardware) return browser;
10
- await browser.close(); browser = undefined;
11
- } catch (error) {
12
- await browser?.close(); browser = undefined;
13
- if (platform !== 'linux' || options.raster === 'software' || /Executable doesn't exist/.test(error.message)) throw error;
14
- }
15
- // ANGLE can launch without a working GL device on a headless Linux host.
16
- // Relaunch with SwiftShader so WebGL shots remain usable and repeatable.
17
- return await launcher.launch(browserOptions('software', options, platform));
18
- } catch (cause) { throw new MultiplaneError(/Executable doesn't exist/.test(cause.message) ? 'browser_not_installed' : 'browser_launch_failed', undefined,
19
- { cause, hint: browserHint(/Executable doesn't exist/.test(cause.message), platform), details: { reason: cause.message.split(/\r?\n/)[0] } }); }
20
- }
21
- export async function environment(browser, ffmpeg = null) {
22
- const session = await browser.newBrowserCDPSession();
23
- try {
24
- const { gpu } = await session.send('SystemInfo.getInfo');
25
- const renderer = gpu.auxAttributes?.glRenderer ?? gpu.devices?.[0]?.deviceString ?? 'unknown';
26
- return { node: process.version, platform: process.platform, chromium: browser.version(),
27
- gpu: { renderer, hardware: !/swiftshader|llvmpipe|software|unknown/i.test(renderer) }, ffmpeg };
28
- } finally { await session.detach(); }
29
- }
30
- export async function loaded(events, capture, ffmpeg = null) {
31
- events.options.alpha = capture.alpha;
32
- events.start(true);
33
- const { width, height, duration, fps, cuts, post } = capture;
34
- events.emit('shot', { width, height, duration, fps, cuts, post });
35
- events.check();
36
- }
37
-
38
- export function warnGpuFallback(events, info, options = {}) {
39
- if ((options.raster ?? 'gpu') === 'gpu' && !info.gpu.hardware) {
40
- events.emit('warning', { code: 'gpu_software_fallback', message: 'Hardware GPU rasterization is unavailable.' });
41
- }
42
- }
43
-
44
- export const observeBrowser = (events, ffmpeg = null) => {
45
- let observed = false;
46
- return async browser => {
47
- const unsubscribe = events.onCancel(() => browser.close());
48
- browser.once('disconnected', unsubscribe);
49
- events.check();
50
- if (observed) return;
51
- const info = await environment(browser, ffmpeg);
52
- observed = true;
53
- events.emit('environment', info);
54
- warnGpuFallback(events, info, events.options);
55
- };
56
- };
1
+ import { chromium } from 'playwright';
2
+ import { browserOptions, browserHint } from './platform.mjs';
3
+ import { MultiplaneError } from './errors.mjs';
4
+ export async function launchBrowser(options = {}, launcher = chromium, platform = process.platform) {
5
+ let browser;
6
+ try {
7
+ try {
8
+ browser = await launcher.launch(browserOptions(options.raster ?? 'gpu', options, platform));
9
+ if (platform !== 'linux' || options.raster === 'software' || (await environment(browser)).gpu.hardware) return browser;
10
+ await browser.close(); browser = undefined;
11
+ } catch (error) {
12
+ await browser?.close(); browser = undefined;
13
+ if (platform !== 'linux' || options.raster === 'software' || /Executable doesn't exist/.test(error.message)) throw error;
14
+ }
15
+ // ANGLE can launch without a working GL device on a headless Linux host.
16
+ // Relaunch with SwiftShader so WebGL shots remain usable and repeatable.
17
+ return await launcher.launch(browserOptions('software', options, platform));
18
+ } catch (cause) { throw new MultiplaneError(/Executable doesn't exist/.test(cause.message) ? 'browser_not_installed' : 'browser_launch_failed', undefined,
19
+ { cause, hint: browserHint(/Executable doesn't exist/.test(cause.message), platform), details: { reason: cause.message.split(/\r?\n/)[0] } }); }
20
+ }
21
+ export async function environment(browser, ffmpeg = null) {
22
+ const session = await browser.newBrowserCDPSession();
23
+ try {
24
+ const { gpu } = await session.send('SystemInfo.getInfo');
25
+ const renderer = gpu.auxAttributes?.glRenderer ?? gpu.devices?.[0]?.deviceString ?? 'unknown';
26
+ return { node: process.version, platform: process.platform, chromium: browser.version(),
27
+ gpu: { renderer, hardware: !/swiftshader|llvmpipe|software|unknown/i.test(renderer) }, ffmpeg };
28
+ } finally { await session.detach(); }
29
+ }
30
+ export async function loaded(events, capture, ffmpeg = null) {
31
+ events.options.alpha = capture.alpha;
32
+ events.start(true);
33
+ const { width, height, duration, fps, cuts, post } = capture;
34
+ events.emit('shot', { width, height, duration, fps, cuts, post });
35
+ events.check();
36
+ }
37
+
38
+ export function warnGpuFallback(events, info, options = {}) {
39
+ if ((options.raster ?? 'gpu') === 'gpu' && !info.gpu.hardware) {
40
+ events.emit('warning', { code: 'gpu_software_fallback', message: 'Hardware GPU rasterization is unavailable.' });
41
+ }
42
+ }
43
+
44
+ export const observeBrowser = (events, ffmpeg = null) => {
45
+ let observed = false;
46
+ return async browser => {
47
+ const unsubscribe = events.onCancel(() => browser.close());
48
+ browser.once('disconnected', unsubscribe);
49
+ events.check();
50
+ if (observed) return;
51
+ const info = await environment(browser, ffmpeg);
52
+ observed = true;
53
+ events.emit('environment', info);
54
+ warnGpuFallback(events, info, events.options);
55
+ };
56
+ };
package/src/errors.mjs CHANGED
@@ -18,6 +18,7 @@ export const errorCodes = Object.freeze(Object.fromEntries([
18
18
  [3, 'skill_changed', 'The installed skill changed since inspection.'],
19
19
  [3, 'unsupported_node', 'Node 24 or newer is required.'],
20
20
  [2, 'invalid_option', 'Invalid option.'],
21
+ [2, 'resume_unavailable', 'This render cannot be resumed.'],
21
22
  [2, 'invalid_scale', 'Scale must produce positive even integer dimensions.'],
22
23
  [2, 'output_exists', 'Output already exists.'],
23
24
  [3, 'ffmpeg_not_found', 'FFmpeg was not found.'],
package/src/events.mjs CHANGED
@@ -105,6 +105,7 @@ export function createEvents(command, shot, options = {}, job = false, deferStar
105
105
  if (!options._hostedCLI) monitor();
106
106
  return {
107
107
  directory, jobId, emit, warning, artifacts, options, monitor,
108
+ ...(options._resume ? { resumeData: { resumedFrom: options._resume.jobId, resume: { segmentsReused: 0, framesReused: 0 } } } : {}),
108
109
  // TODO(design): Document memoryBytes in status.json (latest sample, null on failure).
109
110
  memory(bytes) { state.memoryBytes = bytes; state.updatedAt = new Date().toISOString(); save(); },
110
111
  preview(frame) { state.previewFrame = frame; state.updatedAt = new Date().toISOString(); save(); },
@@ -113,7 +114,8 @@ export function createEvents(command, shot, options = {}, job = false, deferStar
113
114
  if (deferStart && !resolved) { save(); return; }
114
115
  startedEvent = true;
115
116
  const { onEvent, _preview, _resources, _probeVideo, _inspectFFmpeg, _hostedCLI, ...effective } = options;
116
- emit('start', { version, shot, ...(jobId ? { jobId } : {}), options: effective, ...(this.workersSource ? { workersSource: this.workersSource } : {}) });
117
+ for (const key of Object.keys(effective)) if (key.startsWith('_')) delete effective[key];
118
+ emit('start', { ...this.resumeData, version, shot, ...(jobId ? { jobId } : {}), options: effective, ...(this.workersSource ? { workersSource: this.workersSource } : {}) });
117
119
  index('start', { command, shot, pid: process.pid });
118
120
  for (const [type, fields] of pendingEvents.splice(0)) emit(type, fields);
119
121
  },
@@ -129,7 +131,7 @@ export function createEvents(command, shot, options = {}, job = false, deferStar
129
131
  if (deferStart) this.start(true);
130
132
  const result = { command, ok: !error, code: error?.code ?? null, message: error?.message ?? null,
131
133
  ...(error?.hint ? { hint: error.hint } : {}), ...(error?.details ? { details: error.details } : {}),
132
- artifacts, elapsedMs: Math.round(performance.now() - started), ...(jobId ? { jobId } : {}), data };
134
+ artifacts, elapsedMs: Math.round(performance.now() - started), ...(jobId ? { jobId } : {}), data: this.resumeData ? { ...this.resumeData, ...data } : data };
133
135
  index('end', { state: !error ? 'succeeded' : error.code === 'cancelled' ? 'cancelled' : 'failed' });
134
136
  emit('result', result);
135
137
  return result;
@@ -142,7 +144,7 @@ export async function runCommand(command, shot, options, action, job = true) {
142
144
  let events, resources, linted = false;
143
145
  try {
144
146
  const explicit = options;
145
- const user = loadUserConfig(command, options);
147
+ const user = options._effectiveOptions ? { options: { ...options }, config: { applied: [] }, warnings: [] } : loadUserConfig(command, options);
146
148
  options = user.options;
147
149
  if (['render', 'stills', 'verify'].includes(command)) {
148
150
  options.quality = resolveQuality(options);
@@ -192,7 +194,7 @@ export async function runCommand(command, shot, options, action, job = true) {
192
194
  if (!options['no-lint']) { await checkLint(shot, events.options, events); linted = true; }
193
195
  events.start();
194
196
  const request = { schema: 'multiplane/1', jobId: events.jobId, jobDir: events.directory,
195
- command, args: options._hostedCLI.args, cwd: process.cwd(), node: process.execPath,
197
+ command: options._resume ? 'resume' : command, args: options._hostedCLI.args, cwd: process.cwd(), node: process.execPath,
196
198
  bin: options._hostedCLI.bin, env: hostedEnvironment(process.env), at: new Date().toISOString() };
197
199
  const ownership = await handoff(request);
198
200
  if (ownership.hosted) {
@@ -210,7 +212,7 @@ export async function runCommand(command, shot, options, action, job = true) {
210
212
  resources = createResourceSampler({ enabled: options._resources !== false, onSample: events.memory });
211
213
  events.resources = resources;
212
214
  }
213
- if (options.draft) {
215
+ if (options.draft && !options._effectiveOptions) {
214
216
  // Keep lint and cancellation active before the dimension-only draft probe.
215
217
  if (!linted && !options['no-lint']) {
216
218
  const { findings, data } = await scanShot(shot, events.options);
@@ -221,17 +223,17 @@ export async function runCommand(command, shot, options, action, job = true) {
221
223
  }
222
224
  }
223
225
  const normalized = { ...Object.fromEntries(Object.keys(explicit).filter(key => key !== '_hostedCLI').map(key => [key, options[key]])), draft: true };
224
- Object.assign(events.options, await applyDraft(command, shot, normalized, options, {
226
+ Object.assign(events.options, await applyDraft(command, shot, normalized, options, {
225
227
  check: events.check,
226
228
  onBrowser: browser => {
227
229
  const unsubscribe = events.onCancel(() => browser.close());
228
230
  browser.once('disconnected', unsubscribe);
229
231
  events.check();
230
232
  },
231
- }));
232
- // Draft merges explicit options again; retain the absolute output path
233
- // required by atomic directory publication and artifact relocation.
234
- events.options.out = path.resolve(events.options.out);
233
+ }));
234
+ // Draft merges explicit options again; retain the absolute output path
235
+ // required by atomic directory publication and artifact relocation.
236
+ events.options.out = path.resolve(events.options.out);
235
237
  }
236
238
  events.start();
237
239
  for (const warning of user.warnings) events.warning(warning);
package/src/ffmpeg.mjs CHANGED
@@ -1,32 +1,32 @@
1
- import { spawn } from 'node:child_process';
2
- import { MultiplaneError } from './errors.mjs';
3
- import { ffmpegName, ffmpegHint } from './platform.mjs';
4
-
5
- export function ffmpegOutput(executable, args, env = process.env) {
6
- return new Promise((resolve, reject) => {
7
- const child = spawn(executable, args, { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
8
- const timer = setTimeout(() => child.kill('SIGKILL'), 15000);
9
- child.once('close', () => clearTimeout(timer));
10
- child.once('error', () => clearTimeout(timer));
11
- let output = '';
12
- child.stdout.on('data', bytes => { output += bytes; });
13
- child.stderr.on('data', bytes => { output += bytes; });
14
- child.once('error', reject);
15
- child.once('close', code => code === 0 ? resolve(output) : reject(new Error(`FFmpeg exited with code ${code}.`)));
16
- });
17
- }
18
- export async function inspectFFmpeg(explicit, env = process.env, encoder, output = ffmpegOutput) {
19
- const executable = explicit ?? env.MULTIPLANE_FFMPEG ?? ffmpegName();
20
- try {
21
- const version = (await output(executable, ['-version'], env)).split(/\r?\n/)[0];
22
- if (encoder && !new RegExp(`\\b${encoder}\\b`).test(await output(executable, ['-encoders'], env))) throw new Error(`Missing encoder: ${encoder}`);
23
- return { path: executable, version };
24
- } catch (cause) {
25
- throw new MultiplaneError(cause.code === 'ENOENT' ? 'ffmpeg_not_found' : 'ffmpeg_unusable', undefined,
26
- { cause, hint: cause.message.startsWith('Missing encoder:') ? `${cause.message}. Use an FFmpeg build that includes it.` : ffmpegHint(), details: { reason: cause.message } });
27
- }
28
- }
29
-
30
- export async function resolveFFmpeg(explicit, env = process.env) {
31
- return (await inspectFFmpeg(explicit, env)).path;
32
- }
1
+ import { spawn } from 'node:child_process';
2
+ import { MultiplaneError } from './errors.mjs';
3
+ import { ffmpegName, ffmpegHint } from './platform.mjs';
4
+
5
+ export function ffmpegOutput(executable, args, env = process.env) {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(executable, args, { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
8
+ const timer = setTimeout(() => child.kill('SIGKILL'), 15000);
9
+ child.once('close', () => clearTimeout(timer));
10
+ child.once('error', () => clearTimeout(timer));
11
+ let output = '';
12
+ child.stdout.on('data', bytes => { output += bytes; });
13
+ child.stderr.on('data', bytes => { output += bytes; });
14
+ child.once('error', reject);
15
+ child.once('close', code => code === 0 ? resolve(output) : reject(new Error(`FFmpeg exited with code ${code}.`)));
16
+ });
17
+ }
18
+ export async function inspectFFmpeg(explicit, env = process.env, encoder, output = ffmpegOutput) {
19
+ const executable = explicit ?? env.MULTIPLANE_FFMPEG ?? ffmpegName();
20
+ try {
21
+ const version = (await output(executable, ['-version'], env)).split(/\r?\n/)[0];
22
+ if (encoder && !new RegExp(`\\b${encoder}\\b`).test(await output(executable, ['-encoders'], env))) throw new Error(`Missing encoder: ${encoder}`);
23
+ return { path: executable, version };
24
+ } catch (cause) {
25
+ throw new MultiplaneError(cause.code === 'ENOENT' ? 'ffmpeg_not_found' : 'ffmpeg_unusable', undefined,
26
+ { cause, hint: cause.message.startsWith('Missing encoder:') ? `${cause.message}. Use an FFmpeg build that includes it.` : ffmpegHint(), details: { reason: cause.message } });
27
+ }
28
+ }
29
+
30
+ export async function resolveFFmpeg(explicit, env = process.env) {
31
+ return (await inspectFFmpeg(explicit, env)).path;
32
+ }
package/src/format.mjs CHANGED
@@ -1,27 +1,27 @@
1
- import path from 'node:path';
2
- import { MultiplaneError, invalidOption } from './errors.mjs';
3
- import { encoderArgs } from './encoder.mjs';
4
-
5
- export function resolveFormat(options = {}) {
6
- const extension = path.extname(options.out ?? '').slice(1).toLowerCase();
7
- const formats = ['mp4', 'webm', 'mov'];
8
- const format = options.format ?? (formats.includes(extension) ? extension : 'mp4');
9
- if (!formats.includes(format)) throw invalidOption('--format', '--format must be mp4, webm or mov.');
10
- // TODO(design): Document rejection of unknown output extensions as well as conflicts.
11
- if (extension && extension !== format) throw invalidOption('--out', '--out extension must match --format.');
12
- if (format !== 'mp4' && options.encoder !== undefined) throw invalidOption('--encoder', '--encoder only applies to mp4; omit it for webm or mov.');
13
- // TODO(design): Define bitrate controls for the fixed-codec formats if needed.
14
- if (format !== 'mp4' && options['max-bitrate'] !== undefined) throw invalidOption('--max-bitrate', '--max-bitrate currently only applies to mp4.');
15
- if (format === 'mp4' && options.alpha) throw new MultiplaneError('invalid_option', 'MP4 does not support alpha.', { details: { option: '--alpha' }, hint: 'Use --format webm or --format mov and a matching --out extension.' });
16
- return format;
17
- }
18
-
19
- export function videoEncoding(options) {
20
- const { format, alpha } = options;
21
- if (format === 'webm') {
22
- // TODO(design): Document the fixed VP9 quality preset and audio codec.
23
- return { encoder: 'libvpx-vp9', args: ['-c:v', 'libvpx-vp9', '-crf', alpha ? '0' : '16', '-b:v', '0', '-auto-alt-ref', '0', ...(alpha ? ['-lossless', '1'] : [])], pixelFormat: alpha ? 'yuva420p' : 'yuv420p', mux: [], audio: ['-c:a', 'libopus', '-b:a', '320k', '-ac', '2'] };
24
- }
25
- if (format === 'mov') return { encoder: 'prores_ks', args: ['-c:v', 'prores_ks', '-profile:v', '4'], pixelFormat: alpha ? 'yuva444p10le' : 'yuv444p10le', mux: ['-movflags', '+faststart'], audio: ['-c:a', 'aac', '-b:a', '320k', '-ac', '2'] };
26
- return { encoder: options.encoder ?? 'x264', args: encoderArgs(options.encoder ?? 'x264', undefined, options['max-bitrate']), pixelFormat: 'yuv420p', mux: ['-movflags', '+faststart'], audio: ['-c:a', 'aac', '-b:a', '320k', '-ac', '2'] };
27
- }
1
+ import path from 'node:path';
2
+ import { MultiplaneError, invalidOption } from './errors.mjs';
3
+ import { encoderArgs } from './encoder.mjs';
4
+
5
+ export function resolveFormat(options = {}) {
6
+ const extension = path.extname(options.out ?? '').slice(1).toLowerCase();
7
+ const formats = ['mp4', 'webm', 'mov'];
8
+ const format = options.format ?? (formats.includes(extension) ? extension : 'mp4');
9
+ if (!formats.includes(format)) throw invalidOption('--format', '--format must be mp4, webm or mov.');
10
+ // TODO(design): Document rejection of unknown output extensions as well as conflicts.
11
+ if (extension && extension !== format) throw invalidOption('--out', '--out extension must match --format.');
12
+ if (format !== 'mp4' && options.encoder !== undefined) throw invalidOption('--encoder', '--encoder only applies to mp4; omit it for webm or mov.');
13
+ // TODO(design): Define bitrate controls for the fixed-codec formats if needed.
14
+ if (format !== 'mp4' && options['max-bitrate'] !== undefined) throw invalidOption('--max-bitrate', '--max-bitrate currently only applies to mp4.');
15
+ if (format === 'mp4' && options.alpha) throw new MultiplaneError('invalid_option', 'MP4 does not support alpha.', { details: { option: '--alpha' }, hint: 'Use --format webm or --format mov and a matching --out extension.' });
16
+ return format;
17
+ }
18
+
19
+ export function videoEncoding(options) {
20
+ const { format, alpha } = options;
21
+ if (format === 'webm') {
22
+ // TODO(design): Document the fixed VP9 quality preset and audio codec.
23
+ return { encoder: 'libvpx-vp9', args: ['-c:v', 'libvpx-vp9', '-crf', alpha ? '0' : '16', '-b:v', '0', '-auto-alt-ref', '0', ...(alpha ? ['-lossless', '1'] : [])], pixelFormat: alpha ? 'yuva420p' : 'yuv420p', mux: [], audio: ['-c:a', 'libopus', '-b:a', '320k', '-ac', '2'] };
24
+ }
25
+ if (format === 'mov') return { encoder: 'prores_ks', args: ['-c:v', 'prores_ks', '-profile:v', '4'], pixelFormat: alpha ? 'yuva444p10le' : 'yuv444p10le', mux: ['-movflags', '+faststart'], audio: ['-c:a', 'aac', '-b:a', '320k', '-ac', '2'] };
26
+ return { encoder: options.encoder ?? 'x264', args: encoderArgs(options.encoder ?? 'x264', undefined, options['max-bitrate']), pixelFormat: 'yuv420p', mux: ['-movflags', '+faststart'], audio: ['-c:a', 'aac', '-b:a', '320k', '-ac', '2'] };
27
+ }
@@ -1,28 +1,28 @@
1
- import { invalidOption } from './errors.mjs';
2
-
3
- // Encoding time only: capture and post-processing must keep the requested fps.
4
- // Snap NTSC shorthand within 0.0001 fps of n * 1000 / 1001. Otherwise round
5
- // to six decimal places (at most 0.0000005 fps error) and reduce the fraction.
6
- // FFmpeg's AVRational stores both terms as signed 32-bit integers.
7
- export function frameRate(fps) {
8
- if (!Number.isFinite(fps) || fps <= 0) throw invalidOption('--fps', '--fps must be positive and finite.');
9
- const nominal = Math.round(fps * 1001 / 1000);
10
- let numerator, denominator;
11
- if (nominal > 0 && Math.abs(fps - nominal * 1000 / 1001) <= 0.0001) {
12
- numerator = nominal * 1000;
13
- denominator = 1001;
14
- } else {
15
- numerator = Math.round(fps * 1e6);
16
- denominator = 1e6;
17
- }
18
- if (!Number.isSafeInteger(numerator) || numerator <= 0) throw invalidOption('--fps', 'Frame rate cannot be represented at micro-fps precision.');
19
- let a = numerator, b = denominator;
20
- while (b) [a, b] = [b, a % b];
21
- numerator /= a; denominator /= a;
22
- if (numerator > 2147483647) throw invalidOption('--fps', 'Frame rate exceeds FFmpeg rational precision.');
23
- return {
24
- numerator, denominator, argument: `${numerator}/${denominator}`,
25
- fps: numerator / denominator,
26
- seconds: frames => frames * denominator / numerator,
27
- };
28
- }
1
+ import { invalidOption } from './errors.mjs';
2
+
3
+ // Encoding time only: capture and post-processing must keep the requested fps.
4
+ // Snap NTSC shorthand within 0.0001 fps of n * 1000 / 1001. Otherwise round
5
+ // to six decimal places (at most 0.0000005 fps error) and reduce the fraction.
6
+ // FFmpeg's AVRational stores both terms as signed 32-bit integers.
7
+ export function frameRate(fps) {
8
+ if (!Number.isFinite(fps) || fps <= 0) throw invalidOption('--fps', '--fps must be positive and finite.');
9
+ const nominal = Math.round(fps * 1001 / 1000);
10
+ let numerator, denominator;
11
+ if (nominal > 0 && Math.abs(fps - nominal * 1000 / 1001) <= 0.0001) {
12
+ numerator = nominal * 1000;
13
+ denominator = 1001;
14
+ } else {
15
+ numerator = Math.round(fps * 1e6);
16
+ denominator = 1e6;
17
+ }
18
+ if (!Number.isSafeInteger(numerator) || numerator <= 0) throw invalidOption('--fps', 'Frame rate cannot be represented at micro-fps precision.');
19
+ let a = numerator, b = denominator;
20
+ while (b) [a, b] = [b, a % b];
21
+ numerator /= a; denominator /= a;
22
+ if (numerator > 2147483647) throw invalidOption('--fps', 'Frame rate exceeds FFmpeg rational precision.');
23
+ return {
24
+ numerator, denominator, argument: `${numerator}/${denominator}`,
25
+ fps: numerator / denominator,
26
+ seconds: frames => frames * denominator / numerator,
27
+ };
28
+ }