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.
- package/LICENSE +202 -202
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/THIRD_PARTY_NOTICES.md +30 -30
- package/bin/multiplane +24 -14
- package/examples/alpha.html +18 -18
- package/examples/animation.html +17 -17
- package/examples/assets/OFL.txt +93 -93
- package/examples/assets/card.svg +6 -6
- package/examples/three-cards.html +17 -17
- package/examples/typography.html +17 -17
- package/licenses/GPL-3.0.txt +232 -232
- package/licenses/LGPL-3.0.txt +304 -304
- package/licenses/acorn-LICENSE +21 -21
- package/licenses/colour-LICENSE.md +82 -82
- package/licenses/detect-libc-LICENSE +201 -201
- package/licenses/entities-LICENSE +11 -11
- package/licenses/nanoid-LICENSE +20 -20
- package/licenses/parse5-LICENSE +19 -19
- package/licenses/picocolors-LICENSE +15 -15
- package/licenses/playwright-NOTICE +5 -5
- package/licenses/playwright-ThirdPartyNotices.txt +14 -14
- package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
- package/licenses/postcss-LICENSE +20 -20
- package/licenses/postcss-value-parser-LICENSE +22 -22
- package/licenses/semver-LICENSE +15 -15
- package/licenses/sharp-LICENSE +191 -191
- package/licenses/sharp-win32-x64-NOTICES.md +45 -45
- package/licenses/source-map-js-LICENSE +28 -28
- package/licenses/three-LICENSE +21 -21
- package/package.json +92 -92
- package/skills/multiplane/SKILL.md +3 -0
- package/src/alpha.mjs +69 -69
- package/src/artifacts.mjs +61 -61
- package/src/browser-recovery.mjs +26 -26
- package/src/capture-watchdog.mjs +29 -0
- package/src/capture.mjs +34 -18
- package/src/encoder.mjs +16 -16
- package/src/environment.mjs +56 -56
- package/src/errors.mjs +1 -0
- package/src/events.mjs +12 -10
- package/src/ffmpeg.mjs +32 -32
- package/src/format.mjs +27 -27
- package/src/frame-rate.mjs +28 -28
- package/src/home-migration.mjs +54 -54
- package/src/hosted.mjs +3 -3
- package/src/index.mjs +1 -0
- package/src/input-records.mjs +33 -0
- package/src/jobs.mjs +3 -1
- package/src/lint.mjs +248 -248
- package/src/output-alpha.mjs +36 -36
- package/src/output-check.mjs +110 -110
- package/src/platform.mjs +114 -114
- package/src/png-native.mjs +12 -12
- package/src/png.mjs +74 -74
- package/src/post.mjs +56 -39
- package/src/preview.mjs +23 -23
- package/src/process.mjs +9 -9
- package/src/render-pool.mjs +41 -14
- package/src/render-worker.mjs +14 -3
- package/src/render.mjs +148 -52
- package/src/resource-path.mjs +29 -29
- package/src/resources.mjs +27 -27
- package/src/resume.mjs +23 -0
- package/src/runtime/camera.js +37 -37
- package/src/runtime/shot.css +9 -9
- package/src/segment-encoder.mjs +91 -0
- package/src/segments.mjs +123 -0
- package/src/server.mjs +11 -7
- package/src/setup.mjs +51 -51
- package/src/skills.mjs +128 -128
- package/src/stills.mjs +1 -1
- package/src/three/index.js +39 -31
- package/src/verify.mjs +2 -2
- package/src/video-pipe.mjs +6 -6
- package/tools/bench/shots/canvas-2d.html +1 -1
- package/tools/bench/shots/dom-light.html +1 -1
- package/tools/bench/shots/scene.js +44 -44
- package/tools/bench/shots/webgl.html +1 -1
- package/tools/install-browser.mjs +18 -18
package/package.json
CHANGED
|
@@ -1,92 +1,92 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "multiplane",
|
|
3
|
-
"version": "0.3.
|
|
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
|
+
}
|
package/src/browser-recovery.mjs
CHANGED
|
@@ -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
|
+
}
|