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.
- package/LICENSE +202 -202
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/THIRD_PARTY_NOTICES.md +30 -30
- package/bin/multiplane +24 -14
- package/examples/alpha.html +18 -18
- package/examples/animation.html +17 -17
- package/examples/assets/OFL.txt +93 -93
- package/examples/assets/card.svg +6 -6
- package/examples/three-cards.html +17 -17
- package/examples/typography.html +17 -17
- package/licenses/GPL-3.0.txt +232 -232
- package/licenses/LGPL-3.0.txt +304 -304
- package/licenses/acorn-LICENSE +21 -21
- package/licenses/colour-LICENSE.md +82 -82
- package/licenses/detect-libc-LICENSE +201 -201
- package/licenses/entities-LICENSE +11 -11
- package/licenses/nanoid-LICENSE +20 -20
- package/licenses/parse5-LICENSE +19 -19
- package/licenses/picocolors-LICENSE +15 -15
- package/licenses/playwright-NOTICE +5 -5
- package/licenses/playwright-ThirdPartyNotices.txt +14 -14
- package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
- package/licenses/postcss-LICENSE +20 -20
- package/licenses/postcss-value-parser-LICENSE +22 -22
- package/licenses/semver-LICENSE +15 -15
- package/licenses/sharp-LICENSE +191 -191
- package/licenses/sharp-win32-x64-NOTICES.md +45 -45
- package/licenses/source-map-js-LICENSE +28 -28
- package/licenses/three-LICENSE +21 -21
- package/package.json +92 -92
- package/skills/multiplane/SKILL.md +3 -0
- package/src/alpha.mjs +69 -69
- package/src/artifacts.mjs +61 -61
- package/src/browser-recovery.mjs +26 -26
- package/src/capture-watchdog.mjs +29 -0
- package/src/capture.mjs +34 -18
- package/src/encoder.mjs +16 -16
- package/src/environment.mjs +56 -56
- package/src/errors.mjs +1 -0
- package/src/events.mjs +12 -10
- package/src/ffmpeg.mjs +32 -32
- package/src/format.mjs +27 -27
- package/src/frame-rate.mjs +28 -28
- package/src/home-migration.mjs +54 -54
- package/src/hosted.mjs +3 -3
- package/src/index.mjs +1 -0
- package/src/input-records.mjs +33 -0
- package/src/jobs.mjs +3 -1
- package/src/lint.mjs +248 -248
- package/src/output-alpha.mjs +36 -36
- package/src/output-check.mjs +110 -110
- package/src/platform.mjs +114 -114
- package/src/png-native.mjs +12 -12
- package/src/png.mjs +74 -74
- package/src/post.mjs +56 -39
- package/src/preview.mjs +23 -23
- package/src/process.mjs +9 -9
- package/src/render-pool.mjs +41 -14
- package/src/render-worker.mjs +14 -3
- package/src/render.mjs +148 -52
- package/src/resource-path.mjs +29 -29
- package/src/resources.mjs +27 -27
- package/src/resume.mjs +23 -0
- package/src/runtime/camera.js +37 -37
- package/src/runtime/shot.css +9 -9
- package/src/segment-encoder.mjs +91 -0
- package/src/segments.mjs +123 -0
- package/src/server.mjs +11 -7
- package/src/setup.mjs +51 -51
- package/src/skills.mjs +128 -128
- package/src/stills.mjs +1 -1
- package/src/three/index.js +39 -31
- package/src/verify.mjs +2 -2
- package/src/video-pipe.mjs +6 -6
- package/tools/bench/shots/canvas-2d.html +1 -1
- package/tools/bench/shots/dom-light.html +1 -1
- package/tools/bench/shots/scene.js +44 -44
- package/tools/bench/shots/webgl.html +1 -1
- 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
|
+
}
|
package/src/environment.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/frame-rate.mjs
CHANGED
|
@@ -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
|
+
}
|