multiplane 0.3.7 → 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/README.en.md +3 -0
- package/README.md +3 -0
- package/bin/multiplane +24 -14
- package/package.json +1 -1
- package/skills/multiplane/SKILL.md +3 -0
- package/src/capture-watchdog.mjs +29 -0
- package/src/capture.mjs +22 -6
- package/src/errors.mjs +1 -0
- package/src/events.mjs +7 -5
- package/src/hosted.mjs +2 -2
- package/src/index.mjs +1 -0
- package/src/input-records.mjs +33 -0
- package/src/jobs.mjs +3 -1
- package/src/post.mjs +18 -2
- package/src/render-pool.mjs +39 -12
- package/src/render-worker.mjs +13 -2
- package/src/render.mjs +148 -52
- package/src/resume.mjs +23 -0
- package/src/segment-encoder.mjs +91 -0
- package/src/segments.mjs +123 -0
- package/src/server.mjs +4 -0
package/README.en.md
CHANGED
|
@@ -127,11 +127,14 @@ For the full contract, patterns that break determinism, and 3D layer usage, see
|
|
|
127
127
|
| `multiplane status <job>` | View job status |
|
|
128
128
|
| `multiplane cancel <job>` | Stop a job |
|
|
129
129
|
| `multiplane wait <job>` | Wait for a job to finish and print its events, for example after an agent session restarts |
|
|
130
|
+
| `multiplane resume <job>` | Continue an interrupted render from where it stopped |
|
|
130
131
|
| `multiplane config get\|set\|unset` | Read or change the user config |
|
|
131
132
|
| `multiplane skill status\|install\|remove` | Install, update or remove the agent skill for Claude Code and Codex |
|
|
132
133
|
|
|
133
134
|
Shot paths are relative to the resource root `--root` (the current directory by default). Each run has its own job directory, `out/jobs/<jobId>/`. Without `--out`, artifacts are written there (video, `stills/`, `verify/`), so runs do not overwrite each other. See `artifacts` in the result for the final paths. `--out` sets the output location. An existing target is rejected unless `--overwrite` is given. `render`, `stills`, and `verify` automatically run `lint` before starting and stop on errors. Use `--no-lint` to skip it. `npx multiplane <command> --help` lists all options for that command.
|
|
134
135
|
|
|
136
|
+
`render` encodes in segments of about 10 seconds and keeps each one on disk as soon as it is done. If a render stops part way (the process is killed, the machine crashes or loses power, the browser keeps crashing, or you stop it), `multiplane resume <job>` skips the segments already encoded, renders the rest and joins them without re-encoding; the frames are identical to an uninterrupted render. A resume keeps all of the original job's options. It is refused (`resume_unavailable`) when a file the shot loaded has changed or the Multiplane, Chromium or FFmpeg version differs, so a film is never joined from mismatched parts. See section 6.1 of the protocol.
|
|
137
|
+
|
|
135
138
|
Common `render` options:
|
|
136
139
|
|
|
137
140
|
| Option | Meaning |
|
package/README.md
CHANGED
|
@@ -127,11 +127,14 @@ Shot.define({
|
|
|
127
127
|
| `multiplane status <job>` | 查看作业状态 |
|
|
128
128
|
| `multiplane cancel <job>` | 停止作业 |
|
|
129
129
|
| `multiplane wait <job>` | 等一个作业结束并输出它的事件,例如 agent 会话重启之后 |
|
|
130
|
+
| `multiplane resume <job>` | 中断的渲染从停下的地方接着渲 |
|
|
130
131
|
| `multiplane config get\|set\|unset` | 读取或修改用户配置 |
|
|
131
132
|
| `multiplane skill status\|install\|remove` | 给 Claude Code 和 Codex 安装、更新、移除 agent skill |
|
|
132
133
|
|
|
133
134
|
镜头路径相对资源根 `--root`(默认当前目录)。每次运行都有自己的作业目录 `out/jobs/<jobId>/`,不给 `--out` 时产物就写在里面(视频、`stills/`、`verify/`),多次运行互不覆盖;最终路径见结果里的 `artifacts`。`--out` 指定输出位置,目标已存在时会拒绝覆盖,加 `--overwrite` 才替换。`render`、`stills`、`verify` 启动前自动跑 `lint`,有 error 就停下,`--no-lint` 跳过。`npx multiplane <command> --help` 列出该命令的全部参数。
|
|
134
135
|
|
|
136
|
+
`render` 按约 10 秒一段编码,每段编完就落盘。渲染中途停下(进程被杀、死机、断电、浏览器反复崩溃、手动停止)后,`multiplane resume <job>` 会跳过已经编好的段、只渲剩下的,最后不重新编码地拼成成片,画面与一次渲完的逐帧相同。续传沿用原作业的全部参数;镜头加载过的文件改了,或 Multiplane、Chromium、FFmpeg 版本变了,会拒绝续传(`resume_unavailable`),以免拼出前后不一致的片子。见协议第 6.1 节。
|
|
137
|
+
|
|
135
138
|
`render` 的常用参数:
|
|
136
139
|
|
|
137
140
|
| 参数 | 含义 |
|
package/bin/multiplane
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { benchmark } from '../src/benchmark.mjs';
|
|
4
|
-
import { serve } from '../src/serve.mjs';
|
|
5
|
-
import { setup } from '../src/setup.mjs';
|
|
6
|
-
import { postArgs, scaleArgs } from '../src/post.mjs';
|
|
7
|
-
import { createEvents, version } from '../src/events.mjs';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
8
3
|
import { asError, invalidOption } from '../src/errors.mjs';
|
|
9
|
-
import { waitJob } from '../src/jobs.mjs';
|
|
10
4
|
import { claimExecutor, resultExitCode } from '../src/hosted.mjs';
|
|
11
5
|
import { fileURLToPath } from 'node:url';
|
|
12
6
|
|
|
13
7
|
const args = process.argv.slice(2), json = args.includes('--json');
|
|
8
|
+
// Keep CLI flag metadata independent of the rendering implementation.
|
|
9
|
+
const postArgs = ['quality', 'motion-blur', 'max-motion-blur', 'shutter', 'grain', 'no-post', 'adaptive-blur'];
|
|
10
|
+
const scaleArgs = ['scale', 'render-scale', 'upscale'];
|
|
14
11
|
const common = [...postArgs, ...scaleArgs, 'static-reuse', 'no-static-reuse', 'alpha', 'fps', 'raster', 'root', 'job-dir', 'no-lint', 'overwrite', 'no-hosted', 'job-dir-exact', 'hosted-executor'];
|
|
15
12
|
const allowed = {
|
|
16
13
|
inspect: ['root', 'fps', 'no-lint'], serve: ['root', 'port'],
|
|
@@ -19,6 +16,7 @@ const allowed = {
|
|
|
19
16
|
benchmark: ['raster', 'max-workers'], config: [], skill: ['host', 'force', 'expect'],
|
|
20
17
|
lint: ['root'], doctor: ['ffmpeg', 'raster'], status: ['job-dir'], cancel: ['job-dir', 'wait'],
|
|
21
18
|
wait: ['job-dir', 'timeout'],
|
|
19
|
+
resume: ['job-dir', 'workers', 'no-hosted', 'job-dir-exact', 'hosted-executor'],
|
|
22
20
|
};
|
|
23
21
|
// Internal flags used by the desktop app to start hosted executors; not listed in help.
|
|
24
22
|
const internal = ['job-dir-exact', 'hosted-executor'];
|
|
@@ -35,17 +33,18 @@ const onEvent = event => {
|
|
|
35
33
|
};
|
|
36
34
|
let command = args[0] ?? 'doctor';
|
|
37
35
|
try {
|
|
38
|
-
const executor = ['render', 'stills', 'verify'].includes(command) && args.includes('--hosted-executor');
|
|
36
|
+
const executor = ['render', 'stills', 'verify', 'resume'].includes(command) && args.includes('--hosted-executor');
|
|
39
37
|
const exactIndex = args.indexOf('--job-dir-exact');
|
|
40
38
|
const lostClaim = executor && (exactIndex < 0 || !args[exactIndex + 1]
|
|
41
39
|
|| !await claimExecutor(args[exactIndex + 1], command));
|
|
42
40
|
const onBytes = bytes => new Promise((resolve, reject) => process.stdout.write(bytes, error => error ? reject(error) : resolve()));
|
|
43
41
|
if (lostClaim) { process.exitCode = 0; }
|
|
44
|
-
else if (args.length === 1 && args[0] === '--version') console.log(version);
|
|
42
|
+
else if (args.length === 1 && args[0] === '--version') console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version);
|
|
45
43
|
else if (args.length === 0 || args[0] === '--help') {
|
|
46
44
|
console.log('Usage: multiplane <render|stills|verify|inspect|lint> <shot.html> [--json] [--option value]\n'
|
|
47
45
|
+ ' multiplane serve [--root directory] [--port number] [--json]\n'
|
|
48
46
|
+ ' multiplane <status|cancel> <jobId|jobDir> [--json] [--wait seconds]\n'
|
|
47
|
+
+ ' multiplane resume <jobId|jobDir> [--json] [--workers n] [--job-dir directory] [--no-hosted]\n'
|
|
49
48
|
+ ' multiplane wait <jobId|jobDir> [--json] [--timeout seconds]\n'
|
|
50
49
|
+ ' multiplane benchmark [--json] [--raster gpu|software] [--max-workers n]\n'
|
|
51
50
|
+ ' multiplane doctor [--json] [--ffmpeg path]\n'
|
|
@@ -56,11 +55,13 @@ try {
|
|
|
56
55
|
if (args.includes('--help')) console.log(command === 'config'
|
|
57
56
|
? 'Usage: multiplane config <get [key]|set key value|unset key> [--json]\nKeys: ffmpeg jobsDir raster encoder workers hosted'
|
|
58
57
|
: 'Usage: multiplane skill <status|install|remove> [--host claude|codex|all] [--force] [--expect signature] [--json]\nInstall and remove require --host.');
|
|
59
|
-
|
|
58
|
+
// TODO(design): setup/jobs still transitively load rendering through src/events.mjs.
|
|
59
|
+
// Removing those dependencies requires extending task 083's read-only src scope.
|
|
60
|
+
else { const { setup } = await import('../src/setup.mjs'); await setup(command, args.slice(1), onEvent); }
|
|
60
61
|
} else {
|
|
61
62
|
if (!allowed[command]) throw invalidOption('command', 'Unknown command: ' + command);
|
|
62
63
|
if (args.includes('--help')) {
|
|
63
|
-
console.log('Usage: multiplane ' + command + (['doctor', 'serve', 'benchmark'].includes(command) ? '' : ['status', 'cancel', 'wait'].includes(command) ? ' <jobId|jobDir>' : ' <shot.html>') + ' [options]\nOptions: --json --help ' + listed(allowed[command]).map(f => '--' + f + (['static-reuse', 'no-static-reuse', 'overwrite', 'no-lint', 'no-post', 'alpha', 'draft', 'no-hosted', 'hosted-executor'].includes(f) ? '' : ' <value>')).join(' '));
|
|
64
|
+
console.log('Usage: multiplane ' + command + (['doctor', 'serve', 'benchmark'].includes(command) ? '' : ['status', 'cancel', 'wait', 'resume'].includes(command) ? ' <jobId|jobDir>' : ' <shot.html>') + ' [options]\nOptions: --json --help ' + listed(allowed[command]).map(f => '--' + f + (['static-reuse', 'no-static-reuse', 'overwrite', 'no-lint', 'no-post', 'alpha', 'draft', 'no-hosted', 'hosted-executor'].includes(f) ? '' : ' <value>')).join(' '));
|
|
64
65
|
} else {
|
|
65
66
|
const needsTarget = !['doctor', 'serve', 'benchmark'].includes(command);
|
|
66
67
|
const target = needsTarget ? args[1] : undefined;
|
|
@@ -78,14 +79,23 @@ try {
|
|
|
78
79
|
delete options.json;
|
|
79
80
|
if (options['job-dir-exact'] && !executor) throw invalidOption('--job-dir-exact', '--job-dir-exact requires --hosted-executor.');
|
|
80
81
|
options.onEvent = executor ? undefined : onEvent;
|
|
81
|
-
if (['render', 'stills', 'verify'].includes(command) && !executor) options._hostedCLI = {
|
|
82
|
+
if (['render', 'stills', 'verify', 'resume'].includes(command) && !executor) options._hostedCLI = {
|
|
82
83
|
args: args.slice(1).filter(arg => arg !== '--json'), bin: fileURLToPath(import.meta.url), onBytes,
|
|
83
84
|
};
|
|
84
85
|
if (command === 'wait') {
|
|
86
|
+
const { waitJob } = await import('../src/jobs.mjs');
|
|
85
87
|
const result = await waitJob(target, { ...options, onEvent: json ? undefined : onEvent, onBytes: json ? onBytes : undefined });
|
|
86
88
|
reported = true; process.exitCode = resultExitCode(result);
|
|
87
89
|
} else {
|
|
88
|
-
|
|
90
|
+
let action;
|
|
91
|
+
if (command === 'lint') {
|
|
92
|
+
const { runCommand } = await import('../src/events.mjs');
|
|
93
|
+
const { checkLint } = await import('../src/lint.mjs');
|
|
94
|
+
action = (shot, options) => runCommand('lint', shot, options, events => checkLint(shot, events.options, events), false);
|
|
95
|
+
} else {
|
|
96
|
+
const [module, name] = { render: ['render', 'renderShot'], stills: ['stills', 'stillsShot'], verify: ['verify', 'verifyShot'], inspect: ['inspect', 'inspectShot'], serve: ['serve', 'serve'], doctor: ['doctor', 'doctor'], benchmark: ['benchmark', 'benchmark'], status: ['jobs', 'statusJob'], cancel: ['jobs', 'cancelJob'], resume: ['resume', 'resumeRender'] }[command];
|
|
97
|
+
action = (await import(`../src/${module}.mjs`))[name];
|
|
98
|
+
}
|
|
89
99
|
const result = needsTarget ? await action(target, options) : await action(options);
|
|
90
100
|
if (result?.ok !== undefined) { reported = true; process.exitCode = resultExitCode(result); }
|
|
91
101
|
}
|
|
@@ -93,6 +103,6 @@ try {
|
|
|
93
103
|
}
|
|
94
104
|
} catch (failure) {
|
|
95
105
|
const error = asError(failure);
|
|
96
|
-
if (!reported) createEvents(command, null, { onEvent }).finish({}, error);
|
|
106
|
+
if (!reported) { const { createEvents } = await import('../src/events.mjs'); createEvents(command, null, { onEvent }).finish({}, error); }
|
|
97
107
|
process.exitCode = error.exitCode;
|
|
98
108
|
}
|
package/package.json
CHANGED
|
@@ -171,6 +171,7 @@ Use `--json` with any command. Resolve shot paths relative to `--root` (the curr
|
|
|
171
171
|
| `multiplane status <job>` | View job status | — |
|
|
172
172
|
| `multiplane cancel <job>` | Stop a job | `--wait` (seconds) |
|
|
173
173
|
| `multiplane wait <job>` | Wait for a job started earlier and print its events | `--timeout` (seconds) |
|
|
174
|
+
| `multiplane resume <job>` | Continue an interrupted render; already encoded segments are reused | `--workers` |
|
|
174
175
|
|
|
175
176
|
Use `--out`, `--overwrite`, and `--no-lint` (skip automatic lint) with `stills`, `verify`, or `render`. Run `multiplane <command> --help` to list all options for that command.
|
|
176
177
|
|
|
@@ -213,3 +214,5 @@ With `--json`, stdout contains one JSON event per line. **The last line is alway
|
|
|
213
214
|
Rendering may take a long time. Each run creates a job directory, `out/jobs/<jobId>/` (`jobId` appears in both the `start` event and the `result`). Read `status.json` for the latest progress snapshot and `events.jsonl` for the full event stream. To stop a job, run `multiplane cancel <jobId>` or create a file named `stop` in its job directory.
|
|
214
215
|
|
|
215
216
|
If the user turned on hosting in the desktop app, the app runs the render and it keeps going after your session ends. Nothing changes in how you call the command; stderr says the job was handed over, and `status.json` may show `queued` while another hosted job runs. When a session resumes and you started a job earlier but never saw its `result`, run `multiplane wait <jobId> --json` to get its events and exit code instead of starting it again. Use `--no-hosted` only when the user asks for a render that should not go through the app.
|
|
217
|
+
|
|
218
|
+
A `render` that ended without succeeding (`failed` or `cancelled`, or a job whose process disappeared, for example after a crash or reboot) can usually be continued instead of rendered again: run `multiplane resume <jobId> --json`. It reuses the segments already encoded (about 10 seconds each), renders the rest with the original options and output path, and produces the same frames as an uninterrupted render. It is a new job; its `result` carries `resumedFrom` and `resume.framesReused`. `multiplane status <jobId> --json` shows `data.resume.available` and how many frames are done. If it fails with `resume_unavailable`, read `details.reason`: `inputs_changed` or `environment_changed` means start a fresh `render` (do this deliberately, for example after you edited the shot); `running` means the job, or another resume, is still going, so `wait` for it; `missing_manifest` means the job predates segments. Only `--workers` can change on resume. A `resume_renderer_changed` warning (a different GPU or driver) needs no action.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Wall time controls recovery only; it never enters frame generation.
|
|
2
|
+
export function createCaptureWatchdog(slots, onStall, { minimumMs = 120000, now = () => performance.now() } = {}) {
|
|
3
|
+
let longestFrameMs = 0, stopped = false;
|
|
4
|
+
const check = () => {
|
|
5
|
+
if (stopped) return;
|
|
6
|
+
const time = now(), threshold = Math.max(minimumMs, longestFrameMs * 10);
|
|
7
|
+
for (const [worker, slot] of slots.entries()) {
|
|
8
|
+
if (!slot.job || slot.recovering || time - slot.lastDelivery < threshold) continue;
|
|
9
|
+
slot.recovering = true;
|
|
10
|
+
onStall(slot, { worker, frame: slot.frame ?? slot.job.first, waitedSeconds: (time - slot.lastDelivery) / 1000 });
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
const timer = setInterval(check, Math.min(1000, Math.max(10, minimumMs / 4)));
|
|
14
|
+
timer.unref();
|
|
15
|
+
return {
|
|
16
|
+
check,
|
|
17
|
+
completed(slot) {
|
|
18
|
+
const time = now();
|
|
19
|
+
if (slot.frameStarted !== undefined) longestFrameMs = Math.max(longestFrameMs, time - slot.frameStarted);
|
|
20
|
+
slot.lastDelivery = time;
|
|
21
|
+
},
|
|
22
|
+
recovered(slot) {
|
|
23
|
+
slot.recovering = false;
|
|
24
|
+
slot.lastDelivery = now();
|
|
25
|
+
slot.frameStarted = slot.lastDelivery;
|
|
26
|
+
},
|
|
27
|
+
stop() { stopped = true; clearInterval(timer); },
|
|
28
|
+
};
|
|
29
|
+
}
|
package/src/capture.mjs
CHANGED
|
@@ -175,7 +175,7 @@ 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
180
|
get motionProbeUsed() { return motionProbeUsed; },
|
|
181
181
|
get dofSamples() { return dofSamples; },
|
|
@@ -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); }
|
|
@@ -224,13 +225,28 @@ export async function openShot(shot, scale = 1, options = {}) {
|
|
|
224
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/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);
|
package/src/hosted.mjs
CHANGED
|
@@ -18,7 +18,7 @@ async function move(from, to) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export async function hostedAvailable(command, options, env = process.env) {
|
|
21
|
-
if (!['render', 'stills', 'verify'].includes(command) || !options._hostedCLI || options.hosted !== true
|
|
21
|
+
if (!['render', 'stills', 'verify', 'resume'].includes(command) || !options._hostedCLI || options.hosted !== true
|
|
22
22
|
|| options['no-hosted'] || options['hosted-executor'] || env.MULTIPLANE_HOSTED === '0') return false;
|
|
23
23
|
try {
|
|
24
24
|
const app = await json(path.join(userHome(env), 'app.json'));
|
|
@@ -84,7 +84,7 @@ export async function handoff(request, { home = userHome(), timeout = 5000, star
|
|
|
84
84
|
// Claim before loading config, creating status, linting or opening output files.
|
|
85
85
|
export async function claimExecutor(jobDir, command) {
|
|
86
86
|
try {
|
|
87
|
-
if (!path.isAbsolute(jobDir) || !['render', 'stills', 'verify'].includes(command)) return false;
|
|
87
|
+
if (!path.isAbsolute(jobDir) || !['render', 'stills', 'verify', 'resume'].includes(command)) return false;
|
|
88
88
|
const jobId = path.basename(jobDir), base = hostedBase(jobId);
|
|
89
89
|
const request = await json(base + '.claimed.json');
|
|
90
90
|
if (request?.schema !== schema || request.jobId !== jobId || request.command !== command
|
package/src/index.mjs
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
5
|
+
|
|
6
|
+
// Servers inherit the render's context; unrelated inspect/serve calls do not.
|
|
7
|
+
export const inputRecords = new AsyncLocalStorage();
|
|
8
|
+
const largeFiles = new WeakMap();
|
|
9
|
+
export const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
export async function fileDigest(file) {
|
|
11
|
+
const hash = createHash('sha256');
|
|
12
|
+
for await (const bytes of createReadStream(file)) hash.update(bytes);
|
|
13
|
+
return hash.digest('hex');
|
|
14
|
+
}
|
|
15
|
+
export async function recordInput(file) {
|
|
16
|
+
const record = inputRecords.getStore();
|
|
17
|
+
if (!record) return;
|
|
18
|
+
const info = await stat(file);
|
|
19
|
+
// Large media stays streamed. Cache only its digest, never its body.
|
|
20
|
+
if (info.size > 1024 * 1024) {
|
|
21
|
+
let cache = largeFiles.get(record);
|
|
22
|
+
if (!cache) largeFiles.set(record, cache = new Map());
|
|
23
|
+
const previous = cache.get(file);
|
|
24
|
+
const sha256 = previous?.size === info.size && previous.mtimeMs === info.mtimeMs
|
|
25
|
+
? previous.sha256 : await fileDigest(file);
|
|
26
|
+
cache.set(file, { size: info.size, mtimeMs: info.mtimeMs, sha256 });
|
|
27
|
+
record(file, { bytes: info.size, sha256 });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const bytes = await readFile(file);
|
|
31
|
+
record(file, { bytes: bytes.length, sha256: digest(bytes) });
|
|
32
|
+
return bytes;
|
|
33
|
+
}
|
package/src/jobs.mjs
CHANGED
|
@@ -6,8 +6,9 @@ import { MultiplaneError, invalidOption } from './errors.mjs';
|
|
|
6
6
|
import { number } from './capture.mjs';
|
|
7
7
|
import { followJob } from './hosted.mjs';
|
|
8
8
|
import { loadUserConfig } from './user-home.mjs';
|
|
9
|
+
import { resumeStatus } from './segments.mjs';
|
|
9
10
|
|
|
10
|
-
const resolveJob = (target, options) => path.isAbsolute(target) || /[\\/]/.test(target) ? path.resolve(target) : path.join(jobDirectory(options), target);
|
|
11
|
+
export const resolveJob = (target, options) => path.isAbsolute(target) || /[\\/]/.test(target) ? path.resolve(target) : path.join(jobDirectory(options), target);
|
|
11
12
|
export async function readStatus(target, options = {}) {
|
|
12
13
|
let status;
|
|
13
14
|
try { status = JSON.parse(await readFile(path.join(resolveJob(target, options), 'status.json'), 'utf8')); }
|
|
@@ -25,6 +26,7 @@ export async function readStatus(target, options = {}) {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
}
|
|
29
|
+
if (status.command === 'render') status.resume = resumeStatus(status, resolveJob(target, options));
|
|
28
30
|
return status;
|
|
29
31
|
}
|
|
30
32
|
export const statusJob = (target, options = {}) => runCommand('status', null, options, events => readStatus(target, events.options), false);
|
package/src/post.mjs
CHANGED
|
@@ -329,11 +329,25 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
|
329
329
|
let png, result, usedSamples = motionBlur;
|
|
330
330
|
if (candidate && !verifyReuse) { ({ png, image: result, usedSamples } = previousRaw); }
|
|
331
331
|
else if (upward) {
|
|
332
|
+
// TODO(design): Half-size probes need a compatible decision policy;
|
|
333
|
+
// halving the raster and displacement threshold changes refinement.
|
|
332
334
|
// Calibrated in capture raster pixels: about 2px for fast/standard, 1px for high.
|
|
333
335
|
const threshold = resolveQuality(options) === 'high' ? 1 : 2;
|
|
334
|
-
|
|
336
|
+
// Stop as soon as motion is established. Retain exact full-size still
|
|
337
|
+
// checks, and reuse every matching time without changing the final grid.
|
|
338
|
+
await sampleMany([t, endpoints[0]], true);
|
|
335
339
|
const centerProbe = cache.get(t);
|
|
336
|
-
|
|
340
|
+
let still = cache.get(endpoints[0]).image.data.equals(centerProbe.image.data);
|
|
341
|
+
if (still) {
|
|
342
|
+
await sampleMany([endpoints[1]], true);
|
|
343
|
+
still = cache.get(endpoints[1]).image.data.equals(centerProbe.image.data);
|
|
344
|
+
} else if (capture._inspectSample) {
|
|
345
|
+
// Preserve conditional sampleContext reads at the skipped endpoint.
|
|
346
|
+
const start = performance.now();
|
|
347
|
+
await capture._inspectSample(endpoints[1], context(false, endpoints[1]));
|
|
348
|
+
stats.captureMs += performance.now() - start;
|
|
349
|
+
contextUsed ||= capture.sampleContextUsed === true;
|
|
350
|
+
}
|
|
337
351
|
let legacy = contextUsed;
|
|
338
352
|
let grid = legacy ? times : grids.get(motionBlur);
|
|
339
353
|
if (still) {
|
|
@@ -400,6 +414,8 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
|
400
414
|
if (!(candidate && !verifyReuse)) {
|
|
401
415
|
// Only lens-dependent probe captures need replacing. DOM captures and
|
|
402
416
|
// final hook captures remain reusable at the same time and context.
|
|
417
|
+
// TODO(design): Zero aperture still has interleaved projection jitter;
|
|
418
|
+
// reusing its probes requires an approved change to that pattern.
|
|
403
419
|
if (motionProbeUsed && !finalCapture) {
|
|
404
420
|
finalCapture = true;
|
|
405
421
|
dofMotionSamples = new Set(selectedTimes).size;
|
package/src/render-pool.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { MultiplaneError } from './errors.mjs';
|
|
|
4
4
|
import { availableParallelism, freemem, totalmem } from 'node:os';
|
|
5
5
|
import { Worker } from 'node:worker_threads';
|
|
6
6
|
import { blurSampleReport } from './post.mjs';
|
|
7
|
+
import { createCaptureWatchdog } from './capture-watchdog.mjs';
|
|
7
8
|
|
|
8
9
|
export function defaultWorkers(scale = 1, hardware = {}) {
|
|
9
10
|
const cores = hardware.cores ?? availableParallelism();
|
|
@@ -12,24 +13,50 @@ export function defaultWorkers(scale = 1, hardware = {}) {
|
|
|
12
13
|
return Math.max(1, Math.min(12, Math.floor(cores / 2), Math.floor((free - 2 * 1024 ** 3) / workerBytes)));
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export function createRenderPool(shot, options, fps, workers, size, onWarning, onCaptured) {
|
|
16
|
+
export function createRenderPool(shot, options, fps, workers, size, onWarning, onCaptured, onInput) {
|
|
16
17
|
options = { ...options, _browserRecovery: options._browserRecovery ?? recoveryBudget() };
|
|
17
18
|
reuseState(options);
|
|
18
19
|
const slots = [], stats = { frames: 0, samples: 0, staticFrames: 0 };
|
|
19
20
|
const histogram = {}, dofHistogram = {};
|
|
20
21
|
const captured = new Set();
|
|
21
|
-
let failure;
|
|
22
|
+
let failure, stopping = false, closing;
|
|
23
|
+
function close() {
|
|
24
|
+
if (closing) return closing;
|
|
25
|
+
stopping = true; watchdog.stop();
|
|
26
|
+
for (const slot of slots) if (!slot.closed) slot.worker.postMessage({ type: 'close' });
|
|
27
|
+
// A blocked page cannot finish its frame. Close only browsers owned by
|
|
28
|
+
// these workers after the grace period, letting their normal cleanup run.
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
for (const slot of slots) if (!slot.closed) slot.worker.postMessage({ type: 'cancel' });
|
|
31
|
+
}, options._stopGraceMs ?? 5000); // Internal override for short fault tests.
|
|
32
|
+
closing = Promise.all(slots.map(s => s.exited)).finally(() => clearTimeout(timer));
|
|
33
|
+
return closing;
|
|
34
|
+
}
|
|
35
|
+
// Internal deadline override for short fault-injection tests; no CLI option.
|
|
36
|
+
const watchdog = createCaptureWatchdog(slots, (slot, details) => {
|
|
37
|
+
if (failure || stopping) return;
|
|
38
|
+
try {
|
|
39
|
+
onWarning?.({ code: 'worker_stalled', severity: 'warning', message: 'Rendering worker stalled.', details });
|
|
40
|
+
slot.worker.postMessage({ type: 'stalled', details });
|
|
41
|
+
} catch (error) { fail(error); }
|
|
42
|
+
}, { minimumMs: options._stallTimeoutMs });
|
|
43
|
+
const fail = error => {
|
|
44
|
+
failure ??= error;
|
|
45
|
+
watchdog.stop();
|
|
46
|
+
for (const s of slots) { s.pending?.reject(failure); s.pending = null; }
|
|
47
|
+
void close();
|
|
48
|
+
};
|
|
22
49
|
for (let i = 0; i < workers; i++) {
|
|
23
50
|
const worker = new Worker(new URL('./render-worker.mjs', import.meta.url), { workerData: { shot, options, fps, size } });
|
|
24
51
|
const slot = { worker, pending: null, closed: false };
|
|
25
|
-
const fail = error => {
|
|
26
|
-
failure ??= error;
|
|
27
|
-
for (const s of slots) { s.pending?.reject(error); s.pending = null; }
|
|
28
|
-
};
|
|
29
52
|
slot.ready = new Promise((resolve, reject) => { slot.pending = { resolve, reject }; });
|
|
30
53
|
slot.ready.catch(() => {});
|
|
31
54
|
worker.on('message', message => {
|
|
55
|
+
if (message.type === 'frame-start') { slot.frame = message.frame; slot.frameStarted = performance.now(); return; }
|
|
56
|
+
if (message.type === 'recovered') { watchdog.recovered(slot); return; }
|
|
57
|
+
if (message.type === 'input') { try { onInput?.(message.file, message.record); } catch (error) { fail(error); } return; }
|
|
32
58
|
if (message.type === 'captured') {
|
|
59
|
+
watchdog.completed(slot);
|
|
33
60
|
if (!captured.has(message.frame)) {
|
|
34
61
|
captured.add(message.frame);
|
|
35
62
|
try { onCaptured?.(captured.size); } catch (error) { fail(error); }
|
|
@@ -39,6 +66,7 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning, o
|
|
|
39
66
|
if (message.type === 'warning') { onWarning?.(message.warning); return; }
|
|
40
67
|
if (message.type === 'failure') { fail(new MultiplaneError(message.code ?? 'worker_crashed', message.error, { details: message.details, hint: message.hint })); return; }
|
|
41
68
|
const pending = slot.pending; slot.pending = null;
|
|
69
|
+
slot.job = null;
|
|
42
70
|
pending?.resolve(message);
|
|
43
71
|
});
|
|
44
72
|
worker.on('error', error => fail(new MultiplaneError('worker_crashed', undefined, { cause: error })));
|
|
@@ -66,6 +94,8 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning, o
|
|
|
66
94
|
next = Math.min(end, next + chunk, reuseEnabled(options) ? (Math.floor(begin / REUSE_INTERVAL) + 1) * REUSE_INTERVAL + 1 : end);
|
|
67
95
|
const promise = new Promise((resolve, reject) => { slot.pending = { resolve, reject }; });
|
|
68
96
|
promise.catch(() => {});
|
|
97
|
+
slot.job = { first: begin, end: next }; slot.frame = begin;
|
|
98
|
+
slot.lastDelivery = performance.now(); slot.frameStarted = undefined;
|
|
69
99
|
slot.worker.postMessage({ type: 'render', first: begin, end: next });
|
|
70
100
|
queue.push({ slot, promise });
|
|
71
101
|
};
|
|
@@ -86,13 +116,10 @@ export function createRenderPool(shot, options, fps, workers, size, onWarning, o
|
|
|
86
116
|
if (options.transport !== 'raw') schedule(slot);
|
|
87
117
|
}
|
|
88
118
|
},
|
|
89
|
-
|
|
90
|
-
for (const slot of slots) if (!slot.closed) slot.worker.postMessage({ type: 'close' });
|
|
91
|
-
await Promise.all(slots.map(s => s.exited));
|
|
92
|
-
},
|
|
119
|
+
close,
|
|
93
120
|
async cancel() {
|
|
94
|
-
|
|
95
|
-
await
|
|
121
|
+
fail(new MultiplaneError('cancelled'));
|
|
122
|
+
await close();
|
|
96
123
|
},
|
|
97
124
|
};
|
|
98
125
|
const frames = api.frames.bind(api);
|
package/src/render-worker.mjs
CHANGED
|
@@ -3,8 +3,11 @@ import { MultiplaneError, asError } from './errors.mjs';
|
|
|
3
3
|
import { parentPort, workerData, isMarkedAsUntransferable } from 'node:worker_threads';
|
|
4
4
|
import { openShot } from './capture.mjs';
|
|
5
5
|
import { createPostCapture, resolveScale } from './post.mjs';
|
|
6
|
+
import { inputRecords } from './input-records.mjs';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
if (workerData.options._recordInputs) inputRecords.enterWith((file, record) => parentPort.postMessage({ type: 'input', file, record }));
|
|
9
|
+
|
|
10
|
+
let capture, closing = false, running = false, activeFrame;
|
|
8
11
|
const sendFailure = error => {
|
|
9
12
|
const failure = asError(error);
|
|
10
13
|
parentPort.postMessage({ type: 'failure', error: failure.message, code: failure.code, details: failure.details, hint: failure.hint });
|
|
@@ -16,12 +19,17 @@ async function close() {
|
|
|
16
19
|
parentPort.close();
|
|
17
20
|
}
|
|
18
21
|
try {
|
|
19
|
-
capture = await openShot(workerData.shot, resolveScale(workerData.options).renderScale, { ...workerData.options, onWarning: warning => parentPort.postMessage({ type: 'warning', warning }), check: () => { if (closing) throw new MultiplaneError('cancelled'); } });
|
|
22
|
+
capture = await openShot(workerData.shot, resolveScale(workerData.options).renderScale, { ...workerData.options, onRecovered: () => parentPort.postMessage({ type: 'recovered' }), onWarning: warning => parentPort.postMessage({ type: 'warning', warning }), check: () => { if (closing) throw new MultiplaneError('cancelled'); } });
|
|
20
23
|
if (workerData.size && (capture.width !== workerData.size.width || capture.height !== workerData.size.height)) throw new MultiplaneError('internal_error', 'Worker stage dimensions differ from the main process.');
|
|
21
24
|
const processed = createPostCapture(capture, { ...workerData.options, onWarning: warning => parentPort.postMessage({ type: 'warning', warning }) }, workerData.fps);
|
|
22
25
|
parentPort.on('message', async message => {
|
|
23
26
|
if (message.type === 'cancel') { closing = true; await capture?.close(); if (!running) parentPort.close(); return; }
|
|
24
27
|
if (message.type === 'close') { await close(); return; }
|
|
28
|
+
if (message.type === 'stalled') {
|
|
29
|
+
if (!closing && activeFrame === message.details.frame) await capture.stall(message.details).catch(sendFailure);
|
|
30
|
+
else parentPort.postMessage({ type: 'recovered' });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
25
33
|
if (closing) return;
|
|
26
34
|
running = true;
|
|
27
35
|
try {
|
|
@@ -29,7 +37,10 @@ try {
|
|
|
29
37
|
const source = async function* (first, end) {
|
|
30
38
|
for (let index = first; index < end && !closing; index++) {
|
|
31
39
|
const frame = message.first + index;
|
|
40
|
+
activeFrame = frame;
|
|
41
|
+
parentPort.postMessage({ type: 'frame-start', frame });
|
|
32
42
|
const png = await processed.frame(frame / workerData.fps, frame, index === end - 1);
|
|
43
|
+
activeFrame = undefined;
|
|
33
44
|
parentPort.postMessage({ type: 'captured', frame });
|
|
34
45
|
yield { png, hash: processed.lastHash, blurSamples: processed.lastBlurSamples, dofSamples: processed.lastDofSamples };
|
|
35
46
|
}
|
package/src/render.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { readBenchmark, benchmarkMachine, selectWorkers, classifyShot } from './workers.mjs';
|
|
2
2
|
import { recoveryBudget } from './browser-recovery.mjs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { mkdir, rm, writeFile, rename } from 'node:fs/promises';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
-
import { spawn } from 'node:child_process';
|
|
7
6
|
import { stopChild } from './process.mjs';
|
|
8
7
|
import { inspectFFmpeg } from './ffmpeg.mjs';
|
|
9
8
|
import { openShot, number } from './capture.mjs';
|
|
@@ -11,19 +10,28 @@ import { createPostCapture, resolveScale } from './post.mjs';
|
|
|
11
10
|
import { createRenderPool } from './render-pool.mjs';
|
|
12
11
|
import { resolveFormat, videoEncoding } from './format.mjs';
|
|
13
12
|
import { checkAlphaVideo } from './output-alpha.mjs';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
13
|
+
import { inputRecords, recordInput } from './input-records.mjs';
|
|
14
|
+
import { atomicJSON, lockSegments, manifestStore, checkInputs, checkEnvironment, reusableSegments, readManifest, unavailable } from './segments.mjs';
|
|
15
|
+
import { segmentEncoder, assembleSegments } from './segment-encoder.mjs';
|
|
16
|
+
import { version, runCommand } from './events.mjs';
|
|
16
17
|
import { loaded, observeBrowser, environment } from './environment.mjs';
|
|
17
|
-
import {
|
|
18
|
+
import { invalidOption } from './errors.mjs';
|
|
18
19
|
import { requireNewOutput, publishVideo } from './artifacts.mjs';
|
|
19
20
|
import { createPreview } from './preview.mjs';
|
|
20
21
|
import { checkVideo } from './output-check.mjs';
|
|
21
22
|
import { frameRate } from './frame-rate.mjs';
|
|
22
23
|
|
|
24
|
+
const persistedOptions = options => Object.fromEntries(Object.entries(options).filter(([key, value]) =>
|
|
25
|
+
!key.startsWith('_') && typeof value !== 'function' && !['job-dir-exact', 'hosted-executor'].includes(key)));
|
|
26
|
+
|
|
23
27
|
export async function renderShot(shot, options = {}) {
|
|
24
|
-
return runCommand('render', shot, options, async events => {
|
|
28
|
+
return runCommand('render', shot, options, async events => inputRecords.run(null, async () => {
|
|
25
29
|
options = { ...events.options };
|
|
26
30
|
delete options.onEvent;
|
|
31
|
+
const resume = options._resume; delete options._resume;
|
|
32
|
+
const hook = options._segmentHook; delete options._segmentHook;
|
|
33
|
+
const segmentFrames = options._segmentFrames; delete options._segmentFrames;
|
|
34
|
+
delete options._effectiveOptions;
|
|
27
35
|
const probe = options._probeVideo;
|
|
28
36
|
delete options._probeVideo;
|
|
29
37
|
const inspect = options._inspectFFmpeg ?? inspectFFmpeg;
|
|
@@ -32,25 +40,56 @@ export async function renderShot(shot, options = {}) {
|
|
|
32
40
|
const name = path.basename(shot, path.extname(shot));
|
|
33
41
|
let result;
|
|
34
42
|
|
|
35
|
-
let capture, pool,
|
|
43
|
+
let capture, pool, temporary, cleaning, store, directory, release, movedSource;
|
|
44
|
+
const encoders = new Set(), pending = [];
|
|
45
|
+
const inputs = {};
|
|
46
|
+
const onInput = (file, record) => { if (store) store.input(file, record); else inputs[file] = record; };
|
|
47
|
+
inputRecords.enterWith(onInput);
|
|
36
48
|
function cleanup() { return cleaning ??= cleanResources(); }
|
|
37
49
|
async function cleanResources() {
|
|
38
50
|
try {
|
|
39
|
-
|
|
51
|
+
await Promise.all([...encoders].map(encoder => stopChild(encoder)));
|
|
52
|
+
await Promise.allSettled(pending);
|
|
40
53
|
} finally {
|
|
41
54
|
try { await capture?.close(); }
|
|
42
|
-
finally { try { await pool?.close(); } finally {
|
|
55
|
+
finally { try { await pool?.close(); } finally {
|
|
56
|
+
try {
|
|
57
|
+
if (temporary && movedSource) await rename(temporary, movedSource);
|
|
58
|
+
else if (temporary) await rm(temporary, { force: true });
|
|
59
|
+
} finally { release?.(); }
|
|
60
|
+
} }
|
|
43
61
|
}
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
try {
|
|
65
|
+
if (resume) {
|
|
66
|
+
directory = resume.directory;
|
|
67
|
+
release ??= lockSegments(directory);
|
|
68
|
+
resume.manifest = readManifest(directory);
|
|
69
|
+
if (!resume.manifest) throw unavailable('missing_manifest');
|
|
70
|
+
atomicJSON(path.join(events.directory, 'resume.json'), { directory });
|
|
71
|
+
await checkInputs(resume.manifest);
|
|
72
|
+
}
|
|
47
73
|
const out = path.resolve(options.out ?? path.join(events.directory, `${name}.${options.format}`));
|
|
74
|
+
if (!resume) {
|
|
75
|
+
directory = path.join(events.directory, 'segments');
|
|
76
|
+
release = lockSegments(directory);
|
|
77
|
+
// A launch failure can be retried even before metadata or a first frame
|
|
78
|
+
// exists. Replace this provisional record with effective values below.
|
|
79
|
+
store = manifestStore(directory, { schema: 'multiplane-segments/1', jobId: events.jobId,
|
|
80
|
+
shot: path.resolve(options.root, shot), options: persistedOptions({ ...options, out,
|
|
81
|
+
audio: options.audio ? path.resolve(options.audio) : undefined }),
|
|
82
|
+
environment: null, first: 0, end: 0, total: 0, segmentFrames: segmentFrames ?? null, inputs: {}, segments: [] });
|
|
83
|
+
store.save();
|
|
84
|
+
await recordInput(path.resolve(options.root, shot));
|
|
85
|
+
if (options.audio) await recordInput(path.resolve(options.audio));
|
|
86
|
+
}
|
|
48
87
|
if (!options.overwrite) await requireNewOutput(out);
|
|
49
88
|
events.check();
|
|
50
89
|
events.progress('launch');
|
|
51
90
|
const { scale, renderScale } = resolveScale(options);
|
|
52
91
|
const encoding = videoEncoding(options);
|
|
53
|
-
|
|
92
|
+
|
|
54
93
|
const ffmpegInfo = await inspect(options.ffmpeg, process.env, { x264: 'libx264', 'nvenc-h264': 'h264_nvenc', 'nvenc-hevc': 'hevc_nvenc' }[encoding.encoder] ?? encoding.encoder);
|
|
55
94
|
const ffmpeg = ffmpegInfo.path;
|
|
56
95
|
options.transport = 'raw';
|
|
@@ -74,8 +113,7 @@ export async function renderShot(shot, options = {}) {
|
|
|
74
113
|
options.alpha = events.options.alpha = capture.alpha;
|
|
75
114
|
resolveFormat(options);
|
|
76
115
|
Object.assign(encoding, videoEncoding(options));
|
|
77
|
-
|
|
78
|
-
await loaded(events, capture, ffmpegInfo);
|
|
116
|
+
|
|
79
117
|
const fps = number(options.fps ?? capture.fps, '--fps', { positive: true });
|
|
80
118
|
const rate = frameRate(fps);
|
|
81
119
|
const processed = createPostCapture(capture, options, fps);
|
|
@@ -87,57 +125,110 @@ export async function renderShot(shot, options = {}) {
|
|
|
87
125
|
const total = end - first;
|
|
88
126
|
if (!total) throw invalidOption('--from', 'Range contains no frames.');
|
|
89
127
|
const { width, height } = capture;
|
|
90
|
-
events.
|
|
128
|
+
directory = resume?.directory ?? path.join(events.directory, 'segments');
|
|
129
|
+
release ??= lockSegments(directory);
|
|
130
|
+
const info = await environment(capture.browser);
|
|
131
|
+
const currentEnvironment = { multiplane: version, chromium: info.chromium, raster: options.raster,
|
|
132
|
+
renderer: info.gpu.renderer, ffmpeg: ffmpegInfo.version, encoder: encoding.encoder };
|
|
133
|
+
const effective = { ...options, fps, from, to, out, ffmpeg, audio: options.audio ? path.resolve(options.audio) : undefined,
|
|
134
|
+
'motion-blur': processed.post.motionBlur, 'max-motion-blur': processed.post.maxMotionBlur,
|
|
135
|
+
shutter: processed.post.shutter, grain: processed.post.grain };
|
|
136
|
+
const savedOptions = persistedOptions(effective);
|
|
137
|
+
let manifest;
|
|
138
|
+
if (resume) {
|
|
139
|
+
manifest = resume.manifest;
|
|
140
|
+
checkEnvironment(manifest.environment, currentEnvironment);
|
|
141
|
+
if (manifest.environment && manifest.environment.renderer !== currentEnvironment.renderer) {
|
|
142
|
+
events.warning({ code: 'resume_renderer_changed', message: 'GPU renderer changed since the original render.',
|
|
143
|
+
details: { before: manifest.environment.renderer, after: currentEnvironment.renderer } });
|
|
144
|
+
}
|
|
145
|
+
await checkInputs(manifest);
|
|
146
|
+
manifest.segments = await reusableSegments(directory, manifest);
|
|
147
|
+
if (!manifest.environment) Object.assign(manifest, { environment: currentEnvironment, options: savedOptions,
|
|
148
|
+
first, end, total, segmentFrames: manifest.segmentFrames ?? Math.max(1, Math.round(fps * 10)) });
|
|
149
|
+
} else {
|
|
150
|
+
manifest = { schema: 'multiplane-segments/1', jobId: events.jobId, shot: path.resolve(options.root, shot),
|
|
151
|
+
options: savedOptions, environment: currentEnvironment, first, end, total,
|
|
152
|
+
segmentFrames: segmentFrames ?? Math.max(1, Math.round(fps * 10)), inputs: store.manifest.inputs, segments: [] };
|
|
153
|
+
}
|
|
154
|
+
if (!Number.isSafeInteger(manifest.segmentFrames) || manifest.segmentFrames < 1) throw invalidOption('_segmentFrames', 'Segment size must be a positive integer.');
|
|
155
|
+
store = manifestStore(directory, manifest);
|
|
156
|
+
for (const [file, record] of Object.entries(inputs)) store.input(file, record);
|
|
157
|
+
if (options.audio) await recordInput(path.resolve(options.audio));
|
|
158
|
+
store.save();
|
|
159
|
+
const framesReused = manifest.segments.reduce((sum, segment) => sum + segment.end - segment.first, 0);
|
|
160
|
+
const resumeData = resume ? { resumedFrom: resume.jobId, resume: { segmentsReused: manifest.segments.length, framesReused } } : {};
|
|
161
|
+
events.resumeData = resumeData;
|
|
162
|
+
if (resume) atomicJSON(path.join(events.directory, 'resume.json'), { directory });
|
|
163
|
+
await loaded(events, capture, ffmpegInfo);
|
|
164
|
+
events.progress('capture', framesReused, total);
|
|
91
165
|
await capture.close(); capture = undefined;
|
|
92
166
|
events.check();
|
|
93
|
-
const actualWorkers = Math.min(workers, Math.ceil(total / 4));
|
|
167
|
+
const actualWorkers = Math.min(workers, Math.max(1, Math.ceil((total - framesReused) / 4)));
|
|
94
168
|
events.resources.setWorkers(actualWorkers);
|
|
95
169
|
const started = performance.now();
|
|
96
170
|
const frameHashes = process.env.RENDER_FRAME_HASHES ? [] : null;
|
|
171
|
+
options._recordInputs = true;
|
|
97
172
|
pool = createRenderPool(shot, options, fps, actualWorkers, { width, height }, events.warning,
|
|
98
|
-
done => events.progress('capture', done, total));
|
|
173
|
+
done => events.progress('capture', done + framesReused, total), onInput);
|
|
99
174
|
events.onCancel(() => pool?.cancel());
|
|
100
175
|
await mkdir(path.dirname(out), { recursive: true });
|
|
101
|
-
events.
|
|
102
|
-
temporary = `${out}.${events.jobId}.partial.${options.format}`;
|
|
103
|
-
const audioArgs = options.audio
|
|
104
|
-
? ['-i', path.resolve(options.audio), '-map', '0:v:0', '-map', '1:a:0', '-af', `atrim=start=${audioOffset + rate.seconds(first)},asetpts=PTS-STARTPTS,apad,atrim=duration=${rate.seconds(total)}`, ...encoding.audio]
|
|
105
|
-
: ['-an'];
|
|
106
|
-
// Supported since FFmpeg 5.1; older FFmpeg uses the global -vsync 0.
|
|
107
|
-
// Do not add output -r: passthrough must neither drop nor duplicate frames.
|
|
108
|
-
encoder = spawn(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'rawvideo', '-pixel_format', 'rgba', '-video_size', `${width * scale}x${height * scale}`, '-framerate', rate.argument, '-i', 'pipe:0', ...audioArgs, ...videoArgs, '-fps_mode', 'passthrough', '-pix_fmt', encoding.pixelFormat, ...encoding.mux, temporary], { windowsHide: true, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
109
|
-
encoder.once('close', () => { encoderClosed = true; });
|
|
110
|
-
events.onCancel(() => stopChild(encoder));
|
|
111
|
-
let stderr = '', failure;
|
|
112
|
-
encoder.stderr.on('data', data => { stderr = (stderr + data).slice(-8000); });
|
|
113
|
-
encoder.stdin.on('error', error => { failure = error; });
|
|
114
|
-
const finished = new Promise((resolve, reject) => {
|
|
115
|
-
encoder.once('error', cause => reject(new MultiplaneError('encode_failed', undefined, { cause, details: { reason: cause.message } })));
|
|
116
|
-
encoder.once('close', code => code === 0 ? resolve() : reject(new MultiplaneError('encode_failed', undefined, { details: { exitCode: code, stderr } })));
|
|
117
|
-
});
|
|
118
|
-
finished.catch(error => { failure = error; });
|
|
176
|
+
temporary = out + '.' + events.jobId + '.partial.' + options.format;
|
|
119
177
|
const alphaReferences = new Map();
|
|
120
|
-
let encoderWriteMs = 0, framesSent =
|
|
121
|
-
// Internal switch for the pixel-equivalence and overhead tests, not a CLI option.
|
|
178
|
+
let encoderWriteMs = 0, framesSent = total, sealingFailure;
|
|
122
179
|
const preview = createPreview(events, width * scale, height * scale, { enabled: options._preview !== false });
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
180
|
+
const remember = segment => {
|
|
181
|
+
frameHashes?.push(...(segment.frameHashes ?? []));
|
|
182
|
+
for (const [frame, bytes] of Object.entries(segment.alphaReferences ?? {})) alphaReferences.set(Number(frame), Buffer.from(bytes, 'base64'));
|
|
183
|
+
};
|
|
184
|
+
// Keep workers busy while the preceding encoder drains and seals.
|
|
185
|
+
const ranges = [];
|
|
186
|
+
for (let start = first, index = 0; start < end; start += manifest.segmentFrames, index++) {
|
|
187
|
+
const stop = Math.min(end, start + manifest.segmentFrames);
|
|
188
|
+
const reused = manifest.segments.find(item => item.first === start && item.end === stop);
|
|
189
|
+
if (reused) { remember(reused); continue; }
|
|
190
|
+
ranges.push({ first: start, end: stop, index });
|
|
191
|
+
}
|
|
192
|
+
for (let cursor = 0; cursor < ranges.length;) {
|
|
193
|
+
let last = cursor;
|
|
194
|
+
while (last + 1 < ranges.length && ranges[last].end === ranges[last + 1].first) last++;
|
|
195
|
+
let active, hashes, references, rangeIndex = cursor;
|
|
196
|
+
for await (const { frame, png } of pool.frames(ranges[cursor].first, ranges[last].end)) {
|
|
197
|
+
events.check();
|
|
198
|
+
if (sealingFailure) throw sealingFailure;
|
|
199
|
+
if (!active) {
|
|
200
|
+
active = segmentEncoder({ ffmpeg, directory, ...ranges[rangeIndex], width: width * scale, height: height * scale,
|
|
201
|
+
rate, encoding, format: options.format, events, store, hook });
|
|
202
|
+
encoders.add(active.child);
|
|
203
|
+
active.finished.finally(() => encoders.delete(active.child)).catch(() => {});
|
|
204
|
+
hashes = []; references = {};
|
|
205
|
+
}
|
|
206
|
+
await preview(png, frame);
|
|
207
|
+
const relative = frame - first;
|
|
208
|
+
if (options.alpha && (relative === 0 || relative === Math.floor(total / 2))) references[relative] = Buffer.from(png.filter((_, i) => i % 4 === 3)).toString('base64');
|
|
209
|
+
if (frameHashes) hashes.push({ frame, sha256: createHash('sha256').update(png).digest('hex') });
|
|
210
|
+
const writeStarted = performance.now();
|
|
211
|
+
await active.write(png);
|
|
212
|
+
encoderWriteMs += performance.now() - writeStarted;
|
|
213
|
+
if (frame + 1 === ranges[rangeIndex].end) {
|
|
214
|
+
const sealed = active.seal({ ...(frameHashes ? { frameHashes: hashes } : {}), ...(options.alpha ? { alphaReferences: references } : {}) }).then(remember);
|
|
215
|
+
sealed.catch(error => { sealingFailure ??= error; });
|
|
216
|
+
pending.push(sealed);
|
|
217
|
+
// Hooks model interruption at an exact durable boundary in tests.
|
|
218
|
+
if (hook) await sealed;
|
|
219
|
+
active = undefined; rangeIndex++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
cursor = last + 1;
|
|
135
223
|
}
|
|
136
224
|
const drainStarted = performance.now();
|
|
137
225
|
events.progress('encode', total, total);
|
|
138
|
-
|
|
139
|
-
|
|
226
|
+
await Promise.all(pending);
|
|
227
|
+
// Finish all resource requests before the final input audit/publication.
|
|
228
|
+
await pool.close();
|
|
140
229
|
events.check();
|
|
230
|
+
await checkInputs(manifest);
|
|
231
|
+
movedSource = await assembleSegments({ ffmpeg, directory, manifest, temporary, encoding, rate, audioOffset, events });
|
|
141
232
|
const encoderDrainMs = performance.now() - drainStarted;
|
|
142
233
|
const probeStarted = performance.now();
|
|
143
234
|
const outputCheck = await checkVideo(ffmpeg, temporary, { frames: framesSent, durationSeconds: rate.seconds(framesSent), width: width * scale, height: height * scale, fps: rate.fps, ...(options.alpha ? { alpha: true, format: options.format } : {}) }, events, probe);
|
|
@@ -145,13 +236,18 @@ export async function renderShot(shot, options = {}) {
|
|
|
145
236
|
const outputCheckMs = performance.now() - probeStarted;
|
|
146
237
|
await publishVideo(temporary, out, options.overwrite);
|
|
147
238
|
temporary = undefined;
|
|
239
|
+
// Invalidate the checkpoint while still holding the lock, so another
|
|
240
|
+
// resume cannot reuse a manifest whose output has already been published.
|
|
241
|
+
await rm(path.join(directory, 'manifest.json'), { force: true });
|
|
242
|
+
await rm(directory, { recursive: true, force: true });
|
|
243
|
+
release(); release = undefined;
|
|
148
244
|
const elapsed = (performance.now() - started) / 1000;
|
|
149
|
-
if (frameHashes) await writeFile(process.env.RENDER_FRAME_HASHES, JSON.stringify(frameHashes, null, 2));
|
|
245
|
+
if (frameHashes) await writeFile(process.env.RENDER_FRAME_HASHES, JSON.stringify(frameHashes.sort((a, b) => a.frame - b.frame), null, 2));
|
|
150
246
|
if (process.env.RENDER_PROFILE) await writeFile(process.env.RENDER_PROFILE, JSON.stringify({elapsed,frames:total,workers:actualWorkers,options,stats:pool.stats,encoderWriteMs,encoderDrainMs,outputCheckMs},null,2));
|
|
151
247
|
events.artifact(out, 'video');
|
|
152
|
-
result = { staticReuse: pool.staticReuse, blurSamples: pool.blurSamples, dofSamples: pool.dofSamples, output: out, frames: framesSent, fps, from, to, width: width * scale, height: height * scale, encoder: encoding.encoder, format: options.format, alpha: options.alpha, post: { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) }, renderScale, scale, outputCheck };
|
|
248
|
+
result = { ...resumeData, staticReuse: pool.staticReuse, blurSamples: pool.blurSamples, dofSamples: pool.dofSamples, output: out, frames: framesSent, fps, from, to, width: width * scale, height: height * scale, encoder: encoding.encoder, format: options.format, alpha: options.alpha, post: { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) }, renderScale, scale, outputCheck };
|
|
153
249
|
} finally { await cleanup(); }
|
|
154
250
|
|
|
155
251
|
return options.draft ? { ...result, draft: true } : result;
|
|
156
|
-
});
|
|
252
|
+
}));
|
|
157
253
|
}
|
package/src/resume.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { renderShot } from './render.mjs';
|
|
2
|
+
import { readStatus, resolveJob } from './jobs.mjs';
|
|
3
|
+
import { readManifest, segmentDirectory, unavailable, checkInputs } from './segments.mjs';
|
|
4
|
+
import { invalidOption } from './errors.mjs';
|
|
5
|
+
import { loadUserConfig } from './user-home.mjs';
|
|
6
|
+
|
|
7
|
+
export async function resumeRender(target, options = {}) {
|
|
8
|
+
const allowed = ['workers', 'json', 'job-dir', 'no-hosted', 'onEvent', '_hostedCLI', '_segmentHook', '_inspectFFmpeg', '_probeVideo', 'hosted-executor', 'job-dir-exact'];
|
|
9
|
+
for (const key of Object.keys(options)) if (!allowed.includes(key)) throw invalidOption('--' + key, 'This option cannot be changed when resuming.');
|
|
10
|
+
const configured = loadUserConfig('status', options);
|
|
11
|
+
const jobDir = resolveJob(target, configured.options);
|
|
12
|
+
const status = await readStatus(jobDir);
|
|
13
|
+
if (status.command !== 'render') throw unavailable('not_render');
|
|
14
|
+
if (status.state === 'succeeded') throw unavailable('succeeded');
|
|
15
|
+
if (!['failed', 'cancelled'].includes(status.state)) throw unavailable('running');
|
|
16
|
+
const directory = segmentDirectory(jobDir), manifest = readManifest(directory);
|
|
17
|
+
if (!manifest) throw unavailable('missing_manifest');
|
|
18
|
+
await checkInputs(manifest);
|
|
19
|
+
return renderShot(manifest.shot, { ...manifest.options, ...options,
|
|
20
|
+
_resume: { directory, jobId: status.jobId }, _effectiveOptions: true,
|
|
21
|
+
// Only hosting policy and job lookup use current configuration.
|
|
22
|
+
hosted: loadUserConfig('render', {}).options.hosted, 'no-hosted': options['no-hosted'] ?? false });
|
|
23
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { rename, stat, writeFile, open } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { stopChild } from './process.mjs';
|
|
6
|
+
import { writeFrame } from './video-pipe.mjs';
|
|
7
|
+
import { MultiplaneError } from './errors.mjs';
|
|
8
|
+
import { fileDigest } from './input-records.mjs';
|
|
9
|
+
|
|
10
|
+
function childProcess(ffmpeg, args, events, input = false, onLine) {
|
|
11
|
+
events.check();
|
|
12
|
+
const child = spawn(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-y', ...args],
|
|
13
|
+
{ windowsHide: true, stdio: [input ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
|
|
14
|
+
let stderr = '', stdout = '', failure;
|
|
15
|
+
const unsubscribe = events.onCancel(() => stopChild(child));
|
|
16
|
+
child.stderr.on('data', bytes => { stderr = (stderr + bytes).slice(-8000); });
|
|
17
|
+
if (onLine) createInterface({ input: child.stdout, crlfDelay: Infinity }).on('line', onLine);
|
|
18
|
+
else child.stdout.on('data', bytes => { stdout = (stdout + bytes).slice(-8000); });
|
|
19
|
+
child.stdin?.on('error', error => { failure = error; });
|
|
20
|
+
const finished = new Promise((resolve, reject) => {
|
|
21
|
+
child.once('error', cause => reject(new MultiplaneError('encode_failed', undefined, { cause })));
|
|
22
|
+
child.once('close', code => {
|
|
23
|
+
unsubscribe();
|
|
24
|
+
if (code === 0) resolve(stdout);
|
|
25
|
+
else reject(new MultiplaneError('encode_failed', undefined, { details: { exitCode: code, stderr } }));
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
finished.catch(error => { failure = error; });
|
|
29
|
+
return { child, finished, async write(bytes) {
|
|
30
|
+
try {
|
|
31
|
+
if (failure) throw failure;
|
|
32
|
+
await writeFrame(child.stdin, bytes);
|
|
33
|
+
} catch (cause) {
|
|
34
|
+
throw cause instanceof MultiplaneError ? cause : new MultiplaneError('encode_failed', undefined,
|
|
35
|
+
{ cause, details: { reason: cause.message } });
|
|
36
|
+
}
|
|
37
|
+
} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function segmentEncoder({ ffmpeg, directory, index, first, end, width, height, rate, encoding, format, events, store, hook }) {
|
|
41
|
+
const file = `segment-${String(index).padStart(6, '0')}.${format}`;
|
|
42
|
+
const temporary = path.join(directory, file + '.partial.' + format);
|
|
43
|
+
const encoder = childProcess(ffmpeg, ['-f', 'rawvideo', '-pixel_format', 'rgba', '-video_size', `${width}x${height}`,
|
|
44
|
+
'-framerate', rate.argument, '-i', 'pipe:0', '-an', ...encoding.args, '-fps_mode', 'passthrough',
|
|
45
|
+
'-pix_fmt', encoding.pixelFormat, ...encoding.mux, temporary], events, true);
|
|
46
|
+
return { ...encoder, async seal(extra = {}) {
|
|
47
|
+
encoder.child.stdin.end();
|
|
48
|
+
await encoder.finished;
|
|
49
|
+
events.check();
|
|
50
|
+
// Count copied video packets without decoding or relying on version-specific progress fields.
|
|
51
|
+
let frames = 0;
|
|
52
|
+
const probe = childProcess(ffmpeg, ['-i', temporary, '-map', '0:v:0', '-an', '-c', 'copy', '-f', 'framecrc', '-'], events, false, line => {
|
|
53
|
+
if (line.trim() && !line.trimStart().startsWith('#')) frames++;
|
|
54
|
+
});
|
|
55
|
+
await probe.finished;
|
|
56
|
+
if (!frames) throw new MultiplaneError('output_invalid', 'Segment frame count could not be read.', { details: { expected: end - first, frames: null } });
|
|
57
|
+
if (frames !== end - first) throw new MultiplaneError('output_invalid', 'Segment frame count differs.', { details: { expected: end - first, frames } });
|
|
58
|
+
const handle = await open(temporary, 'r+');
|
|
59
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
60
|
+
await hook?.('beforeSeal', index);
|
|
61
|
+
await rename(temporary, path.join(directory, file));
|
|
62
|
+
const segment = { file, first, end, bytes: (await stat(path.join(directory, file))).size,
|
|
63
|
+
sha256: await fileDigest(path.join(directory, file)), ...extra };
|
|
64
|
+
store.manifest.segments = store.manifest.segments.filter(item => item.first !== first).concat(segment).sort((a, b) => a.first - b.first);
|
|
65
|
+
store.save();
|
|
66
|
+
await hook?.('sealed', index);
|
|
67
|
+
return segment;
|
|
68
|
+
} };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function assembleSegments({ ffmpeg, directory, manifest, temporary, encoding, rate, audioOffset, events }) {
|
|
72
|
+
const segments = [...manifest.segments].sort((a, b) => a.first - b.first);
|
|
73
|
+
const { options, first, total } = manifest;
|
|
74
|
+
if (segments.length === 1 && !options.audio) {
|
|
75
|
+
const source = path.join(directory, segments[0].file);
|
|
76
|
+
try { await rename(source, temporary); return source; }
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error.code !== 'EXDEV') throw error;
|
|
79
|
+
const { copyFile } = await import('node:fs/promises');
|
|
80
|
+
await copyFile(source, temporary);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const list = path.join(directory, 'concat.txt');
|
|
85
|
+
// Relative generated names avoid quoting user paths in the concat language.
|
|
86
|
+
await writeFile(list, segments.map(segment => `file '${segment.file}'\nduration ${rate.seconds(segment.end - segment.first)}\n`).join(''));
|
|
87
|
+
const input = segments.length === 1 ? ['-i', path.join(directory, segments[0].file)] : ['-f', 'concat', '-safe', '1', '-i', list];
|
|
88
|
+
const audio = options.audio ? ['-i', options.audio, '-map', '0:v:0', '-map', '1:a:0', '-af',
|
|
89
|
+
`atrim=start=${audioOffset + rate.seconds(first)},asetpts=PTS-STARTPTS,apad,atrim=duration=${rate.seconds(total)}`, ...encoding.audio] : ['-map', '0:v:0', '-an'];
|
|
90
|
+
await childProcess(ffmpeg, [...input, ...audio, '-c:v', 'copy', ...encoding.mux, temporary], events).finished;
|
|
91
|
+
}
|
package/src/segments.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync, statSync, fsyncSync } from 'node:fs';
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { fileDigest } from './input-records.mjs';
|
|
5
|
+
import { MultiplaneError } from './errors.mjs';
|
|
6
|
+
import { retryFileOperation } from './user-home.mjs';
|
|
7
|
+
|
|
8
|
+
export const unavailable = (reason, changes) => new MultiplaneError('resume_unavailable', undefined,
|
|
9
|
+
{ details: { reason, ...(changes ? { changes } : {}) } });
|
|
10
|
+
|
|
11
|
+
// TODO(design): Document the checkpoint schema and resume.json directory pointer.
|
|
12
|
+
|
|
13
|
+
export function atomicJSON(file, data) {
|
|
14
|
+
const temporary = file + '.tmp';
|
|
15
|
+
const fd = openSync(temporary, 'w');
|
|
16
|
+
try { writeFileSync(fd, JSON.stringify(data) + '\n'); fsyncSync(fd); } finally { closeSync(fd); }
|
|
17
|
+
retryFileOperation(() => renameSync(temporary, file), performance.now() + 1000);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function segmentDirectory(jobDir) {
|
|
21
|
+
try {
|
|
22
|
+
const { directory } = JSON.parse(readFileSync(path.join(jobDir, 'resume.json'), 'utf8'));
|
|
23
|
+
if (!path.isAbsolute(directory) || path.basename(directory) !== 'segments') throw Error('Invalid segment directory.');
|
|
24
|
+
return directory;
|
|
25
|
+
}
|
|
26
|
+
catch { return path.join(jobDir, 'segments'); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readManifest(directory) {
|
|
30
|
+
try {
|
|
31
|
+
const value = JSON.parse(readFileSync(path.join(directory, 'manifest.json'), 'utf8'));
|
|
32
|
+
if (value.schema !== 'multiplane-segments/1' || !value.options || !Array.isArray(value.segments) || !value.inputs) return null;
|
|
33
|
+
if (typeof value.shot !== 'string' || !Number.isSafeInteger(value.first) || value.first < 0
|
|
34
|
+
|| !Number.isSafeInteger(value.end) || value.end < value.first || value.total !== value.end - value.first
|
|
35
|
+
|| (value.segmentFrames !== null && (!Number.isSafeInteger(value.segmentFrames) || value.segmentFrames < 1))) return null;
|
|
36
|
+
const starts = new Set();
|
|
37
|
+
for (const segment of value.segments) {
|
|
38
|
+
if (!Number.isSafeInteger(segment.first) || segment.first < value.first || segment.first >= value.end
|
|
39
|
+
|| (segment.first - value.first) % value.segmentFrames !== 0
|
|
40
|
+
|| segment.end !== Math.min(value.end, segment.first + value.segmentFrames)
|
|
41
|
+
|| starts.has(segment.first) || typeof segment.file !== 'string' || segment.file !== path.basename(segment.file)) return null;
|
|
42
|
+
starts.add(segment.first);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
} catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function lockSegments(directory) {
|
|
49
|
+
mkdirSync(directory, { recursive: true });
|
|
50
|
+
const file = path.join(directory, 'render.lock');
|
|
51
|
+
for (;;) {
|
|
52
|
+
let fd;
|
|
53
|
+
try { fd = openSync(file, 'wx'); }
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== 'EEXIST') throw error;
|
|
56
|
+
try {
|
|
57
|
+
const before = statSync(file);
|
|
58
|
+
let owner;
|
|
59
|
+
try { owner = JSON.parse(readFileSync(file, 'utf8')); } catch { /* Do not steal a lock being written. */ }
|
|
60
|
+
let dead = false;
|
|
61
|
+
if (Number.isSafeInteger(owner?.pid) && owner.pid > 0) {
|
|
62
|
+
try { process.kill(owner.pid, 0); } catch (error) { dead = error.code === 'ESRCH'; }
|
|
63
|
+
}
|
|
64
|
+
if (dead || (!owner && Date.now() - before.mtimeMs > 10000)) {
|
|
65
|
+
const after = statSync(file);
|
|
66
|
+
if (before.ino === after.ino && before.mtimeMs === after.mtimeMs) unlinkSync(file);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
} catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
70
|
+
throw unavailable('running');
|
|
71
|
+
}
|
|
72
|
+
try { writeFileSync(fd, JSON.stringify({ pid: process.pid })); fsyncSync(fd); } finally { closeSync(fd); }
|
|
73
|
+
return () => { try { unlinkSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; } };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function manifestStore(directory, manifest) {
|
|
78
|
+
const save = () => atomicJSON(path.join(directory, 'manifest.json'), manifest);
|
|
79
|
+
return { manifest, save,
|
|
80
|
+
input(file, record) {
|
|
81
|
+
const previous = manifest.inputs[file];
|
|
82
|
+
if (previous && previous.sha256 !== record.sha256) throw unavailable('inputs_changed', [file]);
|
|
83
|
+
if (!previous) { manifest.inputs[file] = record; save(); }
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function checkInputs(manifest) {
|
|
89
|
+
const changes = [];
|
|
90
|
+
for (const [file, expected] of Object.entries(manifest.inputs)) {
|
|
91
|
+
try { if (await fileDigest(file) !== expected.sha256) changes.push(file); }
|
|
92
|
+
catch { changes.push(file); }
|
|
93
|
+
}
|
|
94
|
+
if (changes.length) throw unavailable('inputs_changed', changes);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function checkEnvironment(expected, actual) {
|
|
98
|
+
if (!expected) return;
|
|
99
|
+
const changes = Object.keys(actual).filter(key => key !== 'renderer' && JSON.stringify(expected[key]) !== JSON.stringify(actual[key]));
|
|
100
|
+
if (changes.length) throw unavailable('environment_changed', changes);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function reusableSegments(directory, manifest) {
|
|
104
|
+
const valid = [];
|
|
105
|
+
for (const segment of manifest.segments) {
|
|
106
|
+
if (segment.file !== path.basename(segment.file)) continue;
|
|
107
|
+
try {
|
|
108
|
+
const file = path.join(directory, segment.file);
|
|
109
|
+
if ((await stat(file)).size === segment.bytes && await fileDigest(file) === segment.sha256) valid.push(segment);
|
|
110
|
+
} catch { /* Missing or damaged segments are rendered again. */ }
|
|
111
|
+
}
|
|
112
|
+
return valid;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function resumeStatus(status, directory) {
|
|
116
|
+
const manifest = readManifest(segmentDirectory(directory));
|
|
117
|
+
// TODO(design): Specify missing_manifest for legacy, missing or invalid checkpoints.
|
|
118
|
+
const reason = status.state === 'succeeded' ? 'succeeded' : !['failed', 'cancelled'].includes(status.state) ? 'running' : !manifest ? 'missing_manifest' : null;
|
|
119
|
+
return { available: reason === null, reason,
|
|
120
|
+
framesDone: manifest?.segments.reduce((sum, segment) => sum + segment.end - segment.first, 0)
|
|
121
|
+
?? (status.state === 'succeeded' ? status.result?.data?.frames ?? status.done ?? 0 : 0),
|
|
122
|
+
framesTotal: manifest?.total ?? status.total ?? status.result?.data?.frames ?? 0 };
|
|
123
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { MultiplaneError, invalidOption } from './errors.mjs';
|
|
8
8
|
import { resolveThree } from './three-package.mjs';
|
|
9
|
+
import { recordInput } from './input-records.mjs';
|
|
9
10
|
|
|
10
11
|
export const root = process.cwd();
|
|
11
12
|
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
@@ -71,6 +72,7 @@ export async function startServer(port = 0, root = process.cwd()) {
|
|
|
71
72
|
target = await normalizeResourcePath(target);
|
|
72
73
|
if (!await isInsideResourceRoot(resourceBase, target)) { res.writeHead(403); return res.end(); }
|
|
73
74
|
const info = await stat(target);
|
|
75
|
+
const recorded = await recordInput(target);
|
|
74
76
|
const contentType = { '.wav': 'audio/wav', '.srt': 'text/plain; charset=utf-8', '.mp4': 'video/mp4' }[path.extname(target)] ?? mime[path.extname(target)] ?? 'application/octet-stream';
|
|
75
77
|
const headers = { 'Content-Type': contentType, 'Content-Length': info.size, 'Accept-Ranges': 'bytes' };
|
|
76
78
|
if (req.headers.range) {
|
|
@@ -82,10 +84,12 @@ export async function startServer(port = 0, root = process.cwd()) {
|
|
|
82
84
|
}
|
|
83
85
|
res.writeHead(206, { ...headers, 'Content-Length': end - start + 1, 'Content-Range': `bytes ${start}-${end}/${info.size}` });
|
|
84
86
|
if (req.method === 'HEAD') return res.end();
|
|
87
|
+
if (recorded) return res.end(recorded.subarray(start, end + 1));
|
|
85
88
|
return createReadStream(target, { start, end }).on('error', () => res.destroy()).pipe(res);
|
|
86
89
|
}
|
|
87
90
|
res.writeHead(200, headers);
|
|
88
91
|
if (req.method === 'HEAD') return res.end();
|
|
92
|
+
if (recorded) return res.end(recorded);
|
|
89
93
|
createReadStream(target).on('error', () => res.destroy()).pipe(res);
|
|
90
94
|
} catch (error) {
|
|
91
95
|
if (error.code === 'ERR_MODULE_NOT_FOUND') { res.writeHead(503, { 'x-multiplane-error': 'three_not_installed' }); return res.end('Optional three.js is not installed.'); }
|