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 +17 -6
- package/README.md +17 -6
- package/bin/multiplane +24 -14
- package/package.json +3 -2
- package/skills/multiplane/SKILL.md +4 -1
- package/src/artifacts.mjs +14 -7
- package/src/capture-watchdog.mjs +29 -0
- package/src/capture.mjs +22 -6
- package/src/errors.mjs +1 -0
- package/src/events.mjs +7 -5
- package/src/hosted.mjs +2 -2
- package/src/index.mjs +1 -0
- package/src/input-records.mjs +33 -0
- package/src/jobs.mjs +3 -1
- package/src/post.mjs +18 -2
- package/src/render-pool.mjs +39 -12
- package/src/render-worker.mjs +13 -2
- package/src/render.mjs +148 -52
- package/src/resume.mjs +23 -0
- package/src/segment-encoder.mjs +91 -0
- package/src/segments.mjs +123 -0
- package/src/server.mjs +4 -0
package/README.en.md
CHANGED
|
@@ -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.
|
|
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:
|
|
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,
|
|
277
|
-
- **
|
|
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
|
|
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.
|
|
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
|
-
-
|
|
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
|
|
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 {
|
|
3
|
-
import { benchmark } from '../src/benchmark.mjs';
|
|
4
|
-
import { serve } from '../src/serve.mjs';
|
|
5
|
-
import { setup } from '../src/setup.mjs';
|
|
6
|
-
import { postArgs, scaleArgs } from '../src/post.mjs';
|
|
7
|
-
import { createEvents, version } from '../src/events.mjs';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
8
3
|
import { asError, invalidOption } from '../src/errors.mjs';
|
|
9
|
-
import { waitJob } from '../src/jobs.mjs';
|
|
10
4
|
import { claimExecutor, resultExitCode } from '../src/hosted.mjs';
|
|
11
5
|
import { fileURLToPath } from 'node:url';
|
|
12
6
|
|
|
13
7
|
const args = process.argv.slice(2), json = args.includes('--json');
|
|
8
|
+
// Keep CLI flag metadata independent of the rendering implementation.
|
|
9
|
+
const postArgs = ['quality', 'motion-blur', 'max-motion-blur', 'shutter', 'grain', 'no-post', 'adaptive-blur'];
|
|
10
|
+
const scaleArgs = ['scale', 'render-scale', 'upscale'];
|
|
14
11
|
const common = [...postArgs, ...scaleArgs, 'static-reuse', 'no-static-reuse', 'alpha', 'fps', 'raster', 'root', 'job-dir', 'no-lint', 'overwrite', 'no-hosted', 'job-dir-exact', 'hosted-executor'];
|
|
15
12
|
const allowed = {
|
|
16
13
|
inspect: ['root', 'fps', 'no-lint'], serve: ['root', 'port'],
|
|
@@ -19,6 +16,7 @@ const allowed = {
|
|
|
19
16
|
benchmark: ['raster', 'max-workers'], config: [], skill: ['host', 'force', 'expect'],
|
|
20
17
|
lint: ['root'], doctor: ['ffmpeg', 'raster'], status: ['job-dir'], cancel: ['job-dir', 'wait'],
|
|
21
18
|
wait: ['job-dir', 'timeout'],
|
|
19
|
+
resume: ['job-dir', 'workers', 'no-hosted', 'job-dir-exact', 'hosted-executor'],
|
|
22
20
|
};
|
|
23
21
|
// Internal flags used by the desktop app to start hosted executors; not listed in help.
|
|
24
22
|
const internal = ['job-dir-exact', 'hosted-executor'];
|
|
@@ -35,17 +33,18 @@ const onEvent = event => {
|
|
|
35
33
|
};
|
|
36
34
|
let command = args[0] ?? 'doctor';
|
|
37
35
|
try {
|
|
38
|
-
const executor = ['render', 'stills', 'verify'].includes(command) && args.includes('--hosted-executor');
|
|
36
|
+
const executor = ['render', 'stills', 'verify', 'resume'].includes(command) && args.includes('--hosted-executor');
|
|
39
37
|
const exactIndex = args.indexOf('--job-dir-exact');
|
|
40
38
|
const lostClaim = executor && (exactIndex < 0 || !args[exactIndex + 1]
|
|
41
39
|
|| !await claimExecutor(args[exactIndex + 1], command));
|
|
42
40
|
const onBytes = bytes => new Promise((resolve, reject) => process.stdout.write(bytes, error => error ? reject(error) : resolve()));
|
|
43
41
|
if (lostClaim) { process.exitCode = 0; }
|
|
44
|
-
else if (args.length === 1 && args[0] === '--version') console.log(version);
|
|
42
|
+
else if (args.length === 1 && args[0] === '--version') console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url))).version);
|
|
45
43
|
else if (args.length === 0 || args[0] === '--help') {
|
|
46
44
|
console.log('Usage: multiplane <render|stills|verify|inspect|lint> <shot.html> [--json] [--option value]\n'
|
|
47
45
|
+ ' multiplane serve [--root directory] [--port number] [--json]\n'
|
|
48
46
|
+ ' multiplane <status|cancel> <jobId|jobDir> [--json] [--wait seconds]\n'
|
|
47
|
+
+ ' multiplane resume <jobId|jobDir> [--json] [--workers n] [--job-dir directory] [--no-hosted]\n'
|
|
49
48
|
+ ' multiplane wait <jobId|jobDir> [--json] [--timeout seconds]\n'
|
|
50
49
|
+ ' multiplane benchmark [--json] [--raster gpu|software] [--max-workers n]\n'
|
|
51
50
|
+ ' multiplane doctor [--json] [--ffmpeg path]\n'
|
|
@@ -56,11 +55,13 @@ try {
|
|
|
56
55
|
if (args.includes('--help')) console.log(command === 'config'
|
|
57
56
|
? 'Usage: multiplane config <get [key]|set key value|unset key> [--json]\nKeys: ffmpeg jobsDir raster encoder workers hosted'
|
|
58
57
|
: 'Usage: multiplane skill <status|install|remove> [--host claude|codex|all] [--force] [--expect signature] [--json]\nInstall and remove require --host.');
|
|
59
|
-
|
|
58
|
+
// TODO(design): setup/jobs still transitively load rendering through src/events.mjs.
|
|
59
|
+
// Removing those dependencies requires extending task 083's read-only src scope.
|
|
60
|
+
else { const { setup } = await import('../src/setup.mjs'); await setup(command, args.slice(1), onEvent); }
|
|
60
61
|
} else {
|
|
61
62
|
if (!allowed[command]) throw invalidOption('command', 'Unknown command: ' + command);
|
|
62
63
|
if (args.includes('--help')) {
|
|
63
|
-
console.log('Usage: multiplane ' + command + (['doctor', 'serve', 'benchmark'].includes(command) ? '' : ['status', 'cancel', 'wait'].includes(command) ? ' <jobId|jobDir>' : ' <shot.html>') + ' [options]\nOptions: --json --help ' + listed(allowed[command]).map(f => '--' + f + (['static-reuse', 'no-static-reuse', 'overwrite', 'no-lint', 'no-post', 'alpha', 'draft', 'no-hosted', 'hosted-executor'].includes(f) ? '' : ' <value>')).join(' '));
|
|
64
|
+
console.log('Usage: multiplane ' + command + (['doctor', 'serve', 'benchmark'].includes(command) ? '' : ['status', 'cancel', 'wait', 'resume'].includes(command) ? ' <jobId|jobDir>' : ' <shot.html>') + ' [options]\nOptions: --json --help ' + listed(allowed[command]).map(f => '--' + f + (['static-reuse', 'no-static-reuse', 'overwrite', 'no-lint', 'no-post', 'alpha', 'draft', 'no-hosted', 'hosted-executor'].includes(f) ? '' : ' <value>')).join(' '));
|
|
64
65
|
} else {
|
|
65
66
|
const needsTarget = !['doctor', 'serve', 'benchmark'].includes(command);
|
|
66
67
|
const target = needsTarget ? args[1] : undefined;
|
|
@@ -78,14 +79,23 @@ try {
|
|
|
78
79
|
delete options.json;
|
|
79
80
|
if (options['job-dir-exact'] && !executor) throw invalidOption('--job-dir-exact', '--job-dir-exact requires --hosted-executor.');
|
|
80
81
|
options.onEvent = executor ? undefined : onEvent;
|
|
81
|
-
if (['render', 'stills', 'verify'].includes(command) && !executor) options._hostedCLI = {
|
|
82
|
+
if (['render', 'stills', 'verify', 'resume'].includes(command) && !executor) options._hostedCLI = {
|
|
82
83
|
args: args.slice(1).filter(arg => arg !== '--json'), bin: fileURLToPath(import.meta.url), onBytes,
|
|
83
84
|
};
|
|
84
85
|
if (command === 'wait') {
|
|
86
|
+
const { waitJob } = await import('../src/jobs.mjs');
|
|
85
87
|
const result = await waitJob(target, { ...options, onEvent: json ? undefined : onEvent, onBytes: json ? onBytes : undefined });
|
|
86
88
|
reported = true; process.exitCode = resultExitCode(result);
|
|
87
89
|
} else {
|
|
88
|
-
|
|
90
|
+
let action;
|
|
91
|
+
if (command === 'lint') {
|
|
92
|
+
const { runCommand } = await import('../src/events.mjs');
|
|
93
|
+
const { checkLint } = await import('../src/lint.mjs');
|
|
94
|
+
action = (shot, options) => runCommand('lint', shot, options, events => checkLint(shot, events.options, events), false);
|
|
95
|
+
} else {
|
|
96
|
+
const [module, name] = { render: ['render', 'renderShot'], stills: ['stills', 'stillsShot'], verify: ['verify', 'verifyShot'], inspect: ['inspect', 'inspectShot'], serve: ['serve', 'serve'], doctor: ['doctor', 'doctor'], benchmark: ['benchmark', 'benchmark'], status: ['jobs', 'statusJob'], cancel: ['jobs', 'cancelJob'], resume: ['resume', 'resumeRender'] }[command];
|
|
97
|
+
action = (await import(`../src/${module}.mjs`))[name];
|
|
98
|
+
}
|
|
89
99
|
const result = needsTarget ? await action(target, options) : await action(options);
|
|
90
100
|
if (result?.ok !== undefined) { reported = true; process.exitCode = resultExitCode(result); }
|
|
91
101
|
}
|
|
@@ -93,6 +103,6 @@ try {
|
|
|
93
103
|
}
|
|
94
104
|
} catch (failure) {
|
|
95
105
|
const error = asError(failure);
|
|
96
|
-
if (!reported) createEvents(command, null, { onEvent }).finish({}, error);
|
|
106
|
+
if (!reported) { const { createEvents } = await import('../src/events.mjs'); createEvents(command, null, { onEvent }).finish({}, error); }
|
|
97
107
|
process.exitCode = error.exitCode;
|
|
98
108
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "multiplane",
|
|
3
|
-
"version": "0.3.
|
|
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 {
|
|
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
|
|
21
|
-
try { await
|
|
22
|
-
catch (error) {
|
|
23
|
-
|
|
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
|
-
|
|
117
|
+
for (const key of Object.keys(effective)) if (key.startsWith('_')) delete effective[key];
|
|
118
|
+
emit('start', { ...this.resumeData, version, shot, ...(jobId ? { jobId } : {}), options: effective, ...(this.workersSource ? { workersSource: this.workersSource } : {}) });
|
|
117
119
|
index('start', { command, shot, pid: process.pid });
|
|
118
120
|
for (const [type, fields] of pendingEvents.splice(0)) emit(type, fields);
|
|
119
121
|
},
|
|
@@ -129,7 +131,7 @@ export function createEvents(command, shot, options = {}, job = false, deferStar
|
|
|
129
131
|
if (deferStart) this.start(true);
|
|
130
132
|
const result = { command, ok: !error, code: error?.code ?? null, message: error?.message ?? null,
|
|
131
133
|
...(error?.hint ? { hint: error.hint } : {}), ...(error?.details ? { details: error.details } : {}),
|
|
132
|
-
artifacts, elapsedMs: Math.round(performance.now() - started), ...(jobId ? { jobId } : {}), data };
|
|
134
|
+
artifacts, elapsedMs: Math.round(performance.now() - started), ...(jobId ? { jobId } : {}), data: this.resumeData ? { ...this.resumeData, ...data } : data };
|
|
133
135
|
index('end', { state: !error ? 'succeeded' : error.code === 'cancelled' ? 'cancelled' : 'failed' });
|
|
134
136
|
emit('result', result);
|
|
135
137
|
return result;
|
|
@@ -142,7 +144,7 @@ export async function runCommand(command, shot, options, action, job = true) {
|
|
|
142
144
|
let events, resources, linted = false;
|
|
143
145
|
try {
|
|
144
146
|
const explicit = options;
|
|
145
|
-
const user = loadUserConfig(command, options);
|
|
147
|
+
const user = options._effectiveOptions ? { options: { ...options }, config: { applied: [] }, warnings: [] } : loadUserConfig(command, options);
|
|
146
148
|
options = user.options;
|
|
147
149
|
if (['render', 'stills', 'verify'].includes(command)) {
|
|
148
150
|
options.quality = resolveQuality(options);
|
|
@@ -192,7 +194,7 @@ export async function runCommand(command, shot, options, action, job = true) {
|
|
|
192
194
|
if (!options['no-lint']) { await checkLint(shot, events.options, events); linted = true; }
|
|
193
195
|
events.start();
|
|
194
196
|
const request = { schema: 'multiplane/1', jobId: events.jobId, jobDir: events.directory,
|
|
195
|
-
command, args: options._hostedCLI.args, cwd: process.cwd(), node: process.execPath,
|
|
197
|
+
command: options._resume ? 'resume' : command, args: options._hostedCLI.args, cwd: process.cwd(), node: process.execPath,
|
|
196
198
|
bin: options._hostedCLI.bin, env: hostedEnvironment(process.env), at: new Date().toISOString() };
|
|
197
199
|
const ownership = await handoff(request);
|
|
198
200
|
if (ownership.hosted) {
|
|
@@ -210,7 +212,7 @@ export async function runCommand(command, shot, options, action, job = true) {
|
|
|
210
212
|
resources = createResourceSampler({ enabled: options._resources !== false, onSample: events.memory });
|
|
211
213
|
events.resources = resources;
|
|
212
214
|
}
|
|
213
|
-
if (options.draft) {
|
|
215
|
+
if (options.draft && !options._effectiveOptions) {
|
|
214
216
|
// Keep lint and cancellation active before the dimension-only draft probe.
|
|
215
217
|
if (!linted && !options['no-lint']) {
|
|
216
218
|
const { findings, data } = await scanShot(shot, events.options);
|
package/src/hosted.mjs
CHANGED
|
@@ -18,7 +18,7 @@ async function move(from, to) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export async function hostedAvailable(command, options, env = process.env) {
|
|
21
|
-
if (!['render', 'stills', 'verify'].includes(command) || !options._hostedCLI || options.hosted !== true
|
|
21
|
+
if (!['render', 'stills', 'verify', 'resume'].includes(command) || !options._hostedCLI || options.hosted !== true
|
|
22
22
|
|| options['no-hosted'] || options['hosted-executor'] || env.MULTIPLANE_HOSTED === '0') return false;
|
|
23
23
|
try {
|
|
24
24
|
const app = await json(path.join(userHome(env), 'app.json'));
|
|
@@ -84,7 +84,7 @@ export async function handoff(request, { home = userHome(), timeout = 5000, star
|
|
|
84
84
|
// Claim before loading config, creating status, linting or opening output files.
|
|
85
85
|
export async function claimExecutor(jobDir, command) {
|
|
86
86
|
try {
|
|
87
|
-
if (!path.isAbsolute(jobDir) || !['render', 'stills', 'verify'].includes(command)) return false;
|
|
87
|
+
if (!path.isAbsolute(jobDir) || !['render', 'stills', 'verify', 'resume'].includes(command)) return false;
|
|
88
88
|
const jobId = path.basename(jobDir), base = hostedBase(jobId);
|
|
89
89
|
const request = await json(base + '.claimed.json');
|
|
90
90
|
if (request?.schema !== schema || request.jobId !== jobId || request.command !== command
|
package/src/index.mjs
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
5
|
+
|
|
6
|
+
// Servers inherit the render's context; unrelated inspect/serve calls do not.
|
|
7
|
+
export const inputRecords = new AsyncLocalStorage();
|
|
8
|
+
const largeFiles = new WeakMap();
|
|
9
|
+
export const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
export async function fileDigest(file) {
|
|
11
|
+
const hash = createHash('sha256');
|
|
12
|
+
for await (const bytes of createReadStream(file)) hash.update(bytes);
|
|
13
|
+
return hash.digest('hex');
|
|
14
|
+
}
|
|
15
|
+
export async function recordInput(file) {
|
|
16
|
+
const record = inputRecords.getStore();
|
|
17
|
+
if (!record) return;
|
|
18
|
+
const info = await stat(file);
|
|
19
|
+
// Large media stays streamed. Cache only its digest, never its body.
|
|
20
|
+
if (info.size > 1024 * 1024) {
|
|
21
|
+
let cache = largeFiles.get(record);
|
|
22
|
+
if (!cache) largeFiles.set(record, cache = new Map());
|
|
23
|
+
const previous = cache.get(file);
|
|
24
|
+
const sha256 = previous?.size === info.size && previous.mtimeMs === info.mtimeMs
|
|
25
|
+
? previous.sha256 : await fileDigest(file);
|
|
26
|
+
cache.set(file, { size: info.size, mtimeMs: info.mtimeMs, sha256 });
|
|
27
|
+
record(file, { bytes: info.size, sha256 });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const bytes = await readFile(file);
|
|
31
|
+
record(file, { bytes: bytes.length, sha256: digest(bytes) });
|
|
32
|
+
return bytes;
|
|
33
|
+
}
|
package/src/jobs.mjs
CHANGED
|
@@ -6,8 +6,9 @@ import { MultiplaneError, invalidOption } from './errors.mjs';
|
|
|
6
6
|
import { number } from './capture.mjs';
|
|
7
7
|
import { followJob } from './hosted.mjs';
|
|
8
8
|
import { loadUserConfig } from './user-home.mjs';
|
|
9
|
+
import { resumeStatus } from './segments.mjs';
|
|
9
10
|
|
|
10
|
-
const resolveJob = (target, options) => path.isAbsolute(target) || /[\\/]/.test(target) ? path.resolve(target) : path.join(jobDirectory(options), target);
|
|
11
|
+
export const resolveJob = (target, options) => path.isAbsolute(target) || /[\\/]/.test(target) ? path.resolve(target) : path.join(jobDirectory(options), target);
|
|
11
12
|
export async function readStatus(target, options = {}) {
|
|
12
13
|
let status;
|
|
13
14
|
try { status = JSON.parse(await readFile(path.join(resolveJob(target, options), 'status.json'), 'utf8')); }
|
|
@@ -25,6 +26,7 @@ export async function readStatus(target, options = {}) {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
}
|
|
29
|
+
if (status.command === 'render') status.resume = resumeStatus(status, resolveJob(target, options));
|
|
28
30
|
return status;
|
|
29
31
|
}
|
|
30
32
|
export const statusJob = (target, options = {}) => runCommand('status', null, options, events => readStatus(target, events.options), false);
|
package/src/post.mjs
CHANGED
|
@@ -329,11 +329,25 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
|
329
329
|
let png, result, usedSamples = motionBlur;
|
|
330
330
|
if (candidate && !verifyReuse) { ({ png, image: result, usedSamples } = previousRaw); }
|
|
331
331
|
else if (upward) {
|
|
332
|
+
// TODO(design): Half-size probes need a compatible decision policy;
|
|
333
|
+
// halving the raster and displacement threshold changes refinement.
|
|
332
334
|
// Calibrated in capture raster pixels: about 2px for fast/standard, 1px for high.
|
|
333
335
|
const threshold = resolveQuality(options) === 'high' ? 1 : 2;
|
|
334
|
-
|
|
336
|
+
// Stop as soon as motion is established. Retain exact full-size still
|
|
337
|
+
// checks, and reuse every matching time without changing the final grid.
|
|
338
|
+
await sampleMany([t, endpoints[0]], true);
|
|
335
339
|
const centerProbe = cache.get(t);
|
|
336
|
-
|
|
340
|
+
let still = cache.get(endpoints[0]).image.data.equals(centerProbe.image.data);
|
|
341
|
+
if (still) {
|
|
342
|
+
await sampleMany([endpoints[1]], true);
|
|
343
|
+
still = cache.get(endpoints[1]).image.data.equals(centerProbe.image.data);
|
|
344
|
+
} else if (capture._inspectSample) {
|
|
345
|
+
// Preserve conditional sampleContext reads at the skipped endpoint.
|
|
346
|
+
const start = performance.now();
|
|
347
|
+
await capture._inspectSample(endpoints[1], context(false, endpoints[1]));
|
|
348
|
+
stats.captureMs += performance.now() - start;
|
|
349
|
+
contextUsed ||= capture.sampleContextUsed === true;
|
|
350
|
+
}
|
|
337
351
|
let legacy = contextUsed;
|
|
338
352
|
let grid = legacy ? times : grids.get(motionBlur);
|
|
339
353
|
if (still) {
|
|
@@ -400,6 +414,8 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
|
|
|
400
414
|
if (!(candidate && !verifyReuse)) {
|
|
401
415
|
// Only lens-dependent probe captures need replacing. DOM captures and
|
|
402
416
|
// final hook captures remain reusable at the same time and context.
|
|
417
|
+
// TODO(design): Zero aperture still has interleaved projection jitter;
|
|
418
|
+
// reusing its probes requires an approved change to that pattern.
|
|
403
419
|
if (motionProbeUsed && !finalCapture) {
|
|
404
420
|
finalCapture = true;
|
|
405
421
|
dofMotionSamples = new Set(selectedTimes).size;
|