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 +2 -2
- package/README.md +2 -2
- package/package.json +1 -1
- package/skills/multiplane/SKILL.md +3 -1
- package/src/capture.mjs +5 -5
- package/src/config-lock.mjs +94 -0
- package/src/config.mjs +4 -11
- package/src/errors.mjs +1 -0
- package/src/hosted.mjs +17 -3
- package/src/input-records.mjs +9 -3
- package/src/post.mjs +34 -20
- package/src/server.mjs +9 -3
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
|
|
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
|
|
290
|
+
- **托管渲染**(默认关闭):在设置里开启后,agent 发起的渲染改由软件执行,不再挂在 agent 的会话上,会话结束也会照常做完。一次只执行一个托管渲染,其余排队。执行进程启动失败时,该作业会报告失败,后续队列可以继续,不会无限重试同一个作业。agent 的命令和输出不变;会话重启后用 `multiplane wait <job>` 接上之前的作业。开启托管后,关闭窗口时软件留在系统托盘。见协议第 11 节。
|
|
291
291
|
- **更新**:启动时和之后每天检查一次 GitHub 上的新版本(可以关闭)。Windows 上在软件里下载并安装;macOS 和 Linux 上打开下载页。“关于”页会按界面语言列出从已安装版本起每个版本的更新内容。
|
|
292
292
|
- 界面有英文和简体中文,默认跟随系统语言,也可以在设置里切换。
|
|
293
293
|
|
package/package.json
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
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);
|
package/src/input-records.mjs
CHANGED
|
@@ -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
|
-
|
|
33
|
+
remember({ bytes: info.size, sha256 });
|
|
28
34
|
return;
|
|
29
35
|
}
|
|
30
36
|
const bytes = await readFile(file);
|
|
31
|
-
|
|
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
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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)
|
|
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 = [
|
|
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
|
-
|
|
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) {
|