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 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 { renderShot, stillsShot, verifyShot, inspectShot, lintShot, doctor, statusJob, cancelJob } from '../src/index.mjs';
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
- else await setup(command, args.slice(1), onEvent);
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
- const action = { render: renderShot, stills: stillsShot, verify: verifyShot, inspect: inspectShot, serve, lint: lintShot, doctor, benchmark, status: statusJob, cancel: cancelJob }[command];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multiplane",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
@@ -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
- emit('start', { version, shot, ...(jobId ? { jobId } : {}), options: effective, ...(this.workersSource ? { workersSource: this.workersSource } : {}) });
117
+ for (const key of Object.keys(effective)) if (key.startsWith('_')) delete effective[key];
118
+ emit('start', { ...this.resumeData, version, shot, ...(jobId ? { jobId } : {}), options: effective, ...(this.workersSource ? { workersSource: this.workersSource } : {}) });
117
119
  index('start', { command, shot, pid: process.pid });
118
120
  for (const [type, fields] of pendingEvents.splice(0)) emit(type, fields);
119
121
  },
@@ -129,7 +131,7 @@ export function createEvents(command, shot, options = {}, job = false, deferStar
129
131
  if (deferStart) this.start(true);
130
132
  const result = { command, ok: !error, code: error?.code ?? null, message: error?.message ?? null,
131
133
  ...(error?.hint ? { hint: error.hint } : {}), ...(error?.details ? { details: error.details } : {}),
132
- artifacts, elapsedMs: Math.round(performance.now() - started), ...(jobId ? { jobId } : {}), data };
134
+ artifacts, elapsedMs: Math.round(performance.now() - started), ...(jobId ? { jobId } : {}), data: this.resumeData ? { ...this.resumeData, ...data } : data };
133
135
  index('end', { state: !error ? 'succeeded' : error.code === 'cancelled' ? 'cancelled' : 'failed' });
134
136
  emit('result', result);
135
137
  return result;
@@ -142,7 +144,7 @@ export async function runCommand(command, shot, options, action, job = true) {
142
144
  let events, resources, linted = false;
143
145
  try {
144
146
  const explicit = options;
145
- const user = loadUserConfig(command, options);
147
+ const user = options._effectiveOptions ? { options: { ...options }, config: { applied: [] }, warnings: [] } : loadUserConfig(command, options);
146
148
  options = user.options;
147
149
  if (['render', 'stills', 'verify'].includes(command)) {
148
150
  options.quality = resolveQuality(options);
@@ -192,7 +194,7 @@ export async function runCommand(command, shot, options, action, job = true) {
192
194
  if (!options['no-lint']) { await checkLint(shot, events.options, events); linted = true; }
193
195
  events.start();
194
196
  const request = { schema: 'multiplane/1', jobId: events.jobId, jobDir: events.directory,
195
- command, args: options._hostedCLI.args, cwd: process.cwd(), node: process.execPath,
197
+ command: options._resume ? 'resume' : command, args: options._hostedCLI.args, cwd: process.cwd(), node: process.execPath,
196
198
  bin: options._hostedCLI.bin, env: hostedEnvironment(process.env), at: new Date().toISOString() };
197
199
  const ownership = await handoff(request);
198
200
  if (ownership.hosted) {
@@ -210,7 +212,7 @@ export async function runCommand(command, shot, options, action, job = true) {
210
212
  resources = createResourceSampler({ enabled: options._resources !== false, onSample: events.memory });
211
213
  events.resources = resources;
212
214
  }
213
- if (options.draft) {
215
+ if (options.draft && !options._effectiveOptions) {
214
216
  // Keep lint and cancellation active before the dimension-only draft probe.
215
217
  if (!linted && !options['no-lint']) {
216
218
  const { findings, data } = await scanShot(shot, events.options);
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
@@ -1,4 +1,5 @@
1
1
  export { renderShot } from './render.mjs';
2
+ export { resumeRender } from './resume.mjs';
2
3
  export { stillsShot } from './stills.mjs';
3
4
  export { verifyShot } from './verify.mjs';
4
5
  export { openShot } from './capture.mjs';
@@ -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
- await sampleMany([...endpoints, t], true);
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
- const still = endpoints.every(time => cache.get(time).image.data.equals(centerProbe.image.data));
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;
@@ -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
- async close() {
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
- for (const slot of slots) if (!slot.closed) slot.worker.postMessage({ type: 'cancel' });
95
- await Promise.all(slots.map(s => s.exited));
121
+ fail(new MultiplaneError('cancelled'));
122
+ await close();
96
123
  },
97
124
  };
98
125
  const frames = api.frames.bind(api);
@@ -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
- let capture, closing = false, running = false;
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 { writeFrame } from './video-pipe.mjs';
15
- import { runCommand } from './events.mjs';
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 { MultiplaneError, invalidOption } from './errors.mjs';
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, encoder, temporary, cleaning, encoderClosed = false;
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
- if (encoder && !encoderClosed) await stopChild(encoder);
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 { if (temporary) await rm(temporary, { force: true }); } }
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
- let videoArgs = encoding.args;
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
- videoArgs = encoding.args;
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.progress('capture', 0, total);
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.check();
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 = 0;
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
- for await (const { frame, png } of pool.frames(first, end, async ({ frame, png }) => {
124
- events.check();
125
- await preview(png, frame);
126
- })) {
127
- events.check();
128
- if (failure) throw new MultiplaneError('encode_failed', undefined, { cause: failure });
129
- if (options.alpha && (framesSent === 0 || framesSent === Math.floor(total / 2))) alphaReferences.set(framesSent, Buffer.from(png.filter((_, i) => i % 4 === 3)));
130
- frameHashes?.push({ frame, sha256: createHash('sha256').update(png).digest('hex') });
131
- const writeStarted = performance.now();
132
- await writeFrame(encoder.stdin, png).catch(cause => { throw new MultiplaneError('encode_failed', undefined, { cause, details: { reason: cause.message } }); });
133
- encoderWriteMs += performance.now() - writeStarted;
134
- framesSent++;
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
- encoder.stdin.end();
139
- await finished;
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
+ }
@@ -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.'); }