multiplane 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +202 -202
  2. package/README.en.md +3 -0
  3. package/README.md +3 -0
  4. package/THIRD_PARTY_NOTICES.md +30 -30
  5. package/bin/multiplane +24 -14
  6. package/examples/alpha.html +18 -18
  7. package/examples/animation.html +17 -17
  8. package/examples/assets/OFL.txt +93 -93
  9. package/examples/assets/card.svg +6 -6
  10. package/examples/three-cards.html +17 -17
  11. package/examples/typography.html +17 -17
  12. package/licenses/GPL-3.0.txt +232 -232
  13. package/licenses/LGPL-3.0.txt +304 -304
  14. package/licenses/acorn-LICENSE +21 -21
  15. package/licenses/colour-LICENSE.md +82 -82
  16. package/licenses/detect-libc-LICENSE +201 -201
  17. package/licenses/entities-LICENSE +11 -11
  18. package/licenses/nanoid-LICENSE +20 -20
  19. package/licenses/parse5-LICENSE +19 -19
  20. package/licenses/picocolors-LICENSE +15 -15
  21. package/licenses/playwright-NOTICE +5 -5
  22. package/licenses/playwright-ThirdPartyNotices.txt +14 -14
  23. package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
  24. package/licenses/postcss-LICENSE +20 -20
  25. package/licenses/postcss-value-parser-LICENSE +22 -22
  26. package/licenses/semver-LICENSE +15 -15
  27. package/licenses/sharp-LICENSE +191 -191
  28. package/licenses/sharp-win32-x64-NOTICES.md +45 -45
  29. package/licenses/source-map-js-LICENSE +28 -28
  30. package/licenses/three-LICENSE +21 -21
  31. package/package.json +92 -92
  32. package/skills/multiplane/SKILL.md +3 -0
  33. package/src/alpha.mjs +69 -69
  34. package/src/artifacts.mjs +61 -61
  35. package/src/browser-recovery.mjs +26 -26
  36. package/src/capture-watchdog.mjs +29 -0
  37. package/src/capture.mjs +34 -18
  38. package/src/encoder.mjs +16 -16
  39. package/src/environment.mjs +56 -56
  40. package/src/errors.mjs +1 -0
  41. package/src/events.mjs +12 -10
  42. package/src/ffmpeg.mjs +32 -32
  43. package/src/format.mjs +27 -27
  44. package/src/frame-rate.mjs +28 -28
  45. package/src/home-migration.mjs +54 -54
  46. package/src/hosted.mjs +3 -3
  47. package/src/index.mjs +1 -0
  48. package/src/input-records.mjs +33 -0
  49. package/src/jobs.mjs +3 -1
  50. package/src/lint.mjs +248 -248
  51. package/src/output-alpha.mjs +36 -36
  52. package/src/output-check.mjs +110 -110
  53. package/src/platform.mjs +114 -114
  54. package/src/png-native.mjs +12 -12
  55. package/src/png.mjs +74 -74
  56. package/src/post.mjs +56 -39
  57. package/src/preview.mjs +23 -23
  58. package/src/process.mjs +9 -9
  59. package/src/render-pool.mjs +41 -14
  60. package/src/render-worker.mjs +14 -3
  61. package/src/render.mjs +148 -52
  62. package/src/resource-path.mjs +29 -29
  63. package/src/resources.mjs +27 -27
  64. package/src/resume.mjs +23 -0
  65. package/src/runtime/camera.js +37 -37
  66. package/src/runtime/shot.css +9 -9
  67. package/src/segment-encoder.mjs +91 -0
  68. package/src/segments.mjs +123 -0
  69. package/src/server.mjs +11 -7
  70. package/src/setup.mjs +51 -51
  71. package/src/skills.mjs +128 -128
  72. package/src/stills.mjs +1 -1
  73. package/src/three/index.js +39 -31
  74. package/src/verify.mjs +2 -2
  75. package/src/video-pipe.mjs +6 -6
  76. package/tools/bench/shots/canvas-2d.html +1 -1
  77. package/tools/bench/shots/dom-light.html +1 -1
  78. package/tools/bench/shots/scene.js +44 -44
  79. package/tools/bench/shots/webgl.html +1 -1
  80. package/tools/install-browser.mjs +18 -18
package/package.json CHANGED
@@ -1,92 +1,92 @@
1
- {
2
- "name": "multiplane",
3
- "version": "0.3.6",
4
- "type": "module",
5
- "license": "Apache-2.0",
6
- "publishConfig": {
7
- "registry": "https://registry.npmjs.org/",
8
- "access": "public"
9
- },
10
- "engines": {
11
- "node": ">=24"
12
- },
13
- "exports": {
14
- ".": "./src/index.mjs",
15
- "./three": "./src/three/index.js",
16
- "./runtime/*": "./src/runtime/*"
17
- },
18
- "bin": {
19
- "multiplane": "bin/multiplane"
20
- },
21
- "files": [
22
- "src/",
23
- "bin/",
24
- "examples/",
25
- "skills/",
26
- "tools/install-browser.mjs",
27
- "tools/bench/shots/dom-light.html",
28
- "tools/bench/shots/canvas-2d.html",
29
- "tools/bench/shots/webgl.html",
30
- "tools/bench/shots/scene.js",
31
- "LICENSE",
32
- "THIRD_PARTY_NOTICES.md",
33
- "licenses/",
34
- "README.md",
35
- "README.en.md"
36
- ],
37
- "scripts": {
38
- "postinstall": "node tools/install-browser.mjs",
39
- "test": "node --import ./tests/test-home.mjs tools/test-suite.mjs",
40
- "export:public": "node tools/export-public.mjs",
41
- "release:check": "node tools/release-check.mjs",
42
- "bench": "node tools/bench/run.mjs",
43
- "test:full": "node --import ./tests/test-home.mjs tools/test-suite.mjs --full"
44
- },
45
- "dependencies": {
46
- "acorn": "8.15.0",
47
- "parse5": "7.3.0",
48
- "playwright": "1.63.0",
49
- "postcss": "8.5.28",
50
- "postcss-value-parser": "4.2.0",
51
- "sharp": "0.35.5"
52
- },
53
- "peerDependencies": {
54
- "three": "0.180.0"
55
- },
56
- "peerDependenciesMeta": {
57
- "three": {
58
- "optional": true
59
- }
60
- },
61
- "devDependencies": {
62
- "three": "0.180.0"
63
- },
64
- "overrides": {
65
- "postcss": {
66
- "nanoid": "3.3.19"
67
- }
68
- },
69
- "description": "Local headless HTML-to-video renderer for AI agents: shots are plain web pages driven by render(t), with determinism checks, cut-aware motion blur and a 3D layer with accumulated depth of field.",
70
- "keywords": [
71
- "video",
72
- "renderer",
73
- "html",
74
- "headless",
75
- "chromium",
76
- "playwright",
77
- "ffmpeg",
78
- "ai-agent",
79
- "deterministic",
80
- "motion-blur",
81
- "depth-of-field",
82
- "three.js"
83
- ],
84
- "repository": {
85
- "type": "git",
86
- "url": "git+https://github.com/LBEILC/Multiplane.git"
87
- },
88
- "homepage": "https://github.com/LBEILC/Multiplane#readme",
89
- "bugs": {
90
- "url": "https://github.com/LBEILC/Multiplane/issues"
91
- }
92
- }
1
+ {
2
+ "name": "multiplane",
3
+ "version": "0.3.8",
4
+ "type": "module",
5
+ "license": "Apache-2.0",
6
+ "publishConfig": {
7
+ "registry": "https://registry.npmjs.org/",
8
+ "access": "public"
9
+ },
10
+ "engines": {
11
+ "node": ">=24"
12
+ },
13
+ "exports": {
14
+ ".": "./src/index.mjs",
15
+ "./three": "./src/three/index.js",
16
+ "./runtime/*": "./src/runtime/*"
17
+ },
18
+ "bin": {
19
+ "multiplane": "bin/multiplane"
20
+ },
21
+ "files": [
22
+ "src/",
23
+ "bin/",
24
+ "examples/",
25
+ "skills/",
26
+ "tools/install-browser.mjs",
27
+ "tools/bench/shots/dom-light.html",
28
+ "tools/bench/shots/canvas-2d.html",
29
+ "tools/bench/shots/webgl.html",
30
+ "tools/bench/shots/scene.js",
31
+ "LICENSE",
32
+ "THIRD_PARTY_NOTICES.md",
33
+ "licenses/",
34
+ "README.md",
35
+ "README.en.md"
36
+ ],
37
+ "scripts": {
38
+ "postinstall": "node tools/install-browser.mjs",
39
+ "test": "node --import ./tests/test-home.mjs tools/test-suite.mjs",
40
+ "export:public": "node tools/export-public.mjs",
41
+ "release:check": "node tools/release-check.mjs",
42
+ "bench": "node tools/bench/run.mjs",
43
+ "test:full": "node --import ./tests/test-home.mjs tools/test-suite.mjs --full"
44
+ },
45
+ "dependencies": {
46
+ "acorn": "8.15.0",
47
+ "parse5": "7.3.0",
48
+ "playwright": "1.63.0",
49
+ "postcss": "8.5.28",
50
+ "postcss-value-parser": "4.2.0",
51
+ "sharp": "0.35.5"
52
+ },
53
+ "peerDependencies": {
54
+ "three": "0.180.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "three": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "three": "0.180.0"
63
+ },
64
+ "overrides": {
65
+ "postcss": {
66
+ "nanoid": "3.3.19"
67
+ }
68
+ },
69
+ "description": "Local headless HTML-to-video renderer for AI agents: shots are plain web pages driven by render(t), with determinism checks, cut-aware motion blur and a 3D layer with accumulated depth of field.",
70
+ "keywords": [
71
+ "video",
72
+ "renderer",
73
+ "html",
74
+ "headless",
75
+ "chromium",
76
+ "playwright",
77
+ "ffmpeg",
78
+ "ai-agent",
79
+ "deterministic",
80
+ "motion-blur",
81
+ "depth-of-field",
82
+ "three.js"
83
+ ],
84
+ "repository": {
85
+ "type": "git",
86
+ "url": "git+https://github.com/LBEILC/Multiplane.git"
87
+ },
88
+ "homepage": "https://github.com/LBEILC/Multiplane#readme",
89
+ "bugs": {
90
+ "url": "https://github.com/LBEILC/Multiplane/issues"
91
+ }
92
+ }
@@ -171,6 +171,7 @@ Use `--json` with any command. Resolve shot paths relative to `--root` (the curr
171
171
  | `multiplane status <job>` | View job status | — |
172
172
  | `multiplane cancel <job>` | Stop a job | `--wait` (seconds) |
173
173
  | `multiplane wait <job>` | Wait for a job started earlier and print its events | `--timeout` (seconds) |
174
+ | `multiplane resume <job>` | Continue an interrupted render; already encoded segments are reused | `--workers` |
174
175
 
175
176
  Use `--out`, `--overwrite`, and `--no-lint` (skip automatic lint) with `stills`, `verify`, or `render`. Run `multiplane <command> --help` to list all options for that command.
176
177
 
@@ -213,3 +214,5 @@ With `--json`, stdout contains one JSON event per line. **The last line is alway
213
214
  Rendering may take a long time. Each run creates a job directory, `out/jobs/<jobId>/` (`jobId` appears in both the `start` event and the `result`). Read `status.json` for the latest progress snapshot and `events.jsonl` for the full event stream. To stop a job, run `multiplane cancel <jobId>` or create a file named `stop` in its job directory.
214
215
 
215
216
  If the user turned on hosting in the desktop app, the app runs the render and it keeps going after your session ends. Nothing changes in how you call the command; stderr says the job was handed over, and `status.json` may show `queued` while another hosted job runs. When a session resumes and you started a job earlier but never saw its `result`, run `multiplane wait <jobId> --json` to get its events and exit code instead of starting it again. Use `--no-hosted` only when the user asks for a render that should not go through the app.
217
+
218
+ A `render` that ended without succeeding (`failed` or `cancelled`, or a job whose process disappeared, for example after a crash or reboot) can usually be continued instead of rendered again: run `multiplane resume <jobId> --json`. It reuses the segments already encoded (about 10 seconds each), renders the rest with the original options and output path, and produces the same frames as an uninterrupted render. It is a new job; its `result` carries `resumedFrom` and `resume.framesReused`. `multiplane status <jobId> --json` shows `data.resume.available` and how many frames are done. If it fails with `resume_unavailable`, read `details.reason`: `inputs_changed` or `environment_changed` means start a fresh `render` (do this deliberately, for example after you edited the shot); `running` means the job, or another resume, is still going, so `wait` for it; `missing_manifest` means the job predates segments. Only `--workers` can change on resume. A `resume_renderer_changed` warning (a different GPU or driver) needs no action.
package/src/alpha.mjs CHANGED
@@ -1,69 +1,69 @@
1
- // All weighted operations use linear-light, premultiplied RGBA.
2
- const linear = Float64Array.from({ length: 256 }, (_, i) => {
3
- const x = i / 255;
4
- return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
5
- });
6
- const clamp = x => Math.max(0, Math.min(1, x));
7
- const srgb = x => Math.round(255 * (x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055));
8
- function premultiply(image) {
9
- const data = new Float64Array(image.data.length);
10
- for (let p = 0; p < data.length; p += 4) {
11
- const a = image.data[p + 3] / 255;
12
- for (let c = 0; c < 3; c++) data[p + c] = linear[image.data[p + c]] * a;
13
- data[p + 3] = a;
14
- }
15
- return data;
16
- }
17
- function finish(data, width, height) {
18
- const bytes = Buffer.alloc(data.length);
19
- for (let p = 0; p < data.length; p += 4) {
20
- const a = data[p + 3];
21
- bytes[p + 3] = Math.round(clamp(a) * 255);
22
- // Unpremultiply with the unquantized, unclamped weight, including ringing.
23
- if (bytes[p + 3]) for (let c = 0; c < 3; c++) bytes[p + c] = srgb(clamp(data[p + c] / a));
24
- }
25
- return { width, height, data: bytes };
26
- }
27
- export function averageAlpha(images) {
28
- const data = new Float64Array(images[0].data.length);
29
- for (const image of images) {
30
- for (let p = 0; p < data.length; p += 4) {
31
- const a = image.data[p + 3] / 255;
32
- for (let c = 0; c < 3; c++) data[p + c] += linear[image.data[p + c]] * a / images.length;
33
- data[p + 3] += a / images.length;
34
- }
35
- }
36
- return finish(data, images[0].width, images[0].height);
37
- }
38
- export function clearHiddenColors(image) {
39
- const data = Buffer.from(image.data);
40
- for (let p = 0; p < data.length; p += 4) if (!data[p + 3]) data.fill(0, p, p + 3);
41
- return { ...image, data };
42
- }
43
- const sinc = x => x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
44
- function weights(input, output, kernel) {
45
- const ratio = input / output, support = Math.max(1, ratio);
46
- return Array.from({ length: output }, (_, i) => {
47
- const center = (i + 0.5) * ratio - 0.5;
48
- if (kernel === 'neighbor') return [[Math.max(0, Math.min(input - 1, Math.round(center))), 1]];
49
- const result = []; let total = 0;
50
- for (let j = Math.ceil(center - 3 * support); j <= Math.floor(center + 3 * support); j++) {
51
- const distance = (j - center) / support;
52
- const weight = Math.abs(distance) < 3 ? sinc(distance) * sinc(distance / 3) : 0;
53
- result.push([Math.max(0, Math.min(input - 1, j)), weight]); total += weight;
54
- }
55
- return result.map(([j, weight]) => [j, weight / total]);
56
- });
57
- }
58
- export function resizeAlpha(image, width, height, kernel = 'lanczos') {
59
- const input = premultiply(image), horizontal = new Float64Array(width * image.height * 4);
60
- const output = new Float64Array(width * height * 4);
61
- const wx = weights(image.width, width, kernel), wy = weights(image.height, height, kernel);
62
- for (let y = 0; y < image.height; y++) for (let x = 0; x < width; x++) {
63
- for (const [source, weight] of wx[x]) for (let c = 0; c < 4; c++) horizontal[(y * width + x) * 4 + c] += input[(y * image.width + source) * 4 + c] * weight;
64
- }
65
- for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
66
- for (const [source, weight] of wy[y]) for (let c = 0; c < 4; c++) output[(y * width + x) * 4 + c] += horizontal[(source * width + x) * 4 + c] * weight;
67
- }
68
- return finish(output, width, height);
69
- }
1
+ // All weighted operations use linear-light, premultiplied RGBA.
2
+ const linear = Float64Array.from({ length: 256 }, (_, i) => {
3
+ const x = i / 255;
4
+ return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
5
+ });
6
+ const clamp = x => Math.max(0, Math.min(1, x));
7
+ const srgb = x => Math.round(255 * (x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055));
8
+ function premultiply(image) {
9
+ const data = new Float64Array(image.data.length);
10
+ for (let p = 0; p < data.length; p += 4) {
11
+ const a = image.data[p + 3] / 255;
12
+ for (let c = 0; c < 3; c++) data[p + c] = linear[image.data[p + c]] * a;
13
+ data[p + 3] = a;
14
+ }
15
+ return data;
16
+ }
17
+ function finish(data, width, height) {
18
+ const bytes = Buffer.alloc(data.length);
19
+ for (let p = 0; p < data.length; p += 4) {
20
+ const a = data[p + 3];
21
+ bytes[p + 3] = Math.round(clamp(a) * 255);
22
+ // Unpremultiply with the unquantized, unclamped weight, including ringing.
23
+ if (bytes[p + 3]) for (let c = 0; c < 3; c++) bytes[p + c] = srgb(clamp(data[p + c] / a));
24
+ }
25
+ return { width, height, data: bytes };
26
+ }
27
+ export function averageAlpha(images) {
28
+ const data = new Float64Array(images[0].data.length);
29
+ for (const image of images) {
30
+ for (let p = 0; p < data.length; p += 4) {
31
+ const a = image.data[p + 3] / 255;
32
+ for (let c = 0; c < 3; c++) data[p + c] += linear[image.data[p + c]] * a / images.length;
33
+ data[p + 3] += a / images.length;
34
+ }
35
+ }
36
+ return finish(data, images[0].width, images[0].height);
37
+ }
38
+ export function clearHiddenColors(image) {
39
+ const data = Buffer.from(image.data);
40
+ for (let p = 0; p < data.length; p += 4) if (!data[p + 3]) data.fill(0, p, p + 3);
41
+ return { ...image, data };
42
+ }
43
+ const sinc = x => x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
44
+ function weights(input, output, kernel) {
45
+ const ratio = input / output, support = Math.max(1, ratio);
46
+ return Array.from({ length: output }, (_, i) => {
47
+ const center = (i + 0.5) * ratio - 0.5;
48
+ if (kernel === 'neighbor') return [[Math.max(0, Math.min(input - 1, Math.round(center))), 1]];
49
+ const result = []; let total = 0;
50
+ for (let j = Math.ceil(center - 3 * support); j <= Math.floor(center + 3 * support); j++) {
51
+ const distance = (j - center) / support;
52
+ const weight = Math.abs(distance) < 3 ? sinc(distance) * sinc(distance / 3) : 0;
53
+ result.push([Math.max(0, Math.min(input - 1, j)), weight]); total += weight;
54
+ }
55
+ return result.map(([j, weight]) => [j, weight / total]);
56
+ });
57
+ }
58
+ export function resizeAlpha(image, width, height, kernel = 'lanczos') {
59
+ const input = premultiply(image), horizontal = new Float64Array(width * image.height * 4);
60
+ const output = new Float64Array(width * height * 4);
61
+ const wx = weights(image.width, width, kernel), wy = weights(image.height, height, kernel);
62
+ for (let y = 0; y < image.height; y++) for (let x = 0; x < width; x++) {
63
+ for (const [source, weight] of wx[x]) for (let c = 0; c < 4; c++) horizontal[(y * width + x) * 4 + c] += input[(y * image.width + source) * 4 + c] * weight;
64
+ }
65
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
66
+ for (const [source, weight] of wy[y]) for (let c = 0; c < 4; c++) output[(y * width + x) * 4 + c] += horizontal[(source * width + x) * 4 + c] * weight;
67
+ }
68
+ return finish(output, width, height);
69
+ }
package/src/artifacts.mjs CHANGED
@@ -1,61 +1,61 @@
1
- import { constants } from 'node:fs';
2
- import { copyFile, rm, writeFile, access, rename, mkdtemp, mkdir } from 'node:fs/promises';
3
- import path from 'node:path';
4
- import { MultiplaneError } from './errors.mjs';
5
-
6
- const outputError = (error, file) => {
7
- if (error.code === 'EEXIST') throw new MultiplaneError('output_exists', undefined, { details: { path: file }, hint: 'Choose a new output path.' });
8
- throw error;
9
- };
10
- export async function requireNewOutput(file) {
11
- try { await access(file); }
12
- catch (error) { if (error.code === 'ENOENT') return; throw error; }
13
- throw new MultiplaneError('output_exists', undefined, { details: { path: file }, hint: 'Choose a new output path.' });
14
- }
15
- export async function writeArtifact(file, bytes) {
16
- try { await writeFile(file, bytes, { flag: 'wx' }); }
17
- catch (error) { outputError(error, file); }
18
- }
19
- export async function publishVideo(temporary, file, overwrite = false) {
20
- if (overwrite) { await rename(temporary, file); return; }
21
- try { await copyFile(temporary, file, constants.COPYFILE_EXCL); }
22
- catch (error) { outputError(error, file); }
23
- await rm(temporary);
24
- }
25
-
26
- // Build a replacement directory in isolation; publish only after the whole command succeeds.
27
- export async function directoryOutput(events, action) {
28
- const target = events.options.out;
29
- await mkdir(path.dirname(target), { recursive: true });
30
- const temporary = await mkdtemp(target + '.partial-');
31
- const backup = temporary + '.previous';
32
- const artifacts = [], artifact = events.artifact;
33
- const relocate = value => {
34
- if (typeof value === 'string' && value.startsWith(temporary + path.sep)) return target + value.slice(temporary.length);
35
- if (Array.isArray(value)) return value.map(relocate);
36
- if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, relocate(v)]));
37
- return value;
38
- };
39
- events.options.out = temporary;
40
- events.artifact = (file, kind) => artifacts.push([file, kind]);
41
- let saved = false, committed = false, keepTemporary = false;
42
- try {
43
- const data = await action(events);
44
- events.check();
45
- try { await rename(target, backup); saved = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
46
- try { await rename(temporary, target); }
47
- catch (error) { const restore = saved; saved = false; if (restore) await rename(backup, target); throw error; }
48
- committed = true;
49
- for (const [file, kind] of artifacts) artifact(relocate(file), kind);
50
- return relocate(data);
51
- } catch (error) {
52
- // Keep completed diagnostic artifacts addressable when verification fails.
53
- keepTemporary = artifacts.length > 0;
54
- if (keepTemporary && !committed) for (const [file, kind] of artifacts) artifact(file, kind);
55
- throw error;
56
- } finally {
57
- events.options.out = target; events.artifact = artifact;
58
- if (!keepTemporary) await rm(temporary, { recursive: true, force: true });
59
- if (saved && committed) await rm(backup, { recursive: true, force: true });
60
- }
61
- }
1
+ import { constants } from 'node:fs';
2
+ import { copyFile, rm, writeFile, access, rename, mkdtemp, mkdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { MultiplaneError } from './errors.mjs';
5
+
6
+ const outputError = (error, file) => {
7
+ if (error.code === 'EEXIST') throw new MultiplaneError('output_exists', undefined, { details: { path: file }, hint: 'Choose a new output path.' });
8
+ throw error;
9
+ };
10
+ export async function requireNewOutput(file) {
11
+ try { await access(file); }
12
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
13
+ throw new MultiplaneError('output_exists', undefined, { details: { path: file }, hint: 'Choose a new output path.' });
14
+ }
15
+ export async function writeArtifact(file, bytes) {
16
+ try { await writeFile(file, bytes, { flag: 'wx' }); }
17
+ catch (error) { outputError(error, file); }
18
+ }
19
+ export async function publishVideo(temporary, file, overwrite = false) {
20
+ if (overwrite) { await rename(temporary, file); return; }
21
+ try { await copyFile(temporary, file, constants.COPYFILE_EXCL); }
22
+ catch (error) { outputError(error, file); }
23
+ await rm(temporary);
24
+ }
25
+
26
+ // Build a replacement directory in isolation; publish only after the whole command succeeds.
27
+ export async function directoryOutput(events, action) {
28
+ const target = events.options.out;
29
+ await mkdir(path.dirname(target), { recursive: true });
30
+ const temporary = await mkdtemp(target + '.partial-');
31
+ const backup = temporary + '.previous';
32
+ const artifacts = [], artifact = events.artifact;
33
+ const relocate = value => {
34
+ if (typeof value === 'string' && value.startsWith(temporary + path.sep)) return target + value.slice(temporary.length);
35
+ if (Array.isArray(value)) return value.map(relocate);
36
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, relocate(v)]));
37
+ return value;
38
+ };
39
+ events.options.out = temporary;
40
+ events.artifact = (file, kind) => artifacts.push([file, kind]);
41
+ let saved = false, committed = false, keepTemporary = false;
42
+ try {
43
+ const data = await action(events);
44
+ events.check();
45
+ try { await rename(target, backup); saved = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
46
+ try { await rename(temporary, target); }
47
+ catch (error) { const restore = saved; saved = false; if (restore) await rename(backup, target); throw error; }
48
+ committed = true;
49
+ for (const [file, kind] of artifacts) artifact(relocate(file), kind);
50
+ return relocate(data);
51
+ } catch (error) {
52
+ // Keep completed diagnostic artifacts addressable when verification fails.
53
+ keepTemporary = artifacts.length > 0;
54
+ if (keepTemporary && !committed) for (const [file, kind] of artifacts) artifact(file, kind);
55
+ throw error;
56
+ } finally {
57
+ events.options.out = target; events.artifact = artifact;
58
+ if (!keepTemporary) await rm(temporary, { recursive: true, force: true });
59
+ if (saved && committed) await rm(backup, { recursive: true, force: true });
60
+ }
61
+ }
@@ -1,26 +1,26 @@
1
- import { MultiplaneError } from './errors.mjs';
2
-
3
- export const MAX_BROWSER_RESTARTS = 2;
4
- export const recoveryBudget = () => new Int32Array(new SharedArrayBuffer(4));
5
-
6
- // Playwright includes bounded browser logs on transport errors. Never retain the
7
- // launch command (which also contains local profile paths) in public diagnostics.
8
- export function crashDetails(error, t) {
9
- const messages = [], seen = new Set();
10
- for (let e = error; e && !seen.has(e); e = e.cause) { seen.add(e); messages.push(e.message ?? String(e)); }
11
- const log = messages.join('\n');
12
- const exit = log.match(/process did exit: exitCode=([^,\s]+), signal=([^\s>]+)/);
13
- const stderr = log.split(/\r?\n/).filter(line => /\[err\]/.test(line) && !line.includes('<launching>')).slice(-20).join('\n').slice(-4000);
14
- return { ...(t === undefined ? {} : { t }), reason: (messages.at(-1) ?? 'Browser closed unexpectedly').split(/\r?\n/)[0].slice(0, 500),
15
- ...(exit ? { exitCode: exit[1] === 'null' ? null : Number(exit[1]), signal: exit[2] === 'null' ? null : exit[2] } : {}),
16
- ...(stderr ? { stderr } : {}) };
17
- }
18
-
19
- export function browserClosed(error, browser, page, crashed = false) {
20
- if (error?.code === 'render_threw' || error?.code === 'cancelled') return false;
21
- return crashed || page?.isClosed() || (browser && !browser.isConnected())
22
- || /Target (?:page, context or browser|closed)|(?:Page|Browser|Connection|Session) closed|Page crashed|Target crashed|browser has disconnected/i.test(error?.message ?? '')
23
- || (error?.cause && browserClosed(error.cause, browser, page, crashed));
24
- }
25
-
26
- export const crashedError = (error, t) => new MultiplaneError('browser_crashed', undefined, { cause: error, details: crashDetails(error, t) });
1
+ import { MultiplaneError } from './errors.mjs';
2
+
3
+ export const MAX_BROWSER_RESTARTS = 2;
4
+ export const recoveryBudget = () => new Int32Array(new SharedArrayBuffer(4));
5
+
6
+ // Playwright includes bounded browser logs on transport errors. Never retain the
7
+ // launch command (which also contains local profile paths) in public diagnostics.
8
+ export function crashDetails(error, t) {
9
+ const messages = [], seen = new Set();
10
+ for (let e = error; e && !seen.has(e); e = e.cause) { seen.add(e); messages.push(e.message ?? String(e)); }
11
+ const log = messages.join('\n');
12
+ const exit = log.match(/process did exit: exitCode=([^,\s]+), signal=([^\s>]+)/);
13
+ const stderr = log.split(/\r?\n/).filter(line => /\[err\]/.test(line) && !line.includes('<launching>')).slice(-20).join('\n').slice(-4000);
14
+ return { ...(t === undefined ? {} : { t }), reason: (messages.at(-1) ?? 'Browser closed unexpectedly').split(/\r?\n/)[0].slice(0, 500),
15
+ ...(exit ? { exitCode: exit[1] === 'null' ? null : Number(exit[1]), signal: exit[2] === 'null' ? null : exit[2] } : {}),
16
+ ...(stderr ? { stderr } : {}) };
17
+ }
18
+
19
+ export function browserClosed(error, browser, page, crashed = false) {
20
+ if (error?.code === 'render_threw' || error?.code === 'cancelled') return false;
21
+ return crashed || page?.isClosed() || (browser && !browser.isConnected())
22
+ || /Target (?:page, context or browser|closed)|(?:Page|Browser|Connection|Session) closed|Page crashed|Target crashed|browser has disconnected/i.test(error?.message ?? '')
23
+ || (error?.cause && browserClosed(error.cause, browser, page, crashed));
24
+ }
25
+
26
+ export const crashedError = (error, t) => new MultiplaneError('browser_crashed', undefined, { cause: error, details: crashDetails(error, t) });
@@ -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
+ }