multiplane 0.3.7 → 0.3.10

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
@@ -11,7 +11,7 @@ A local, headless HTML video renderer for AI agents. **A shot is an ordinary web
11
11
 
12
12
  <img src="https://raw.githubusercontent.com/LBEILC/Multiplane/main/docs/images/hero.webp" width="720" alt="3D cards rendered by Multiplane, with depth of field and motion blur">
13
13
 
14
- > Version `0.3.0`. The full test suite passes in CI on Windows, macOS and Linux; Windows has also been verified on a real machine. Interfaces may still change before 1.0.
14
+ > Version `0.3.8` (the latest version is on the [Releases page](https://github.com/LBEILC/Multiplane/releases)). The full test suite passes in CI on Windows, macOS and Linux; Windows has also been verified on a real machine. Interfaces may still change before 1.0.
15
15
 
16
16
  The name comes from Disney's multiplane camera: flat artwork placed at different depths, photographed frame by frame with real depth of field. Here, this corresponds to layered web content, a 3D card layer, and accumulated depth of field.
17
17
 
@@ -127,11 +127,16 @@ 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`, with the reason) when a file the shot loaded or the audio file has changed, or the Multiplane, Chromium or FFmpeg version, the raster mode or the encoder differs, so a film is never joined from mismatched parts; a different GPU or driver only produces a warning (`resume_renderer_changed`). `multiplane status` reports whether a job can be resumed and how many frames are done. See section 6.1 of the protocol.
137
+
138
+ A stuck browser does not stall a render forever. If a worker holds frames but delivers none for `max(120 seconds, 10 × the slowest frame so far)`, its browser is closed and relaunched, the frame is rendered again from scratch (with the same pixels), and a `worker_stalled` warning is reported. Each frame is retried at most twice; a third stall ends the job with `browser_crashed`. Segments already encoded are kept, so the job can be resumed.
139
+
135
140
  Common `render` options:
136
141
 
137
142
  | Option | Meaning |
@@ -269,20 +274,26 @@ npm install three@0.180.0
269
274
 
270
275
  <picture>
271
276
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/LBEILC/Multiplane/main/docs/images/app-jobs-en-dark.png">
272
- <img src="https://raw.githubusercontent.com/LBEILC/Multiplane/main/docs/images/app-jobs-en-light.png" width="800" alt="The Jobs page of the Multiplane desktop app: a running job, a queued job and a live preview frame">
277
+ <img src="https://raw.githubusercontent.com/LBEILC/Multiplane/main/docs/images/app-jobs-en-light.png" width="800" alt="The Jobs page of the Multiplane desktop app: running, queued and resumable jobs on the left, and the Timeline tab of the selected job on the right, listing how long each phase took and the events">
273
278
  </picture>
274
279
 
275
280
 
276
- - **Jobs**: list render, stills, and check jobs across projects, show progress and preview frames in real time, view finished videos, contact sheets, and diff images from failed checks, and stop running jobs.
277
- - **Environment**: check whether the browser, GPU rasterization, FFmpeg, and other requirements are ready. Install and update the Multiplane skill for Claude Code (`~/.claude/skills/`) and Codex (`~/.agents/skills/`), without directly overwriting user modifications.
281
+ - **Jobs**: list render, stills, and check jobs across projects, show progress and preview frames in real time, and stop running jobs. Opening a job shows four tabs:
282
+ - **Preview**: finished videos play in a frame-accurate player: step one frame at a time, jump between cuts, and read the exact frame number and timecode. Any frame opens in a viewer that zooms to actual pixels, and when a determinism check fails, the reference, actual and difference images can be compared side by side or by flickering between them.
283
+ - **Summary**: the output, whether it was verified, and the picture, post-processing, performance and environment at a glance.
284
+ - **Timeline**: how long each phase took, with the events and warnings along the way.
285
+ - **Options**: the settings the job actually used, in plain words, which can be copied as a command.
286
+ - **Resume**: a failed or stopped render that can continue shows **Resume** with the frames already done, and the new job links back to the original. When resuming is refused, the app explains why, for example which files changed. With hosting on, the app also takes over an agent's `resume` requests.
287
+ - **Tray popover**: on Windows and macOS, clicking the tray icon opens a small panel with running, queued and recently finished jobs. The tray icon and the taskbar button show render progress. Linux keeps the system menu.
288
+ - **Environment**: check whether the browser, GPU rasterization, FFmpeg, and other requirements are ready; on first use, **Install browser** here downloads Chromium. Install and update the Multiplane skill for Claude Code (`~/.claude/skills/`) and Codex (`~/.agents/skills/`), without directly overwriting user modifications.
278
289
  - **Settings**: write the user config `config.json` described above.
279
290
  - **Hosted renders** (off by default): when turned on in Settings, renders started by an agent are run by the app instead of the agent's session, so they keep going when the session ends. One hosted render runs at a time and the rest wait in a queue. The agent's command and output stay the same, and `multiplane wait <job>` picks a job up again after a session restarts. With hosting on, closing the window keeps the app in the system tray. See section 11 of the protocol.
280
- - **Updates**: the app checks GitHub for a new release at startup and once a day (can be turned off). On Windows it downloads and installs the update in the app; on macOS and Linux it links to the download page.
291
+ - **Updates**: the app checks GitHub for a new release at startup and once a day (can be turned off). On Windows it downloads and installs the update in the app; on macOS and Linux it links to the download page. The About page lists what changed in each version since the one installed, in the interface language.
281
292
  - The interface is in English and Simplified Chinese. It follows the system language and can be switched in Settings.
282
293
 
283
294
  It communicates with the renderer only through the files and commands in [`docs/protocol.md`](https://github.com/LBEILC/Multiplane/blob/main/docs/protocol.md). It is not required for rendering: agents can render from the command line without it.
284
295
 
285
- Download an installer from the [Releases page](https://github.com/LBEILC/Multiplane/releases). The installers bundle a renderer of the same version, so the app works without a separate Node.js install. That renderer is also available as a command: `multiplane.cmd` in the install directory on Windows, `Multiplane.app/Contents/Resources/bin/multiplane` on macOS, and `Multiplane-<version>-x86_64.AppImage --multiplane-cli` on Linux. The Environment page shows the exact path when `multiplane` is not on PATH. FFmpeg is not bundled (see above), and Chromium is downloaded on first use from the Environment page.
296
+ Download an installer from the [Releases page](https://github.com/LBEILC/Multiplane/releases). The installers bundle a renderer of the same version, so the app works without a separate Node.js install. That renderer is also available as a command: `multiplane.cmd` in the install directory on Windows, `Multiplane.app/Contents/Resources/bin/multiplane` on macOS, and `Multiplane-<version>-x86_64.AppImage --multiplane-cli` on Linux. The Environment page shows the exact path when `multiplane` is not on PATH. FFmpeg is not bundled (see above; install one with libx264 support), and Chromium is downloaded on first use with **Install browser** on the Environment page.
286
297
 
287
298
  | System | Package | First launch (the packages are not code-signed) |
288
299
  |---|---|---|
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  <img src="https://raw.githubusercontent.com/LBEILC/Multiplane/main/docs/images/hero.webp" width="720" alt="Multiplane 渲染的三维卡片:景深虚化与运动模糊">
13
13
 
14
- > 当前版本 `0.3.0`。完整测试在 Windows、macOS、Linux 三个平台的 CI 上全部通过;Windows 另在真机上验证过。1.0 之前接口仍可能调整。
14
+ > 当前版本 `0.3.8`(最新版本见 [Releases 页面](https://github.com/LBEILC/Multiplane/releases))。完整测试在 Windows、macOS、Linux 三个平台的 CI 上全部通过;Windows 另在真机上验证过。1.0 之前接口仍可能调整。
15
15
 
16
16
  名字来自迪士尼的多层摄影台(multiplane camera):一层层平面画稿放在不同深度、逐帧拍摄、带真实景深。对应这里的多层网页画面、三维卡片层和累积景深。
17
17
 
@@ -127,11 +127,16 @@ 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`,并说明是哪一项),以免拼出前后不一致的片子;只是显卡或驱动不同,则只给出警告(`resume_renderer_changed`)。`multiplane status` 会告诉你一个作业能不能续传、已经渲完多少帧。见协议第 6.1 节。
137
+
138
+ 浏览器卡住时,渲染也不会一直挂着:某个进程拿着帧、却在 `max(120 秒, 此前最慢一帧耗时的 10 倍)` 内一帧都交不出来时,会关掉它的浏览器、重新启动,并把那一帧从头重渲(像素相同),同时报 `worker_stalled` 警告。每一帧最多重试两次,第三次再卡住,作业以 `browser_crashed` 结束;已经编好的段会保留,之后可以用 `resume` 接着渲。
139
+
135
140
  `render` 的常用参数:
136
141
 
137
142
  | 参数 | 含义 |
@@ -269,20 +274,26 @@ npm install three@0.180.0
269
274
 
270
275
  <picture>
271
276
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/LBEILC/Multiplane/main/docs/images/app-jobs-zh-dark.png">
272
- <img src="https://raw.githubusercontent.com/LBEILC/Multiplane/main/docs/images/app-jobs-zh-light.png" width="800" alt="Multiplane 桌面软件的作业页:进行中的作业、排队中的作业和实时预览帧">
277
+ <img src="https://raw.githubusercontent.com/LBEILC/Multiplane/main/docs/images/app-jobs-zh-light.png" width="800" alt="Multiplane 桌面软件的作业页:左边是进行中、排队中和可继续渲染的作业,右边是所选作业的“过程”标签,列出各阶段用时和事件">
273
278
  </picture>
274
279
 
275
280
 
276
- - **作业**:列出各项目里的渲染、抽帧、检查作业,实时显示进度和预览帧,查看成片、联系表和检查不通过时的差异图,可以停止进行中的作业。
277
- - **环境**:检查浏览器、GPU 光栅、FFmpeg 等是否就绪;给 Claude Code(`~/.claude/skills/`)和 Codex(`~/.agents/skills/`)安装、更新 Multiplane 的 skill,自己改过的不会被直接覆盖。
281
+ - **作业**:列出各项目里的渲染、抽帧、检查作业,实时显示进度和预览帧,可以停止进行中的作业。点开一个作业,详情分四个标签:
282
+ - **预览**:成片在逐帧播放器里播放,可以一帧一帧地走、在切点之间跳转,并显示准确的帧号和时间码;任意一帧都能单独打开,放大到实际像素。确定性检查不通过时,参考图、实际图和差异图可以并排看,也可以闪烁对比。
283
+ - **概览**:产物、成品是否核对过,以及画面、后期、性能、环境一目了然。
284
+ - **过程**:每个阶段用了多久,以及过程中的事件和警告。
285
+ - **参数**:用平白的话列出这次作业实际生效的参数,可以复制成命令。
286
+ - **继续渲染**:失败或被停止、又能接着渲的渲染,会显示“继续渲染”和已经完成的帧数;新作业会链回原作业。不能接着渲时(例如镜头用到的文件变了),软件会说明原因。开启托管后,软件也会接管 agent 发起的 `resume`。
287
+ - **托盘弹窗**:在 Windows 和 macOS 上点托盘图标,会打开一个小面板,列出进行中、排队中和最近完成的作业;托盘图标和任务栏按钮会显示渲染进度。Linux 上仍是系统菜单。
288
+ - **环境**:检查浏览器、GPU 光栅、FFmpeg 等是否就绪,首次使用时在这里点“安装浏览器”下载 Chromium;给 Claude Code(`~/.claude/skills/`)和 Codex(`~/.agents/skills/`)安装、更新 Multiplane 的 skill,自己改过的不会被直接覆盖。
278
289
  - **设置**:写入上文的用户配置 `config.json`。
279
290
  - **托管渲染**(默认关闭):在设置里开启后,agent 发起的渲染改由软件执行,不再挂在 agent 的会话上,会话结束也会照常做完。一次只执行一个托管渲染,其余排队。agent 的命令和输出不变;会话重启后用 `multiplane wait <job>` 接上之前的作业。开启托管后,关闭窗口时软件留在系统托盘。见协议第 11 节。
280
- - **更新**:启动时和之后每天检查一次 GitHub 上的新版本(可以关闭)。Windows 上在软件里下载并安装;macOS 和 Linux 上打开下载页。
291
+ - **更新**:启动时和之后每天检查一次 GitHub 上的新版本(可以关闭)。Windows 上在软件里下载并安装;macOS 和 Linux 上打开下载页。“关于”页会按界面语言列出从已安装版本起每个版本的更新内容。
281
292
  - 界面有英文和简体中文,默认跟随系统语言,也可以在设置里切换。
282
293
 
283
294
  它只通过 [`docs/protocol.zh-CN.md`](https://github.com/LBEILC/Multiplane/blob/main/docs/protocol.zh-CN.md) 的文件和命令行与渲染器打交道,不是渲染的必需品:不装它,agent 照样可以用命令行渲染。
284
295
 
285
- 从 [Releases 页面](https://github.com/LBEILC/Multiplane/releases) 下载安装包。安装包里带一份同版本的渲染器,不需要另装 Node.js。这份渲染器也可以当命令用:Windows 为安装目录下的 `multiplane.cmd`,macOS 为 `Multiplane.app/Contents/Resources/bin/multiplane`,Linux 为 `Multiplane-<版本>-x86_64.AppImage --multiplane-cli`。PATH 里找不到 `multiplane` 时,环境页会显示完整路径。FFmpeg 不随包提供(见上文),Chromium 在环境页首次使用时下载。
296
+ 从 [Releases 页面](https://github.com/LBEILC/Multiplane/releases) 下载安装包。安装包里带一份同版本的渲染器,不需要另装 Node.js。这份渲染器也可以当命令用:Windows 为安装目录下的 `multiplane.cmd`,macOS 为 `Multiplane.app/Contents/Resources/bin/multiplane`,Linux 为 `Multiplane-<版本>-x86_64.AppImage --multiplane-cli`。PATH 里找不到 `multiplane` 时,环境页会显示完整路径。FFmpeg 不随包提供(见上文,需要自己安装一个支持 libx264 的),Chromium 在环境页首次使用时点“安装浏览器”下载。
286
297
 
287
298
  | 系统 | 安装包 | 首次打开(安装包没有代码签名) |
288
299
  |---|---|---|
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.10",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
@@ -40,7 +40,8 @@
40
40
  "export:public": "node tools/export-public.mjs",
41
41
  "release:check": "node tools/release-check.mjs",
42
42
  "bench": "node tools/bench/run.mjs",
43
- "test:full": "node --import ./tests/test-home.mjs tools/test-suite.mjs --full"
43
+ "test:full": "node --import ./tests/test-home.mjs tools/test-suite.mjs --full",
44
+ "release": "node tools/release.mjs"
44
45
  },
45
46
  "dependencies": {
46
47
  "acorn": "8.15.0",
@@ -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
 
@@ -210,6 +211,8 @@ With `--json`, stdout contains one JSON event per line. **The last line is alway
210
211
 
211
212
  ## Long renders
212
213
 
213
- 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
+ Rendering may take a long time, often longer than the timeout of your shell or command tool. When the tool lets you, give `render` a timeout long enough for the whole render or run it in the background; if the command is cut off, the job's process usually dies with it, so do not start the same render again: check it with `multiplane status <jobId> --json` and continue it with `multiplane resume` (see below). 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.
package/src/artifacts.mjs CHANGED
@@ -1,5 +1,4 @@
1
- import { constants } from 'node:fs';
2
- import { copyFile, rm, writeFile, access, rename, mkdtemp, mkdir } from 'node:fs/promises';
1
+ import { link, rm, writeFile, access, rename, mkdtemp, mkdir } from 'node:fs/promises';
3
2
  import path from 'node:path';
4
3
  import { MultiplaneError } from './errors.mjs';
5
4
 
@@ -16,11 +15,19 @@ export async function writeArtifact(file, bytes) {
16
15
  try { await writeFile(file, bytes, { flag: 'wx' }); }
17
16
  catch (error) { outputError(error, file); }
18
17
  }
19
- export async function publishVideo(temporary, file, overwrite = false) {
20
- if (overwrite) { await rename(temporary, file); return; }
21
- try { await copyFile(temporary, file, constants.COPYFILE_EXCL); }
22
- catch (error) { outputError(error, file); }
23
- await rm(temporary);
18
+ export async function publishVideo(temporary, file, overwrite = false, { linkFile = link, move = rename, remove = rm } = {}) {
19
+ if (overwrite) { await move(temporary, file); return; }
20
+ try { await linkFile(temporary, file); }
21
+ catch (error) {
22
+ if (!['ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EPERM'].includes(error.code)) outputError(error, file);
23
+ // The temporary video is in the output directory, so rename publishes it whole.
24
+ // Accept the short race between checking the target and renaming.
25
+ await requireNewOutput(file);
26
+ try { await move(temporary, file); }
27
+ catch (moveError) { outputError(moveError, file); }
28
+ return;
29
+ }
30
+ await remove(temporary);
24
31
  }
25
32
 
26
33
  // Build a replacement directory in isolation; publish only after the whole command succeeds.
@@ -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;