multiplane 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.en.md +322 -0
- package/README.md +321 -2
- package/THIRD_PARTY_NOTICES.md +30 -0
- package/bin/multiplane +98 -0
- package/examples/alpha.html +18 -0
- package/examples/animation.html +17 -0
- package/examples/assets/OFL.txt +93 -0
- package/examples/assets/Roboto-Variable.ttf +0 -0
- package/examples/assets/card.svg +6 -0
- package/examples/assets/readme-card-0.svg +1 -0
- package/examples/assets/readme-card-1.svg +1 -0
- package/examples/assets/readme-card-2.svg +1 -0
- package/examples/assets/readme-detail.svg +1 -0
- package/examples/readme-cards.html +23 -0
- package/examples/readme-cut.html +13 -0
- package/examples/readme-depth.html +15 -0
- package/examples/readme-wheel.html +14 -0
- package/examples/three-cards.html +17 -0
- package/examples/typography.html +17 -0
- package/licenses/GPL-3.0.txt +232 -0
- package/licenses/LGPL-3.0.txt +304 -0
- package/licenses/acorn-LICENSE +21 -0
- package/licenses/colour-LICENSE.md +82 -0
- package/licenses/detect-libc-LICENSE +201 -0
- package/licenses/entities-LICENSE +11 -0
- package/licenses/nanoid-LICENSE +20 -0
- package/licenses/native/README.ijg +258 -0
- package/licenses/native/aom-LICENSE +27 -0
- package/licenses/native/aom-PATENTS +108 -0
- package/licenses/native/archive-COPYING +65 -0
- package/licenses/native/cairo-COPYING +33 -0
- package/licenses/native/cairo-COPYING-MPL-1.1 +470 -0
- package/licenses/native/cgif-LICENSE +21 -0
- package/licenses/native/exif-COPYING +504 -0
- package/licenses/native/expat-COPYING +21 -0
- package/licenses/native/ffi-LICENSE +21 -0
- package/licenses/native/fontconfig-COPYING +200 -0
- package/licenses/native/freetype-FTL.TXT +169 -0
- package/licenses/native/fribidi-COPYING +501 -0
- package/licenses/native/glib-COPYING +175 -0
- package/licenses/native/harfbuzz-COPYING +42 -0
- package/licenses/native/heif-COPYING +871 -0
- package/licenses/native/highway-LICENSE +371 -0
- package/licenses/native/imagequant-COPYRIGHT +36 -0
- package/licenses/native/lcms-COPYING +22 -0
- package/licenses/native/mozjpeg-LICENSE.md +135 -0
- package/licenses/native/nsgif-COPYING +21 -0
- package/licenses/native/pango-COPYING +482 -0
- package/licenses/native/pixman-COPYING +42 -0
- package/licenses/native/png-LICENSE +134 -0
- package/licenses/native/proxy-libintl-COPYING +482 -0
- package/licenses/native/rsvg-COPYING.LIB +502 -0
- package/licenses/native/sources.json +240 -0
- package/licenses/native/tiff-LICENSE.md +49 -0
- package/licenses/native/uhdr-LICENSE +230 -0
- package/licenses/native/vips-COPYING +502 -0
- package/licenses/native/webp-COPYING +30 -0
- package/licenses/native/webp-PATENTS +23 -0
- package/licenses/native/xml2-Copyright +24 -0
- package/licenses/native/zlib-ng-LICENSE.md +19 -0
- package/licenses/parse5-LICENSE +19 -0
- package/licenses/picocolors-LICENSE +15 -0
- package/licenses/playwright-LICENSE +202 -0
- package/licenses/playwright-NOTICE +5 -0
- package/licenses/playwright-ThirdPartyNotices.txt +14 -0
- package/licenses/playwright-core-ThirdPartyNotices.txt +13 -0
- package/licenses/postcss-LICENSE +20 -0
- package/licenses/postcss-value-parser-LICENSE +22 -0
- package/licenses/semver-LICENSE +15 -0
- package/licenses/sharp-LICENSE +191 -0
- package/licenses/sharp-win32-x64-LICENSE +191 -0
- package/licenses/sharp-win32-x64-NOTICES.md +59 -0
- package/licenses/source-map-js-LICENSE +28 -0
- package/licenses/three-LICENSE +21 -0
- package/package.json +92 -6
- package/skills/multiplane/SKILL.md +213 -0
- package/src/alpha.mjs +69 -0
- package/src/artifacts.mjs +61 -0
- package/src/benchmark.mjs +140 -0
- package/src/browser-recovery.mjs +26 -0
- package/src/capture.mjs +224 -0
- package/src/config.mjs +79 -0
- package/src/doctor.mjs +56 -0
- package/src/draft.mjs +30 -0
- package/src/encoder.mjs +16 -0
- package/src/environment.mjs +56 -0
- package/src/errors.mjs +51 -0
- package/src/events.mjs +252 -0
- package/src/ffmpeg.mjs +32 -0
- package/src/format.mjs +27 -0
- package/src/frame-rate.mjs +28 -0
- package/src/home-migration.mjs +54 -0
- package/src/hosted.mjs +134 -0
- package/src/index.mjs +14 -0
- package/src/inspect.mjs +20 -0
- package/src/jobs.mjs +51 -0
- package/src/lint.mjs +248 -0
- package/src/output-alpha.mjs +36 -0
- package/src/output-check.mjs +110 -0
- package/src/pixel-comparison.mjs +43 -0
- package/src/platform.mjs +114 -0
- package/src/png-native.mjs +12 -0
- package/src/png.mjs +74 -0
- package/src/post.mjs +433 -0
- package/src/preview.mjs +23 -0
- package/src/process-identity.mjs +17 -0
- package/src/process.mjs +9 -0
- package/src/render-pool.mjs +93 -0
- package/src/render-worker.mjs +59 -0
- package/src/render.mjs +157 -0
- package/src/resource-path.mjs +29 -0
- package/src/resources.mjs +27 -0
- package/src/runtime/camera.js +37 -0
- package/src/runtime/shot.css +9 -0
- package/src/runtime/shot.js +71 -0
- package/src/serve.mjs +23 -0
- package/src/server.mjs +93 -0
- package/src/setup.mjs +51 -0
- package/src/skills.mjs +128 -0
- package/src/static-reuse.mjs +265 -0
- package/src/stills.mjs +81 -0
- package/src/three/index.js +348 -0
- package/src/user-home.mjs +105 -0
- package/src/verify.mjs +161 -0
- package/src/video-pipe.mjs +6 -0
- package/src/workers.mjs +62 -0
- package/tools/bench/shots/canvas-2d.html +1 -0
- package/tools/bench/shots/dom-light.html +1 -0
- package/tools/bench/shots/scene.js +45 -0
- package/tools/bench/shots/webgl.html +1 -0
- package/tools/install-browser.mjs +18 -0
package/src/doctor.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readBenchmark, validBenchmark, benchmarkMachine } from './workers.mjs';
|
|
2
|
+
import { runCommand } from './events.mjs';
|
|
3
|
+
import { launchBrowser, environment, warnGpuFallback } from './environment.mjs';
|
|
4
|
+
import { inspectFFmpeg, ffmpegOutput } from './ffmpeg.mjs';
|
|
5
|
+
import { MultiplaneError } from './errors.mjs';
|
|
6
|
+
|
|
7
|
+
export function benchmarkCheck(record, machine) {
|
|
8
|
+
const current = validBenchmark(record, machine);
|
|
9
|
+
const limits = Object.entries(record.shots ?? {}).filter(([, shot]) => shot?.limitedBy === 'memory')
|
|
10
|
+
.map(([type, shot]) => `${type}: 受内存限制,只测到 ${shot.points?.at(-1)?.workers ?? shot.workers} 个并发。`);
|
|
11
|
+
return { name: 'benchmark', ok: current,
|
|
12
|
+
detail: [current ? 'Local performance benchmark is current.' : '本机性能测试已过期。Run multiplane benchmark to retest; rendering can continue.', ...limits].join(' ') };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const doctor = (options = {}) => runCommand('doctor', null, options, async events => {
|
|
16
|
+
options = events.options;
|
|
17
|
+
const moved = events.migration.migrated.filter(item => ['config.json', 'jobs.jsonl'].includes(item.name));
|
|
18
|
+
const checks = [{ name: 'home', ok: true, detail: `User directory: ${events.config.path.slice(0, -'config.json'.length)}.` + (moved.length ? ` Copied from the previous location: ${moved.map(item => item.source).join(', ')}; original files were retained.` : '') }];
|
|
19
|
+
let failure, browser, info, ffmpeg;
|
|
20
|
+
const nodeOk = Number(process.versions.node.split('.')[0]) >= 24;
|
|
21
|
+
checks.push({ name: 'node', ok: nodeOk, detail: `Node ${process.version}; version 24 or newer is required.` });
|
|
22
|
+
if (!nodeOk) failure = new MultiplaneError('unsupported_node', 'Node 24 or newer is required.');
|
|
23
|
+
try {
|
|
24
|
+
browser = await launchBrowser(options);
|
|
25
|
+
checks.push({ name: 'browser', ok: true, detail: `Chromium ${browser.version()} is available.` });
|
|
26
|
+
info = await environment(browser);
|
|
27
|
+
checks.push({ name: 'gpu', ok: info.gpu.hardware, detail: `GPU renderer: ${info.gpu.renderer}.` });
|
|
28
|
+
warnGpuFallback(events, info, options);
|
|
29
|
+
const record = readBenchmark();
|
|
30
|
+
if (record) {
|
|
31
|
+
checks.push(benchmarkCheck(record, benchmarkMachine(info, options.raster)));
|
|
32
|
+
}
|
|
33
|
+
} catch (error) {
|
|
34
|
+
failure ??= error;
|
|
35
|
+
checks.push({ name: 'browser', ok: false, detail: error.message }, { name: 'gpu', ok: false, detail: 'GPU could not be inspected.' });
|
|
36
|
+
} finally { await browser?.close(); }
|
|
37
|
+
try {
|
|
38
|
+
ffmpeg = await inspectFFmpeg(options.ffmpeg);
|
|
39
|
+
checks.push({ name: 'ffmpeg', ok: true, detail: ffmpeg.version });
|
|
40
|
+
const encoderList = await ffmpegOutput(ffmpeg.path, ['-encoders']);
|
|
41
|
+
const x264 = /\blibx264\b/.test(encoderList);
|
|
42
|
+
for (const name of ['libvpx-vp9', 'prores_ks']) checks.push({ name, ok: new RegExp('\\b' + name + '\\b').test(encoderList), detail: name + ' is optional; required only for its video format, including transparent output.' });
|
|
43
|
+
checks.push({ name: 'libx264', ok: x264, detail: x264 ? 'libx264 is available.' : 'libx264 is unavailable.' });
|
|
44
|
+
if (!x264) failure ??= new MultiplaneError('ffmpeg_unusable', undefined, { hint: 'Install an FFmpeg build with libx264.' });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
failure ??= error;
|
|
47
|
+
checks.push({ name: 'ffmpeg', ok: false, detail: error.message }, { name: 'libx264', ok: false, detail: 'Encoder list could not be inspected.' });
|
|
48
|
+
for (const name of ['libvpx-vp9', 'prores_ks']) checks.push({ name, ok: false, detail: `${name} is optional; encoder availability could not be inspected.` });
|
|
49
|
+
}
|
|
50
|
+
if (info) events.emit('environment', { ...info, ffmpeg: ffmpeg ?? null });
|
|
51
|
+
try { await import('three'); checks.push({ name: 'three', ok: true, detail: 'Optional three.js is available.' }); }
|
|
52
|
+
catch { checks.push({ name: 'three', ok: false, detail: 'Optional three.js is not installed.' }); }
|
|
53
|
+
if (failure && checks.some(c => ['ffmpeg', 'libx264'].includes(c.name) && !c.ok)) failure.hint = (failure.hint ? failure.hint + ' ' : '') + 'Run multiplane config set ffmpeg <path> to save an FFmpeg executable.';
|
|
54
|
+
if (failure) { failure.result = { data: { checks, config: events.config } }; throw failure; }
|
|
55
|
+
return { checks, config: events.config };
|
|
56
|
+
}, false);
|
package/src/draft.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { openShot, validateScale, number } from './capture.mjs';
|
|
2
|
+
import { invalidOption } from './errors.mjs';
|
|
3
|
+
|
|
4
|
+
export function draftScale(width, height, outputScale = 1) {
|
|
5
|
+
let a = width, b = height;
|
|
6
|
+
while (b) [a, b] = [b, a % b];
|
|
7
|
+
const limit = Math.floor(Math.min(0.5, outputScale) * a / 2);
|
|
8
|
+
for (let units = limit; units > 0; units--) {
|
|
9
|
+
const scale = 2 * units / a;
|
|
10
|
+
if ([width * scale, height * scale].every(v => Number.isSafeInteger(v) && v > 0 && v % 2 === 0)) return scale;
|
|
11
|
+
}
|
|
12
|
+
validateScale(width, height, 0, '--render-scale');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function applyDraft(command, shot, explicit, configured, probe = {}) {
|
|
16
|
+
if (!explicit.draft) return configured;
|
|
17
|
+
if (!['render', 'stills', 'verify'].includes(command)) throw invalidOption('--draft', '--draft is supported by render, stills and verify.');
|
|
18
|
+
const options = { ...configured, 'motion-blur': 1, grain: 0, upscale: 'lanczos', ...explicit, draft: true };
|
|
19
|
+
options['max-motion-blur'] = 1;
|
|
20
|
+
if (options['no-post']) Object.assign(options, { 'motion-blur': 1, grain: 0, shutter: 0.5 });
|
|
21
|
+
if (explicit['render-scale'] === undefined) {
|
|
22
|
+
// TODO(design): Document the dimension preflight before start for --draft in the v1 protocol.
|
|
23
|
+
// Load dimensions before start so the event describes the effective preset.
|
|
24
|
+
const outputScale = number(options.scale ?? 1, '--scale', { positive: true });
|
|
25
|
+
const capture = await openShot(shot, outputScale, { ...options, ...probe });
|
|
26
|
+
try { options['render-scale'] = draftScale(capture.width, capture.height, outputScale); }
|
|
27
|
+
finally { await capture.close(); }
|
|
28
|
+
}
|
|
29
|
+
return options;
|
|
30
|
+
}
|
package/src/encoder.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
};
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const errorCodes = Object.freeze(Object.fromEntries([
|
|
2
|
+
[1, 'shot_not_found', 'Shot file was not found.'],
|
|
3
|
+
[1, 'shot_outside_root', 'Shot must be inside the project root.'],
|
|
4
|
+
[1, 'shot_load_failed', 'Shot failed to load.'],
|
|
5
|
+
[1, 'shot_contract_invalid', 'Shot contract is invalid.'],
|
|
6
|
+
[1, 'stage_size_invalid', 'Stage must have fixed positive integer dimensions.'],
|
|
7
|
+
[1, 'ready_timeout', 'Shot readiness timed out.'],
|
|
8
|
+
[1, 'render_threw', 'Shot render function threw an error.'],
|
|
9
|
+
[1, 'resource_failed', 'A shot resource failed to load.'],
|
|
10
|
+
[1, 'nondeterministic_output', 'Determinism verification failed.'],
|
|
11
|
+
[1, 'lint_failed', 'Shot lint checks failed.'],
|
|
12
|
+
[1, 'cancel_timeout', 'Timed out waiting for job cancellation.'],
|
|
13
|
+
[3, 'wait_timeout', 'Timed out waiting for job completion.'],
|
|
14
|
+
[2, 'job_not_found', 'Job status was not found or is invalid.'],
|
|
15
|
+
[3, 'config_invalid', 'User configuration is not a JSON object.'],
|
|
16
|
+
[3, 'skill_modified', 'The installed skill has local modifications.'],
|
|
17
|
+
[3, 'skill_blocked', 'The skill directory cannot be safely changed.'],
|
|
18
|
+
[3, 'skill_changed', 'The installed skill changed since inspection.'],
|
|
19
|
+
[3, 'unsupported_node', 'Node 24 or newer is required.'],
|
|
20
|
+
[2, 'invalid_option', 'Invalid option.'],
|
|
21
|
+
[2, 'invalid_scale', 'Scale must produce positive even integer dimensions.'],
|
|
22
|
+
[2, 'output_exists', 'Output already exists.'],
|
|
23
|
+
[3, 'ffmpeg_not_found', 'FFmpeg was not found.'],
|
|
24
|
+
[3, 'ffmpeg_unusable', 'FFmpeg cannot run or lacks the requested encoder.'],
|
|
25
|
+
[3, 'browser_not_installed', 'Chromium is not installed.'],
|
|
26
|
+
[3, 'browser_launch_failed', 'Chromium failed to launch.'],
|
|
27
|
+
[3, 'port_in_use', 'The requested port is already in use.'],
|
|
28
|
+
[3, 'three_not_installed', 'Optional three.js is not installed.'],
|
|
29
|
+
[3, 'job_dir_unwritable', 'Job directory cannot be created or written.'],
|
|
30
|
+
[4, 'browser_crashed', 'Browser closed unexpectedly and recovery failed.'],
|
|
31
|
+
[4, 'worker_crashed', 'A rendering worker exited unexpectedly.'],
|
|
32
|
+
[4, 'encode_failed', 'Video encoding failed.'],
|
|
33
|
+
[4, 'output_invalid', 'Output does not match the rendered frames.'],
|
|
34
|
+
[4, 'internal_error', 'An internal error occurred.'],
|
|
35
|
+
[130, 'cancelled', 'Job was cancelled.'],
|
|
36
|
+
].map(([exitCode, code, message]) => [code, { exitCode, message }])));
|
|
37
|
+
|
|
38
|
+
export class MultiplaneError extends Error {
|
|
39
|
+
constructor(code, message = errorCodes[code]?.message, { hint, details, result, cause } = {}) {
|
|
40
|
+
super(message, { cause });
|
|
41
|
+
this.name = 'MultiplaneError';
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.exitCode = errorCodes[code]?.exitCode ?? 4;
|
|
44
|
+
this.hint = hint;
|
|
45
|
+
this.details = details;
|
|
46
|
+
this.result = result;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export const asError = error => error instanceof MultiplaneError ? error
|
|
50
|
+
: new MultiplaneError('internal_error', undefined, { cause: error, details: { reason: error.message } });
|
|
51
|
+
export const invalidOption = (option, message) => new MultiplaneError('invalid_option', message, { details: { option } });
|
package/src/events.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { resolveQuality } from './post.mjs';
|
|
2
|
+
import { resolveFormat } from './format.mjs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { checkLint, scanShot } from './lint.mjs';
|
|
5
|
+
import { requireNewOutput, directoryOutput } from './artifacts.mjs';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { appendFileSync, mkdirSync, renameSync, writeFileSync, existsSync, statSync, readFileSync, unlinkSync } from 'node:fs';
|
|
8
|
+
import { stopSignals, ffmpegName } from './platform.mjs';
|
|
9
|
+
import { asError, MultiplaneError, invalidOption } from './errors.mjs';
|
|
10
|
+
import { defaultWorkers } from './render-pool.mjs';
|
|
11
|
+
import { loadUserConfig, appendJobIndex, retryFileOperation } from './user-home.mjs';
|
|
12
|
+
import { userHome } from './platform.mjs';
|
|
13
|
+
import { applyDraft } from './draft.mjs';
|
|
14
|
+
import { createResourceSampler } from './resources.mjs';
|
|
15
|
+
import { hostedAvailable, hostedEnvironment, handoff, followJob } from './hosted.mjs';
|
|
16
|
+
|
|
17
|
+
export const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version;
|
|
18
|
+
export const jobDirectory = (options = {}, env = process.env) => path.resolve(options['job-dir'] ?? env.MULTIPLANE_JOBS_DIR ?? 'out/jobs');
|
|
19
|
+
|
|
20
|
+
export function createEvents(command, shot, options = {}, job = false, deferStart = false) {
|
|
21
|
+
const started = performance.now(), startedAt = new Date().toISOString();
|
|
22
|
+
let directory, jobId, ended = false, lastProgress = -Infinity, stopped = false;
|
|
23
|
+
const artifacts = [];
|
|
24
|
+
if (job) {
|
|
25
|
+
const stamp = startedAt.replaceAll('-', '').replaceAll(':', '').replace('T', '-').slice(0, 15);
|
|
26
|
+
try {
|
|
27
|
+
if (options['job-dir-exact']) {
|
|
28
|
+
directory = path.resolve(options['job-dir-exact']);
|
|
29
|
+
if (!statSync(directory).isDirectory()) throw Error('Missing executor job directory.');
|
|
30
|
+
jobId = path.basename(directory);
|
|
31
|
+
} else {
|
|
32
|
+
mkdirSync(jobDirectory(options), { recursive: true });
|
|
33
|
+
for (;;) {
|
|
34
|
+
jobId = `${stamp}-${path.parse(shot).name}-${randomBytes(2).toString('hex')}`;
|
|
35
|
+
directory = path.join(jobDirectory(options), jobId);
|
|
36
|
+
try { mkdirSync(directory); break; } catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Exercise actual file creation, writing and removal before launching a browser.
|
|
40
|
+
const probe = path.join(directory, '.write-check');
|
|
41
|
+
writeFileSync(probe, 'write check', { flag: 'wx' });
|
|
42
|
+
unlinkSync(probe);
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
throw new MultiplaneError('job_dir_unwritable', undefined, {
|
|
45
|
+
cause, details: { path: jobDirectory(options) },
|
|
46
|
+
hint: 'Use --job-dir or MULTIPLANE_JOBS_DIR to select a writable job directory.',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
options.out = path.resolve(options.out ?? (command === 'render' ? path.join(directory, `${path.parse(shot).name}.${options.format ?? 'mp4'}`) : path.join(directory, command)));
|
|
50
|
+
}
|
|
51
|
+
let state = { schema: 'multiplane/1', jobId, command, shot, pid: process.pid, state: 'running',
|
|
52
|
+
phase: 'launch', done: 0, total: null, fps: null, etaMs: null, startedAt, updatedAt: startedAt, result: null, previewFrame: null, memoryBytes: null };
|
|
53
|
+
const save = () => {
|
|
54
|
+
if (!directory) return;
|
|
55
|
+
const file = path.join(directory, 'status.json');
|
|
56
|
+
const deadline = performance.now() + 1000;
|
|
57
|
+
retryFileOperation(() => writeFileSync(file + '.tmp', JSON.stringify(state) + '\n'), deadline);
|
|
58
|
+
retryFileOperation(() => renameSync(file + '.tmp', file), deadline);
|
|
59
|
+
};
|
|
60
|
+
let startedEvent = false;
|
|
61
|
+
const pendingEvents = [];
|
|
62
|
+
const emit = (type, fields = {}) => {
|
|
63
|
+
if (deferStart && !startedEvent && type !== 'start') { pendingEvents.push([type, fields]); return; }
|
|
64
|
+
if (ended) return;
|
|
65
|
+
if (type === 'progress') {
|
|
66
|
+
for (const key of ['phase', 'done', 'total', 'fps', 'etaMs']) state[key] = fields[key];
|
|
67
|
+
state.updatedAt = new Date().toISOString();
|
|
68
|
+
save();
|
|
69
|
+
if (performance.now() - lastProgress < 250) return;
|
|
70
|
+
lastProgress = performance.now();
|
|
71
|
+
}
|
|
72
|
+
const event = { schema: 'multiplane/1', type, command, ts: new Date().toISOString(), ...fields };
|
|
73
|
+
if (type === 'progress') for (const key of ['phase', 'done', 'total', 'fps', 'etaMs']) state[key] = fields[key];
|
|
74
|
+
if (type === 'result') { state.result = event; state.state = event.ok ? 'succeeded' : event.code === 'cancelled' ? 'cancelled' : 'failed'; ended = true; }
|
|
75
|
+
state.updatedAt = event.ts;
|
|
76
|
+
if (directory) appendFileSync(path.join(directory, 'events.jsonl'), JSON.stringify(event) + '\n');
|
|
77
|
+
save();
|
|
78
|
+
options.onEvent?.(event);
|
|
79
|
+
return event;
|
|
80
|
+
};
|
|
81
|
+
const cancelHandlers = new Set();
|
|
82
|
+
let homeWarned = false;
|
|
83
|
+
const warning = fields => {
|
|
84
|
+
if (fields.code === 'home_unavailable') { if (homeWarned) return; homeWarned = true; }
|
|
85
|
+
emit('warning', fields);
|
|
86
|
+
};
|
|
87
|
+
const index = (event, fields) => {
|
|
88
|
+
if (!directory || process.env.MULTIPLANE_NO_INDEX === '1') return;
|
|
89
|
+
try { appendJobIndex(userHome(), { schema: 'multiplane/1', event, jobId, jobDir: directory, ...fields, ts: new Date().toISOString() }); }
|
|
90
|
+
catch { warning({ code: 'home_unavailable', message: 'Job index could not be updated.' }); }
|
|
91
|
+
};
|
|
92
|
+
const requestStop = () => {
|
|
93
|
+
if (stopped) return;
|
|
94
|
+
stopped = true;
|
|
95
|
+
for (const handler of cancelHandlers) Promise.resolve().then(handler).catch(() => {});
|
|
96
|
+
};
|
|
97
|
+
const signals = job ? stopSignals().map(signal => [signal, requestStop]) : [];
|
|
98
|
+
let timer;
|
|
99
|
+
const monitor = () => {
|
|
100
|
+
if (!job || timer) return;
|
|
101
|
+
for (const [signal, handler] of signals) process.on(signal, handler);
|
|
102
|
+
timer = setInterval(() => { if (existsSync(path.join(directory, 'stop'))) requestStop(); }, 100);
|
|
103
|
+
timer.unref();
|
|
104
|
+
};
|
|
105
|
+
if (!options._hostedCLI) monitor();
|
|
106
|
+
return {
|
|
107
|
+
directory, jobId, emit, warning, artifacts, options, monitor,
|
|
108
|
+
// TODO(design): Document memoryBytes in status.json (latest sample, null on failure).
|
|
109
|
+
memory(bytes) { state.memoryBytes = bytes; state.updatedAt = new Date().toISOString(); save(); },
|
|
110
|
+
preview(frame) { state.previewFrame = frame; state.updatedAt = new Date().toISOString(); save(); },
|
|
111
|
+
start(resolved = false) {
|
|
112
|
+
if (startedEvent) return;
|
|
113
|
+
if (deferStart && !resolved) { save(); return; }
|
|
114
|
+
startedEvent = true;
|
|
115
|
+
const { onEvent, _preview, _resources, _probeVideo, _inspectFFmpeg, _hostedCLI, ...effective } = options;
|
|
116
|
+
emit('start', { version, shot, ...(jobId ? { jobId } : {}), options: effective, ...(this.workersSource ? { workersSource: this.workersSource } : {}) });
|
|
117
|
+
index('start', { command, shot, pid: process.pid });
|
|
118
|
+
for (const [type, fields] of pendingEvents.splice(0)) emit(type, fields);
|
|
119
|
+
},
|
|
120
|
+
check() { if (directory && existsSync(path.join(directory, 'stop'))) requestStop(); if (stopped) throw new MultiplaneError('cancelled'); },
|
|
121
|
+
onCancel(handler) { cancelHandlers.add(handler); if (stopped) Promise.resolve().then(handler).catch(() => {}); return () => cancelHandlers.delete(handler); },
|
|
122
|
+
progress(phase, done = 0, total = null) {
|
|
123
|
+
const seconds = (performance.now() - started) / 1000, fps = done && seconds ? done / seconds : null;
|
|
124
|
+
emit('progress', { phase, done, total, unit: 'frame', fps, etaMs: fps && total !== null ? Math.round((total - done) / fps * 1000) : null });
|
|
125
|
+
},
|
|
126
|
+
finalize() { emit('progress', { phase: 'finalize', done: state.done, total: state.total, unit: 'frame', fps: state.fps, etaMs: 0 }); },
|
|
127
|
+
artifact(file, kind) { const artifact = { path: path.resolve(file), kind, bytes: statSync(file).size }; artifacts.push(artifact); emit('artifact', artifact); },
|
|
128
|
+
finish(data = {}, error) {
|
|
129
|
+
if (deferStart) this.start(true);
|
|
130
|
+
const result = { command, ok: !error, code: error?.code ?? null, message: error?.message ?? null,
|
|
131
|
+
...(error?.hint ? { hint: error.hint } : {}), ...(error?.details ? { details: error.details } : {}),
|
|
132
|
+
artifacts, elapsedMs: Math.round(performance.now() - started), ...(jobId ? { jobId } : {}), data };
|
|
133
|
+
index('end', { state: !error ? 'succeeded' : error.code === 'cancelled' ? 'cancelled' : 'failed' });
|
|
134
|
+
emit('result', result);
|
|
135
|
+
return result;
|
|
136
|
+
},
|
|
137
|
+
dispose() { clearInterval(timer); cancelHandlers.clear(); for (const [signal, handler] of signals) process.off(signal, handler); },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function runCommand(command, shot, options, action, job = true) {
|
|
142
|
+
let events, resources, linted = false;
|
|
143
|
+
try {
|
|
144
|
+
const explicit = options;
|
|
145
|
+
const user = loadUserConfig(command, options);
|
|
146
|
+
options = user.options;
|
|
147
|
+
if (['render', 'stills', 'verify'].includes(command)) {
|
|
148
|
+
options.quality = resolveQuality(options);
|
|
149
|
+
if (options.quality === 'draft') options.draft = true;
|
|
150
|
+
}
|
|
151
|
+
if (job || ['lint', 'inspect'].includes(command)) {
|
|
152
|
+
if (typeof shot !== 'string' || !shot.trim()) throw invalidOption('shot', 'Provide a shot HTML path.');
|
|
153
|
+
if (options.root !== undefined && typeof options.root !== 'string') throw invalidOption('--root', '--root must be a path.');
|
|
154
|
+
shot = path.resolve(options.root ?? process.cwd(), shot);
|
|
155
|
+
}
|
|
156
|
+
const positive = ['scale', 'render-scale', 'fps', 'workers', 'motion-blur', 'max-motion-blur', 'every', 'max-bitrate'];
|
|
157
|
+
for (const key of [...positive, 'from', 'to', 'shutter', 'grain', 'adaptive-blur', 'audio-offset', 'wait']) {
|
|
158
|
+
if (options[key] === undefined) continue;
|
|
159
|
+
const value = Number(options[key]);
|
|
160
|
+
if (!String(options[key]).trim() || !Number.isFinite(value) || value < 0 || (positive.includes(key) && value === 0)
|
|
161
|
+
|| (['workers', 'motion-blur', 'max-motion-blur'].includes(key) && !Number.isSafeInteger(value))) throw invalidOption(`--${key}`, `Invalid value for --${key}.`);
|
|
162
|
+
options[key] = value;
|
|
163
|
+
}
|
|
164
|
+
if (command === 'render') {
|
|
165
|
+
// A saved MP4 preference must not override a requested fixed-codec container.
|
|
166
|
+
const inferred = resolveFormat({ ...options, encoder: explicit.encoder });
|
|
167
|
+
if (inferred !== 'mp4' && explicit.encoder === undefined) {
|
|
168
|
+
delete options.encoder;
|
|
169
|
+
user.config.applied = user.config.applied.filter(field => field !== 'encoder');
|
|
170
|
+
}
|
|
171
|
+
options.format = inferred;
|
|
172
|
+
} else if (job && ['stills', 'verify'].includes(command)) {
|
|
173
|
+
// TODO(design): Document PNG as the frame format reported by stills and verify.
|
|
174
|
+
options.format = 'png';
|
|
175
|
+
}
|
|
176
|
+
const defaults = job ? { root: process.cwd(), raster: 'gpu', scale: 1, 'render-scale': Number(options.scale ?? 1), upscale: 'lanczos',
|
|
177
|
+
'adaptive-blur': 0.5, 'no-post': false, 'no-lint': false, overwrite: false, 'job-dir': jobDirectory(options),
|
|
178
|
+
...(command === 'render' ? { from: 0, ...(options.format === 'mp4' ? { encoder: 'x264' } : {}), workers: defaultWorkers(Number(options.scale ?? 1)), ffmpeg: process.env.MULTIPLANE_FFMPEG ?? ffmpegName() } : {}),
|
|
179
|
+
...(command === 'verify' ? { workers: 2 } : {}),
|
|
180
|
+
...(command === 'stills' && options.at === undefined ? { every: 1 } : {}) }
|
|
181
|
+
: ['lint', 'inspect', 'serve'].includes(command) ? { root: process.cwd() } : command === 'benchmark' ? { raster: 'gpu' }
|
|
182
|
+
: command === 'doctor' ? { raster: 'gpu', ffmpeg: process.env.MULTIPLANE_FFMPEG ?? ffmpegName() }
|
|
183
|
+
: { 'job-dir': jobDirectory(options), ...(command === 'cancel' ? { wait: 30 } : {}) };
|
|
184
|
+
// TODO(design): Document that start waits for effective shot alpha defaults.
|
|
185
|
+
events = createEvents(command, shot, { ...defaults, ...options }, job, job && ['render', 'stills', 'verify'].includes(command));
|
|
186
|
+
events.config = user.config;
|
|
187
|
+
if (command === 'render') events.workersSource = explicit.workers !== undefined ? 'option' : user.config.applied.includes('workers') ? 'config' : 'estimate';
|
|
188
|
+
events.migration = user.migration;
|
|
189
|
+
if (options['hosted-executor']) events.start();
|
|
190
|
+
// Defer start events until the owner is known. Lint failure remains local.
|
|
191
|
+
if (await hostedAvailable(command, options)) {
|
|
192
|
+
if (!options['no-lint']) { await checkLint(shot, events.options, events); linted = true; }
|
|
193
|
+
events.start();
|
|
194
|
+
const request = { schema: 'multiplane/1', jobId: events.jobId, jobDir: events.directory,
|
|
195
|
+
command, args: options._hostedCLI.args, cwd: process.cwd(), node: process.execPath,
|
|
196
|
+
bin: options._hostedCLI.bin, env: hostedEnvironment(process.env), at: new Date().toISOString() };
|
|
197
|
+
const ownership = await handoff(request);
|
|
198
|
+
if (ownership.hosted) {
|
|
199
|
+
const directory = events.directory;
|
|
200
|
+
events.dispose(); events = undefined;
|
|
201
|
+
return await followJob(directory, { onBytes: options._hostedCLI.onBytes });
|
|
202
|
+
}
|
|
203
|
+
process.stderr.write('Rendering locally: ' + ownership.reason + '\n');
|
|
204
|
+
}
|
|
205
|
+
// CLI transport callbacks must never enter workerData or shot evaluation.
|
|
206
|
+
delete options._hostedCLI;
|
|
207
|
+
delete events.options._hostedCLI;
|
|
208
|
+
events.monitor();
|
|
209
|
+
if (job) {
|
|
210
|
+
resources = createResourceSampler({ enabled: options._resources !== false, onSample: events.memory });
|
|
211
|
+
events.resources = resources;
|
|
212
|
+
}
|
|
213
|
+
if (options.draft) {
|
|
214
|
+
// Keep lint and cancellation active before the dimension-only draft probe.
|
|
215
|
+
if (!linted && !options['no-lint']) {
|
|
216
|
+
const { findings, data } = await scanShot(shot, events.options);
|
|
217
|
+
if (data.errors) {
|
|
218
|
+
events.start();
|
|
219
|
+
for (const finding of findings) events.emit('finding', finding);
|
|
220
|
+
throw new MultiplaneError('lint_failed', undefined, { result: { data } });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
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, {
|
|
225
|
+
check: events.check,
|
|
226
|
+
onBrowser: browser => {
|
|
227
|
+
const unsubscribe = events.onCancel(() => browser.close());
|
|
228
|
+
browser.once('disconnected', unsubscribe);
|
|
229
|
+
events.check();
|
|
230
|
+
},
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
events.start();
|
|
234
|
+
for (const warning of user.warnings) events.warning(warning);
|
|
235
|
+
if ((job || command === 'inspect') && !linted && !options['no-lint']) await checkLint(shot, events.options, events);
|
|
236
|
+
if (job && options.out && !options.overwrite) await requireNewOutput(path.resolve(options.out));
|
|
237
|
+
const data = job && ['stills', 'verify'].includes(command) && options.out && options.overwrite
|
|
238
|
+
? await directoryOutput(events, action) : await action(events);
|
|
239
|
+
events.check();
|
|
240
|
+
if (job) events.finalize();
|
|
241
|
+
if (resources) { await resources.stop(); data.resources = { ...resources.snapshot(), ...(events.workersSource ? { workersSource: events.workersSource } : {}) }; }
|
|
242
|
+
if (['render', 'stills', 'verify'].includes(command)) data.quality = options.quality;
|
|
243
|
+
return events.finish(data);
|
|
244
|
+
} catch (failure) {
|
|
245
|
+
let error = asError(failure);
|
|
246
|
+
try { events?.check(); } catch (cancelled) { error = cancelled; }
|
|
247
|
+
events ??= createEvents(command, shot, options);
|
|
248
|
+
await resources?.stop();
|
|
249
|
+
error.result = events.finish({ ...error.result?.data, ...(resources ? { resources: { ...resources.snapshot(), ...(events.workersSource ? { workersSource: events.workersSource } : {}) } } : {}) }, error);
|
|
250
|
+
throw error;
|
|
251
|
+
} finally { await resources?.stop(); events?.dispose(); }
|
|
252
|
+
}
|
package/src/ffmpeg.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/format.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { lstatSync, mkdirSync, openSync, closeSync, copyFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
5
|
+
import { userHome } from './platform.mjs';
|
|
6
|
+
|
|
7
|
+
const present = file => {
|
|
8
|
+
try { lstatSync(file); return true; }
|
|
9
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Use the writers' locks, including the config lock introduced in 038.
|
|
13
|
+
// Publish only complete copies, and recheck the destination under the lock.
|
|
14
|
+
export function migrateUserHome(env = process.env, platform = process.platform, home = os.homedir()) {
|
|
15
|
+
const result = { migrated: [], warnings: [] };
|
|
16
|
+
if (platform !== 'win32' || env.MULTIPLANE_HOME) return result;
|
|
17
|
+
const directory = userHome(env, platform, home);
|
|
18
|
+
const previous = path.win32.join(env.APPDATA || path.win32.join(env.USERPROFILE || home, 'AppData', 'Roaming'), 'multiplane');
|
|
19
|
+
if (path.win32.resolve(previous).toLowerCase() === path.win32.resolve(directory).toLowerCase()) return result;
|
|
20
|
+
for (const [lockName, names] of [['config.json.lock', ['config.json']], ['jobs.lock', ['jobs.jsonl', 'jobs.1.jsonl']]]) {
|
|
21
|
+
let handle;
|
|
22
|
+
const lock = path.join(directory, lockName);
|
|
23
|
+
try {
|
|
24
|
+
if (!names.some(name => !present(path.join(directory, name)) && present(path.join(previous, name)))) continue;
|
|
25
|
+
mkdirSync(directory, { recursive: true });
|
|
26
|
+
const deadline = performance.now() + 10000;
|
|
27
|
+
for (;;) {
|
|
28
|
+
try { handle = openSync(lock, 'wx'); break; }
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error.code !== 'EEXIST' || performance.now() >= deadline) throw error;
|
|
31
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
const target = path.join(directory, name), source = path.join(previous, name);
|
|
36
|
+
if (present(target) || !present(source)) continue;
|
|
37
|
+
const temporary = target + '.' + randomUUID() + '.tmp';
|
|
38
|
+
try {
|
|
39
|
+
copyFileSync(source, temporary);
|
|
40
|
+
renameSync(temporary, target);
|
|
41
|
+
result.migrated.push({ name, source });
|
|
42
|
+
} finally { try { unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; } }
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
result.warnings.push({ code: 'home_unavailable', message: 'User directory migration could not be completed.' });
|
|
46
|
+
} finally {
|
|
47
|
+
if (handle !== undefined) {
|
|
48
|
+
closeSync(handle);
|
|
49
|
+
try { unlinkSync(lock); } catch { result.warnings.push({ code: 'home_unavailable', message: 'User directory migration lock could not be removed.' }); }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|