multiplane 0.3.13 → 0.3.16

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
@@ -133,7 +133,7 @@ For the full contract, patterns that break determinism, and 3D layer usage, see
133
133
 
134
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.
135
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.
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`). New checkpoints track both requested resource paths and their resolved files: retargeting a symlink (including a project-root alias) to different content also prevents resume. `multiplane status` reports whether a job can be resumed and how many frames are done. See section 6.1 of the protocol.
137
137
 
138
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
139
 
@@ -287,7 +287,7 @@ npm install three@0.180.0
287
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
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.
289
289
  - **Settings**: write the user config `config.json` described above.
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.
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. If an executor cannot start, that job reports a failure and the queue continues instead of retrying it indefinitely. 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.
291
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.
292
292
  - The interface is in English and Simplified Chinese. It follows the system language and can be switched in Settings.
293
293
 
package/README.md CHANGED
@@ -133,7 +133,7 @@ Shot.define({
133
133
 
134
134
  镜头路径相对资源根 `--root`(默认当前目录)。每次运行都有自己的作业目录 `out/jobs/<jobId>/`,不给 `--out` 时产物就写在里面(视频、`stills/`、`verify/`),多次运行互不覆盖;最终路径见结果里的 `artifacts`。`--out` 指定输出位置,目标已存在时会拒绝覆盖,加 `--overwrite` 才替换。`render`、`stills`、`verify` 启动前自动跑 `lint`,有 error 就停下,`--no-lint` 跳过。`npx multiplane <command> --help` 列出该命令的全部参数。
135
135
 
136
- `render` 按约 10 秒一段编码,每段编完就落盘。渲染中途停下(进程被杀、死机、断电、浏览器反复崩溃、手动停止)后,`multiplane resume <job>` 会跳过已经编好的段、只渲剩下的,最后不重新编码地拼成成片,画面与一次渲完的逐帧相同。续传沿用原作业的全部参数;镜头加载过的文件或音频文件改了,或 Multiplane、Chromium、FFmpeg 的版本、光栅模式、编码器变了,会拒绝续传(`resume_unavailable`,并说明是哪一项),以免拼出前后不一致的片子;只是显卡或驱动不同,则只给出警告(`resume_renderer_changed`)。`multiplane status` 会告诉你一个作业能不能续传、已经渲完多少帧。见协议第 6.1 节。
136
+ `render` 按约 10 秒一段编码,每段编完就落盘。渲染中途停下(进程被杀、死机、断电、浏览器反复崩溃、手动停止)后,`multiplane resume <job>` 会跳过已经编好的段、只渲剩下的,最后不重新编码地拼成成片,画面与一次渲完的逐帧相同。续传沿用原作业的全部参数;镜头加载过的文件或音频文件改了,或 Multiplane、Chromium、FFmpeg 的版本、光栅模式、编码器变了,会拒绝续传(`resume_unavailable`,并说明是哪一项),以免拼出前后不一致的片子;只是显卡或驱动不同,则只给出警告(`resume_renderer_changed`)。新的检查点同时记录资源的请求路径和实际文件;符号链接(包括项目根目录的别名)改为指向不同内容时,也会拒绝续传。`multiplane status` 会告诉你一个作业能不能续传、已经渲完多少帧。见协议第 6.1 节。
137
137
 
138
138
  浏览器卡住时,渲染也不会一直挂着:某个进程拿着帧、却在 `max(120 秒, 此前最慢一帧耗时的 10 倍)` 内一帧都交不出来时,会关掉它的浏览器、重新启动,并把那一帧从头重渲(像素相同),同时报 `worker_stalled` 警告。每一帧最多重试两次,第三次再卡住,作业以 `browser_crashed` 结束;已经编好的段会保留,之后可以用 `resume` 接着渲。
139
139
 
@@ -287,7 +287,7 @@ npm install three@0.180.0
287
287
  - **托盘弹窗**:在 Windows 和 macOS 上点托盘图标,会打开一个小面板,列出进行中、排队中和最近完成的作业;托盘图标和任务栏按钮会显示渲染进度。Linux 上仍是系统菜单。
288
288
  - **环境**:检查浏览器、GPU 光栅、FFmpeg 等是否就绪,首次使用时在这里点“安装浏览器”下载 Chromium;给 Claude Code(`~/.claude/skills/`)和 Codex(`~/.agents/skills/`)安装、更新 Multiplane 的 skill,自己改过的不会被直接覆盖。
289
289
  - **设置**:写入上文的用户配置 `config.json`。
290
- - **托管渲染**(默认关闭):在设置里开启后,agent 发起的渲染改由软件执行,不再挂在 agent 的会话上,会话结束也会照常做完。一次只执行一个托管渲染,其余排队。agent 的命令和输出不变;会话重启后用 `multiplane wait <job>` 接上之前的作业。开启托管后,关闭窗口时软件留在系统托盘。见协议第 11 节。
290
+ - **托管渲染**(默认关闭):在设置里开启后,agent 发起的渲染改由软件执行,不再挂在 agent 的会话上,会话结束也会照常做完。一次只执行一个托管渲染,其余排队。执行进程启动失败时,该作业会报告失败,后续队列可以继续,不会无限重试同一个作业。agent 的命令和输出不变;会话重启后用 `multiplane wait <job>` 接上之前的作业。开启托管后,关闭窗口时软件留在系统托盘。见协议第 11 节。
291
291
  - **更新**:启动时和之后每天检查一次 GitHub 上的新版本(可以关闭)。Windows 上在软件里下载并安装;macOS 和 Linux 上打开下载页。“关于”页会按界面语言列出从已安装版本起每个版本的更新内容。
292
292
  - 界面有英文和简体中文,默认跟随系统语言,也可以在设置里切换。
293
293
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multiplane",
3
- "version": "0.3.13",
3
+ "version": "0.3.16",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
@@ -73,7 +73,7 @@ Shot.define({
73
73
  | `fps` | No | Frame rate, default 30. |
74
74
  | `fonts` | No | Fonts to wait for: `[['48px MyFont', 'text used'], ...]`. |
75
75
  | `setup()` | No | One-time preparation (load images, create a 3D scene, etc.). May return a Promise; the renderer waits for it before starting. |
76
- | `cuts` | No | Array of hard cut times (seconds). Motion blur time samples do not cross these times. **Declare every hard cut in the image**, or frames on either side of the cut will blur together. |
76
+ | `cuts` | No | Array of hard cut times (seconds). Motion blur time samples do not cross these times. **Declare every hard cut in the image**, or frames on either side of the cut will blur together. This includes instant changes inside a scene: a state that snaps on or off (a pressed button, a panel that collapses), an effect or impulse that starts at full strength, a caption that swaps. Undeclared, the frame at that instant blends both states and is rendered with the maximum number of samples, which cannot make a jump any smoother. |
77
77
  | `post` | No | Post-processing defaults: `motionBlur` (time samples per frame; 1 disables it), `maxMotionBlur` (upper limit for adding samples where motion is fast; see below), `shutter` (shutter fraction of a frame; 0.5 means 180°), `grain` (film grain intensity; 0 disables it). Command-line options override these. |
78
78
  | `blurAt(t)` | No | Return the required number of time samples (a positive integer) at this time, using fewer samples for static sections and more for fast motion. |
79
79
  | `meta` | No | Any JSON data for tools that read the shot (beats, captions, notes), at most 1 MiB. Readable as `SHOT.meta`; returned by `inspect`; never affects pixels. |
@@ -181,6 +181,8 @@ FFmpeg is not bundled. It is found in this order: `--ffmpeg <path>`, the environ
181
181
 
182
182
  Motion blur quality: the shot decides whether there is motion blur (`post.motionBlur` above 1, the starting number of samples); `--quality` decides how many samples fast frames get. The default `standard` compares adjacent samples block by block and starts at `motionBlur` but at most 4 (an explicit `--motion-blur` still sets the start; a limit no larger than the start keeps the count fixed), and doubles the count wherever something moved more than about 2 pixels, up to 32; `fast` is the same with a limit of 16; `high` starts at `motionBlur`, uses about 1 pixel and goes up to 256. Static frames drop to 1 sample. Use the default `standard` for iterating and for the final render: on real shots `high` takes several times longer for a difference visible only when zoomed in. Use `fast` when the user asks for speed over quality (quick reviews of long shots, renders that are not the final deliverable); it renders sooner and very fast motion shows slightly more visible steps. Use `high` only when the user asks, for stills that will be viewed up close, or when very fast thin motion still strobes under `standard`. Do not raise `motionBlur` for the whole shot to fix strobing; use `high`, or `--max-motion-blur` for a custom limit. `result.data.blurSamples` (`{ min, max, mean, histogram }`) shows the counts used. A shot with its own `blurAt(t)` keeps full control of the count.
183
183
 
184
+ Things that change in discrete steps instead of moving (grain or noise offsets that jump every few frames, flicker, random glitch patterns, re-seeded effects) should take their time from `SHOT.sampleContext?.frameTime ?? t`, for example `Math.floor((SHOT.sampleContext?.frameTime ?? t) * 24)`. `frameTime` is the time of the output frame and is the same for every motion-blur sample of that frame, so the step stays crisp instead of blending two states, and it does not push the sample count up. Keep continuous motion (position, rotation, scale, fades) on `t`.
185
+
184
186
  When the user wants an image rather than a video (a cover, poster, thumbnail or illustration), write a shot and use `stills` for the final output: each PNG gets the same supersampling (`--scale 2`), motion blur, depth of field and `--alpha` as video. Pick a time during motion if the still should show motion blur. Hand over the `t-<seconds>.png` file from `result.data.stills`, not the contact sheet.
185
187
 
186
188
  Use `--draft` with `render` or `stills` while iterating: it captures at a scale of at most 0.5 and upscales, with no motion blur and no grain, so it is faster and visibly lower in quality. Options you pass explicitly override the preset. Render the final video without `--draft`; `result.data.draft` is `true` when a draft was produced, so do not hand a draft over as the final result.
package/src/capture.mjs CHANGED
@@ -40,7 +40,7 @@ async function openShotOnce(shot, scale = 1, options = {}) {
40
40
  hint: 'Files under node_modules are not served. Use --root to point to the package directory (for example, --root node_modules/multiplane).',
41
41
  });
42
42
  try { await access(entry); } catch { throw new MultiplaneError('shot_not_found'); }
43
- const server = await startServer(0, root);
43
+ const server = await startServer(0, inputRoot);
44
44
  let browser, page, crashed = false;
45
45
  try {
46
46
  browser = await launchBrowser(options);
@@ -120,9 +120,9 @@ async function openShotOnce(shot, scale = 1, options = {}) {
120
120
  if (alpha && cdp) await cdp.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } });
121
121
  const timings = { renderCallMs: 0, screenshotMs: 0 };
122
122
  let closing, sampleContextUsed = false, motionProbeUsed = false, dofSamples = 0;
123
- const draw = async (t, { motionBlur = 1, motionBlurBase = motionBlur, motionProbeTime, dofMotionSamples, dofMotionIndex } = {}) => {
123
+ const draw = async (t, { frameTime = t, motionBlur = 1, motionBlurBase = motionBlur, motionProbeTime, dofMotionSamples, dofMotionIndex } = {}) => {
124
124
  const renderStart = performance.now();
125
- const changed = await page.evaluate(({ t, motionBlur, motionBlurBase, motionProbeTime, dofMotionSamples, dofMotionIndex, alpha }) => {
125
+ const changed = await page.evaluate(({ t, frameTime, motionBlur, motionBlurBase, motionProbeTime, dofMotionSamples, dofMotionIndex, alpha }) => {
126
126
  globalThis.__multiplaneStatic?.suspend();
127
127
  if (alpha) document.documentElement.style.setProperty('--multiplane-page-background', 'transparent');
128
128
  const stage = document.querySelector('#stage');
@@ -136,7 +136,7 @@ async function openShotOnce(shot, scale = 1, options = {}) {
136
136
  const previous = SHOT.sampleContext;
137
137
  let contextUsed = false;
138
138
  // TODO(design): Document motionBlurBase, stable across nested refinements.
139
- SHOT.sampleContext = { time: t, motionBlurBase, get motionBlur() { contextUsed = true; return motionBlur; } };
139
+ SHOT.sampleContext = { time: t, frameTime, motionBlurBase, get motionBlur() { contextUsed = true; return motionBlur; } };
140
140
  let failure;
141
141
  // Private renderer/layer handshake; never part of the shot contract.
142
142
  const probe = globalThis.__multiplaneMotionProbe = { time: motionProbeTime, used: false, motionSamples: dofMotionSamples, motionIndex: dofMotionIndex, samples: 0 };
@@ -148,7 +148,7 @@ async function openShotOnce(shot, scale = 1, options = {}) {
148
148
  stage.getBoundingClientRect();
149
149
  const changed = globalThis.__multiplaneStatic?.read() ?? true;
150
150
  return { failure, contextUsed, probeUsed: probe.used, dofSamples: probe.samples, changed, reason: globalThis.__multiplaneStatic?.reason };
151
- }, { t, motionBlur, motionBlurBase, motionProbeTime, dofMotionSamples, dofMotionIndex, alpha }).then(({ failure, changed, contextUsed, probeUsed, dofSamples: count, reason }) => {
151
+ }, { t, frameTime, motionBlur, motionBlurBase, motionProbeTime, dofMotionSamples, dofMotionIndex, alpha }).then(({ failure, changed, contextUsed, probeUsed, dofSamples: count, reason }) => {
152
152
  sampleContextUsed = contextUsed;
153
153
  motionProbeUsed = probeUsed;
154
154
  dofSamples = count;
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { hostname } from 'node:os';
5
+ import { processCreatedAt } from './process-identity.mjs';
6
+ import { MultiplaneError } from './errors.mjs';
7
+
8
+ const schema = 'multiplane-config-lock/1';
9
+ const ownerName = /^[0-9a-f-]{36}\.json$/;
10
+
11
+ async function removeEmpty(lock) {
12
+ try { await fs.rmdir(lock); }
13
+ catch (error) {
14
+ // Never recursively remove a directory that may contain another owner.
15
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'ENOTDIR'].includes(error.code)) throw error;
16
+ }
17
+ }
18
+
19
+ async function release(lock, name) {
20
+ try { await fs.unlink(path.join(lock, name)); }
21
+ catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error.code)) return; throw error; }
22
+ await removeEmpty(lock);
23
+ }
24
+
25
+ async function recover(lock) {
26
+ try {
27
+ // Legacy empty files have no verifiable owner. Age alone is not proof
28
+ // that a writer died, so leave them (and unrecognized locks) untouched.
29
+ if (!(await fs.lstat(lock)).isDirectory()) return;
30
+ const names = await fs.readdir(lock);
31
+ // An empty or partial directory may belong to a writer initializing its
32
+ // owner record. Only the successful marker remover may remove emptiness.
33
+ if (names.length !== 1 || !ownerName.test(names[0])) return;
34
+ let owner;
35
+ try { owner = JSON.parse(await fs.readFile(path.join(lock, names[0]), 'utf8')); }
36
+ catch (error) { if (error instanceof SyntaxError) return; throw error; }
37
+ if (owner?.schema !== schema || owner.host !== hostname() || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) return;
38
+ let dead = false;
39
+ try { process.kill(owner.pid, 0); }
40
+ catch (error) { if (error.code !== 'ESRCH') return; dead = true; }
41
+ if (!dead && typeof owner.createdAt === 'string' && Number.isFinite(Date.parse(owner.createdAt))) {
42
+ const current = await processCreatedAt(owner.pid);
43
+ dead = current !== null && current !== owner.createdAt;
44
+ }
45
+ // The unique filename, not a stat/unlink comparison, protects a new
46
+ // owner from a delayed release or a second concurrent recovery.
47
+ if (dead) await release(lock, names[0]);
48
+ } catch (error) {
49
+ if (!['ENOENT', 'ENOTDIR'].includes(error.code)) throw error;
50
+ }
51
+ }
52
+
53
+ export async function lockConfiguration(file, { timeout = 10000 } = {}) {
54
+ const lock = file + '.lock', name = randomUUID() + '.json';
55
+ // Resolve identity before acquiring the directory, not while it is empty.
56
+ const owner = JSON.stringify({
57
+ schema, host: hostname(), pid: process.pid, createdAt: await processCreatedAt(process.pid),
58
+ }) + '\n';
59
+ const deadline = performance.now() + timeout;
60
+ for (;;) {
61
+ try {
62
+ // Unlike rename on Windows, mkdir never replaces a legacy lock file.
63
+ await fs.mkdir(lock);
64
+ break;
65
+ } catch (error) {
66
+ if (!['EEXIST', 'ENOTEMPTY', 'ENOTDIR', 'EISDIR', 'EPERM', 'EACCES'].includes(error.code)) throw error;
67
+ // The previous owner may release the lock before this inspection.
68
+ try { await fs.lstat(lock); }
69
+ catch (missing) {
70
+ if (missing.code !== 'ENOENT') throw missing;
71
+ if (performance.now() >= deadline) throw error;
72
+ await new Promise(resolve => setTimeout(resolve, 25));
73
+ continue;
74
+ }
75
+ if (performance.now() >= deadline) {
76
+ throw new MultiplaneError('config_locked', `Configuration is locked: ${lock}`, {
77
+ cause: error, details: { path: lock },
78
+ hint: 'Close other Multiplane processes and retry. If the lock remains, remove it only after confirming no process is changing settings.',
79
+ });
80
+ }
81
+ await recover(lock);
82
+ await new Promise(resolve => setTimeout(resolve, 25));
83
+ }
84
+ }
85
+ try { await fs.writeFile(path.join(lock, name), owner, { flag: 'wx' }); }
86
+ catch (error) {
87
+ // We acquired this directory and no contender can reclaim an unfinished
88
+ // owner record. Clean up only our own file and the now-empty directory.
89
+ await fs.rm(path.join(lock, name), { force: true });
90
+ await removeEmpty(lock);
91
+ throw error;
92
+ }
93
+ return () => release(lock, name);
94
+ }
package/src/config.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  import path from 'node:path';
2
2
  import { migrateUserHome } from './home-migration.mjs';
3
- import * as fs from 'node:fs/promises';
3
+ import fs from 'node:fs/promises';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { userHome } from './platform.mjs';
6
6
  import { MultiplaneError, invalidOption } from './errors.mjs';
7
7
  import { ffmpegOutput } from './ffmpeg.mjs';
8
+ import { lockConfiguration } from './config-lock.mjs';
8
9
 
9
10
  export const configKeys = ['ffmpeg', 'raster', 'encoder', 'workers', 'jobsDir', 'hosted'];
10
11
  export async function probeFfmpeg(file) {
@@ -56,15 +57,7 @@ export class Configuration {
56
57
  }
57
58
  this.prepare();
58
59
  await fs.mkdir(path.dirname(this.file), { recursive: true });
59
- const lock = this.file + '.lock', deadline = performance.now() + 10000;
60
- let handle;
61
- while (!handle) {
62
- try { handle = await fs.open(lock, 'wx'); }
63
- catch (error) {
64
- if (error.code !== 'EEXIST' || performance.now() >= deadline) throw error;
65
- await new Promise(resolve => setTimeout(resolve, 25));
66
- }
67
- }
60
+ const unlock = await lockConfiguration(this.file);
68
61
  const temporary = this.file + '.' + randomUUID() + '.tmp';
69
62
  let result;
70
63
  try {
@@ -72,7 +65,7 @@ export class Configuration {
72
65
  await fs.writeFile(temporary, JSON.stringify({ ...values, schema: 'multiplane/1', [key]: value }, null, 2) + '\n', { flag: 'wx' });
73
66
  await fs.rename(temporary, this.file);
74
67
  result = { ...await this.read(undefined, true), changed: key };
75
- } finally { await fs.unlink(temporary).catch(() => {}); await handle.close(); await fs.unlink(lock); }
68
+ } finally { await fs.unlink(temporary).catch(() => {}); await unlock(); }
76
69
  if (key === 'ffmpeg' && value !== null) result.check = await probeFfmpeg(value);
77
70
  return result;
78
71
  }
package/src/errors.mjs CHANGED
@@ -13,6 +13,7 @@ export const errorCodes = Object.freeze(Object.fromEntries([
13
13
  [3, 'wait_timeout', 'Timed out waiting for job completion.'],
14
14
  [2, 'job_not_found', 'Job status was not found or is invalid.'],
15
15
  [3, 'config_invalid', 'User configuration is not a JSON object.'],
16
+ [3, 'config_locked', 'User configuration is being changed by another process.'],
16
17
  [3, 'skill_modified', 'The installed skill has local modifications.'],
17
18
  [3, 'skill_blocked', 'The skill directory cannot be safely changed.'],
18
19
  [3, 'skill_changed', 'The installed skill changed since inspection.'],
package/src/hosted.mjs CHANGED
@@ -97,9 +97,10 @@ export const resultExitCode = result => result.ok ? 0 : errorCodes[result.code]?
97
97
 
98
98
  // Keep bytes intact across partial writes and UTF-8 boundaries. Only complete lines
99
99
  // are published, so timeout diagnostics cannot splice into a partial JSON record.
100
- export async function followJob(directory, { timeout = Infinity, onBytes, onEvent, interval = 100 } = {}) {
100
+ export async function followJob(directory, { timeout = Infinity, onBytes, onEvent, interval = 100, inspect = processCreatedAt } = {}) {
101
101
  const deadline = performance.now() + timeout * 1000;
102
102
  let offset = 0, pending = Buffer.alloc(0);
103
+ let identity = null;
103
104
  for (;;) {
104
105
  let handle;
105
106
  try {
@@ -125,8 +126,21 @@ export async function followJob(directory, { timeout = Infinity, onBytes, onEven
125
126
  || typeof status.state !== 'string') throw new MultiplaneError('job_not_found');
126
127
  // A result is committed to the event log before status; read the next poll.
127
128
  if (!status.result && status.state === 'running') {
128
- try { process.kill(status.pid, 0); }
129
- catch (error) { if (error.code === 'ESRCH') throw new MultiplaneError('internal_error', 'Job process exited without a result.'); }
129
+ let exited = false, present = false;
130
+ try { process.kill(status.pid, 0); present = true; }
131
+ catch (error) { exited = error.code === 'ESRCH'; }
132
+ if (present) {
133
+ if (!identity || identity.pid !== status.pid || identity.startedAt !== status.startedAt
134
+ || performance.now() >= identity.expiresAt) {
135
+ const createdAt = await Promise.resolve().then(() => inspect(status.pid)).catch(() => null);
136
+ // Cache failed queries too, avoiding a subprocess on every poll.
137
+ identity = { pid: status.pid, startedAt: status.startedAt, createdAt, expiresAt: performance.now() + 10000 };
138
+ }
139
+ const { createdAt } = identity;
140
+ // Allow two seconds for platform clock rounding; failed queries stay unknown.
141
+ exited = Date.parse(createdAt) - Date.parse(status.startedAt) > 2000;
142
+ }
143
+ if (exited) throw new MultiplaneError('internal_error', 'Job process exited without a result.');
130
144
  }
131
145
  if (performance.now() >= deadline) throw new MultiplaneError('wait_timeout');
132
146
  await delay(interval);
@@ -12,9 +12,15 @@ export async function fileDigest(file) {
12
12
  for await (const bytes of createReadStream(file)) hash.update(bytes);
13
13
  return hash.digest('hex');
14
14
  }
15
- export async function recordInput(file) {
15
+ export async function recordInput(file, requested = file) {
16
16
  const record = inputRecords.getStore();
17
17
  if (!record) return;
18
+ // Bind logical aliases to the bytes read from the canonical served target.
19
+ // Reading the alias again could race a retarget or reuse its stale media cache.
20
+ const remember = value => {
21
+ if (requested !== file) record(requested, value);
22
+ record(file, value);
23
+ };
18
24
  const info = await stat(file);
19
25
  // Large media stays streamed. Cache only its digest, never its body.
20
26
  if (info.size > 1024 * 1024) {
@@ -24,10 +30,10 @@ export async function recordInput(file) {
24
30
  const sha256 = previous?.size === info.size && previous.mtimeMs === info.mtimeMs
25
31
  ? previous.sha256 : await fileDigest(file);
26
32
  cache.set(file, { size: info.size, mtimeMs: info.mtimeMs, sha256 });
27
- record(file, { bytes: info.size, sha256 });
33
+ remember({ bytes: info.size, sha256 });
28
34
  return;
29
35
  }
30
36
  const bytes = await readFile(file);
31
- record(file, { bytes: bytes.length, sha256: digest(bytes) });
37
+ remember({ bytes: bytes.length, sha256: digest(bytes) });
32
38
  return bytes;
33
39
  }
package/src/post.mjs CHANGED
@@ -264,7 +264,7 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
264
264
  const changes = [];
265
265
  const observeStart = performance.now();
266
266
  for (const time of probes) {
267
- changes.push(await capture.observe(time, { motionBlur, motionBlurBase }));
267
+ changes.push(await capture.observe(time, { frameTime: t, motionBlur, motionBlurBase }));
268
268
  observedTime = time;
269
269
  if (changes.at(-1)) break;
270
270
  }
@@ -280,7 +280,7 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
280
280
  let finalCapture = hooked || motionBlur === 1;
281
281
  let selectedTimes = times;
282
282
  let dofMotionSamples = new Set(times).size;
283
- const context = (observed, time) => ({ motionBlur, motionBlurBase, observed,
283
+ const context = (observed, time) => ({ frameTime: t, motionBlur, motionBlurBase, observed,
284
284
  motionProbeTime: finalCapture ? undefined : t,
285
285
  dofMotionSamples: finalCapture ? dofMotionSamples : undefined,
286
286
  dofMotionIndex: finalCapture ? [...new Set(selectedTimes)].sort((a, b) => a - b).indexOf(time) : undefined });
@@ -333,40 +333,54 @@ export function createPostCapture(capture, options = {}, fps = capture.fps) {
333
333
  // halving the raster and displacement threshold changes refinement.
334
334
  // Calibrated in capture raster pixels: about 2px for fast/standard, 1px for high.
335
335
  const threshold = resolveQuality(options) === 'high' ? 1 : 2;
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);
339
- const centerProbe = cache.get(t);
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;
336
+ // Inspect the former probes without screenshots so conditional context
337
+ // reads keep selecting the compatible grid, including on still frames.
338
+ if (capture._inspectSample) {
339
+ for (const time of new Set([t, ...endpoints])) {
340
+ const start = performance.now();
341
+ await capture._inspectSample(time, context(false, time));
342
+ stats.captureMs += performance.now() - start;
343
+ contextUsed ||= capture.sampleContextUsed === true;
344
+ }
345
+ observedTime = undefined;
346
+ }
347
+ const initialGrid = grids.get(motionBlur);
348
+ const nearest = initialGrid.reduce((best, time) => Math.abs(time - t) < Math.abs(best - t) ? time : best);
349
+ let probeLegacy = contextUsed, still;
350
+ while (true) {
351
+ const probes = probeLegacy ? [t, endpoints[0], endpoints[1]] : [initialGrid[0], initialGrid.at(-1), nearest];
352
+ await sampleMany(probes.slice(0, 2), true);
353
+ still = cache.get(probes[0]).image.data.equals(cache.get(probes[1]).image.data);
354
+ if (still) {
355
+ await sampleMany([probes[2]], true);
356
+ still = cache.get(probes[0]).image.data.equals(cache.get(probes[2]).image.data);
357
+ }
358
+ if (!contextUsed || probeLegacy) break;
359
+ // A read at a new grid probe must also restart a potentially still frame.
360
+ probeLegacy = true; cache.clear(); observedTime = undefined;
350
361
  }
351
362
  let legacy = contextUsed;
352
363
  let grid = legacy ? times : grids.get(motionBlur);
353
364
  if (still) {
354
- png = centerProbe.png; result = centerProbe.image; usedSamples = 1; selectedTimes = [t];
365
+ const time = contextUsed ? t : nearest, probe = cache.get(time);
366
+ png = probe.png; result = probe.image; usedSamples = 1; selectedTimes = [time];
355
367
  stats.staticFrames++; stats.adaptiveFrames++;
356
368
  } else {
357
369
  if (legacy) cache.clear();
358
370
  while (true) {
359
- await sampleMany([...grid, t], true);
371
+ await sampleMany(legacy ? [...grid, t] : grid, true);
360
372
  // A conditional read discovered after the probes restarts the frame
361
373
  // on the old grids. No image from a different sample context survives.
362
374
  if (contextUsed && !legacy) {
363
375
  legacy = true; motionBlur = initialMotionBlur;
364
376
  cache.clear(); observedTime = undefined; grid = times; continue;
365
377
  }
366
- const images = grid.map(time => cache.get(time).image), center = cache.get(t);
378
+ const images = grid.map(time => cache.get(time).image);
379
+ const representative = legacy ? t : grid.reduce((best, time) => Math.abs(time - t) < Math.abs(best - t) ? time : best);
380
+ const center = cache.get(representative);
367
381
  const identical = images.every(image => image.data.equals(center.image.data));
368
382
  if (identical) {
369
- png = center.png; result = center.image; usedSamples = 1; selectedTimes = [t];
383
+ png = center.png; result = center.image; usedSamples = 1; selectedTimes = [representative];
370
384
  stats.staticFrames++; stats.adaptiveFrames++; break;
371
385
  }
372
386
  let difference = 0;
package/src/server.mjs CHANGED
@@ -16,6 +16,7 @@ export async function startServer(port = 0, root = process.cwd()) {
16
16
  if (typeof root !== 'string' || !root.trim()) throw invalidOption('--root', '--root must be a directory path.');
17
17
  if (!String(port).trim() || !Number.isInteger(Number(port)) || Number(port) < 0 || Number(port) > 65535) throw invalidOption('--port', '--port must be an integer from 0 to 65535.');
18
18
  port = Number(port);
19
+ const requestedRoot = path.resolve(root);
19
20
  const base = await normalizeResourcePath(root);
20
21
  try { if (!(await stat(base)).isDirectory()) throw new Error(); }
21
22
  catch { throw invalidOption('--root', '--root must be an existing directory.'); }
@@ -43,7 +44,7 @@ export async function startServer(port = 0, root = process.cwd()) {
43
44
 
44
45
  const segments = pathname.replaceAll('\\', '/').split('/');
45
46
  if (segments.some(p => p.startsWith('.') || p === 'node_modules')) { res.writeHead(403); return res.end(); }
46
- let resourceRoot = root, resourceBase = base, resourcePath = pathname;
47
+ let resourceRoot = root, resourceBase = base, requestedResourceRoot = requestedRoot, resourcePath = pathname;
47
48
  // /multiplane/ is reserved for runtime resources.
48
49
  if (pathname.startsWith('/multiplane/')) {
49
50
  if (pathname === '/multiplane/shots.json') {
@@ -58,6 +59,7 @@ export async function startServer(port = 0, root = process.cwd()) {
58
59
  resourcePath = pathname.slice('/multiplane/vendor/three'.length);
59
60
  if (!/^\/(build|examples\/jsm)\/.*\.js$/.test(resourcePath)) { res.writeHead(403); return res.end(); }
60
61
  } else { res.writeHead(404); return res.end(); }
62
+ requestedResourceRoot = resourceRoot;
61
63
  resourceBase = await normalizeResourcePath(resourceRoot);
62
64
  }
63
65
  let target = path.resolve(resourceRoot, `.${resourcePath}`);
@@ -68,11 +70,15 @@ export async function startServer(port = 0, root = process.cwd()) {
68
70
  if (pathname === '/favicon.ico' && error.code === 'ENOENT') { res.writeHead(204); return res.end(); }
69
71
  throw error;
70
72
  }
71
- if (targetInfo.isDirectory()) target = path.join(target, 'index.html');
73
+ let requestedTarget = path.resolve(requestedResourceRoot, `.${resourcePath}`);
74
+ if (targetInfo.isDirectory()) {
75
+ target = path.join(target, 'index.html');
76
+ requestedTarget = path.join(requestedTarget, 'index.html');
77
+ }
72
78
  target = await normalizeResourcePath(target);
73
79
  if (!await isInsideResourceRoot(resourceBase, target)) { res.writeHead(403); return res.end(); }
74
80
  const info = await stat(target);
75
- const recorded = await recordInput(target);
81
+ const recorded = await recordInput(target, requestedTarget);
76
82
  const contentType = { '.wav': 'audio/wav', '.srt': 'text/plain; charset=utf-8', '.mp4': 'video/mp4' }[path.extname(target)] ?? mime[path.extname(target)] ?? 'application/octet-stream';
77
83
  const headers = { 'Content-Type': contentType, 'Content-Length': info.size, 'Accept-Ranges': 'bytes' };
78
84
  if (req.headers.range) {