multiplane 0.3.6 → 0.3.7
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/THIRD_PARTY_NOTICES.md +30 -30
- 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/src/alpha.mjs +69 -69
- package/src/artifacts.mjs +61 -61
- package/src/browser-recovery.mjs +26 -26
- package/src/capture.mjs +12 -12
- package/src/encoder.mjs +16 -16
- package/src/environment.mjs +56 -56
- package/src/events.mjs +5 -5
- 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 +1 -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 +38 -37
- package/src/preview.mjs +23 -23
- package/src/process.mjs +9 -9
- package/src/render-pool.mjs +2 -2
- package/src/render-worker.mjs +1 -1
- package/src/resource-path.mjs +29 -29
- package/src/resources.mjs +27 -27
- package/src/runtime/camera.js +37 -37
- package/src/runtime/shot.css +9 -9
- package/src/server.mjs +7 -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.7",
|
|
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
|
+
}
|
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) });
|
package/src/capture.mjs
CHANGED
|
@@ -119,10 +119,10 @@ async function openShotOnce(shot, scale = 1, options = {}) {
|
|
|
119
119
|
}
|
|
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
|
-
let closing, sampleContextUsed = false, motionProbeUsed = false, dofSamples = 0;
|
|
123
|
-
const draw = async (t, { motionBlur = 1, motionBlurBase = motionBlur, motionProbeTime, dofMotionSamples } = {}) => {
|
|
122
|
+
let closing, sampleContextUsed = false, motionProbeUsed = false, dofSamples = 0;
|
|
123
|
+
const draw = async (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, alpha }) => {
|
|
125
|
+
const changed = await page.evaluate(({ t, 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');
|
|
@@ -139,7 +139,7 @@ async function openShotOnce(shot, scale = 1, options = {}) {
|
|
|
139
139
|
SHOT.sampleContext = { time: t, motionBlurBase, get motionBlur() { contextUsed = true; return motionBlur; } };
|
|
140
140
|
let failure;
|
|
141
141
|
// Private renderer/layer handshake; never part of the shot contract.
|
|
142
|
-
const probe = globalThis.__multiplaneMotionProbe = { time: motionProbeTime, used: false, motionSamples: dofMotionSamples, samples: 0 };
|
|
142
|
+
const probe = globalThis.__multiplaneMotionProbe = { time: motionProbeTime, used: false, motionSamples: dofMotionSamples, motionIndex: dofMotionIndex, samples: 0 };
|
|
143
143
|
try { SHOT.render(t); } catch (error) { failure = String(error?.message ?? error); } finally {
|
|
144
144
|
delete globalThis.__multiplaneMotionProbe;
|
|
145
145
|
if (previous === undefined) delete SHOT.sampleContext;
|
|
@@ -147,11 +147,11 @@ async function openShotOnce(shot, scale = 1, options = {}) {
|
|
|
147
147
|
}
|
|
148
148
|
stage.getBoundingClientRect();
|
|
149
149
|
const changed = globalThis.__multiplaneStatic?.read() ?? true;
|
|
150
|
-
return { failure, contextUsed, probeUsed: probe.used, dofSamples: probe.samples, changed, reason: globalThis.__multiplaneStatic?.reason };
|
|
151
|
-
}, { t, motionBlur, motionBlurBase, motionProbeTime, dofMotionSamples, alpha }).then(({ failure, changed, contextUsed, probeUsed, dofSamples: count, reason }) => {
|
|
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 }) => {
|
|
152
152
|
sampleContextUsed = contextUsed;
|
|
153
|
-
motionProbeUsed = probeUsed;
|
|
154
|
-
dofSamples = count;
|
|
153
|
+
motionProbeUsed = probeUsed;
|
|
154
|
+
dofSamples = count;
|
|
155
155
|
const index = observationReasons.indexOf(reason);
|
|
156
156
|
if (index >= 0) Atomics.add(reuseState(options), index + 4, 1);
|
|
157
157
|
if (failure !== undefined) throw new MultiplaneError('render_threw', undefined, { details: { t, reason: failure } });
|
|
@@ -177,8 +177,8 @@ async function openShotOnce(shot, scale = 1, options = {}) {
|
|
|
177
177
|
return {
|
|
178
178
|
page, browser, server, ...metadata, alpha, beginFrame, timings,
|
|
179
179
|
get sampleContextUsed() { return sampleContextUsed; },
|
|
180
|
-
get motionProbeUsed() { return motionProbeUsed; },
|
|
181
|
-
get dofSamples() { return dofSamples; },
|
|
180
|
+
get motionProbeUsed() { return motionProbeUsed; },
|
|
181
|
+
get dofSamples() { return dofSamples; },
|
|
182
182
|
...(reuseEnabled(options) ? { observe: draw } : {}),
|
|
183
183
|
isCrash: error => browserClosed(error, browser, page, crashed),
|
|
184
184
|
...(metadata.hasBlurAt ? { blurAt: async t => {
|
|
@@ -220,8 +220,8 @@ export async function openShot(shot, scale = 1, options = {}) {
|
|
|
220
220
|
get page() { return current.page; },
|
|
221
221
|
get browser() { return current.browser; },
|
|
222
222
|
get sampleContextUsed() { return current.sampleContextUsed; },
|
|
223
|
-
get motionProbeUsed() { return current.motionProbeUsed; },
|
|
224
|
-
get dofSamples() { return current.dofSamples; },
|
|
223
|
+
get motionProbeUsed() { return current.motionProbeUsed; },
|
|
224
|
+
get dofSamples() { return current.dofSamples; },
|
|
225
225
|
...(current.observe ? { observe: (...args) => current.observe(...args) } : {}),
|
|
226
226
|
beginFrame: (...args) => current.beginFrame(...args),
|
|
227
227
|
frame: (...args) => current.frame(...args),
|
package/src/encoder.mjs
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import { invalidOption } from './errors.mjs';
|
|
2
|
-
export const encoders = ['x264', 'nvenc-h264', 'nvenc-hevc'];
|
|
3
|
-
|
|
4
|
-
export function encoderArgs(name = 'x264', quality, maxMbps) {
|
|
5
|
-
if (!encoders.includes(name)) throw invalidOption('--encoder', '--encoder must be x264, nvenc-h264 or nvenc-hevc.');
|
|
6
|
-
if (maxMbps !== undefined && !(Number.isFinite(maxMbps) && maxMbps > 0)) throw invalidOption('--max-bitrate', '--max-bitrate must be positive (Mbps).');
|
|
7
|
-
const cap = maxMbps ? ['-maxrate', `${maxMbps}M`, '-bufsize', `${maxMbps * 2}M`] : [];
|
|
8
|
-
if (name === 'x264') return ['-c:v', 'libx264', '-profile:v', 'high', '-crf', '16', ...cap];
|
|
9
|
-
quality ??= name === 'nvenc-hevc' ? 20 : 21;
|
|
10
|
-
if (!Number.isInteger(quality) || quality < 1 || quality > 51) throw invalidOption('NVENC', 'NVENC CQ must be an integer from 1 to 51.');
|
|
11
|
-
return ['-c:v', name === 'nvenc-h264' ? 'h264_nvenc' : 'hevc_nvenc',
|
|
12
|
-
'-preset', 'p7', '-tune', 'hq', '-rc', 'vbr', '-cq', String(quality), '-b:v', '0',
|
|
13
|
-
...(maxMbps ? cap : ['-qmin', '0', '-qmax', String(Math.min(51, quality + 2))]),
|
|
14
|
-
'-multipass', 'fullres', '-spatial-aq', '1', '-temporal-aq', '1', '-rc-lookahead', '32', '-bf', '3',
|
|
15
|
-
...(name === 'nvenc-h264' ? ['-profile:v', 'high'] : ['-tag:v', 'hvc1'])];
|
|
16
|
-
}
|
|
1
|
+
import { invalidOption } from './errors.mjs';
|
|
2
|
+
export const encoders = ['x264', 'nvenc-h264', 'nvenc-hevc'];
|
|
3
|
+
|
|
4
|
+
export function encoderArgs(name = 'x264', quality, maxMbps) {
|
|
5
|
+
if (!encoders.includes(name)) throw invalidOption('--encoder', '--encoder must be x264, nvenc-h264 or nvenc-hevc.');
|
|
6
|
+
if (maxMbps !== undefined && !(Number.isFinite(maxMbps) && maxMbps > 0)) throw invalidOption('--max-bitrate', '--max-bitrate must be positive (Mbps).');
|
|
7
|
+
const cap = maxMbps ? ['-maxrate', `${maxMbps}M`, '-bufsize', `${maxMbps * 2}M`] : [];
|
|
8
|
+
if (name === 'x264') return ['-c:v', 'libx264', '-profile:v', 'high', '-crf', '16', ...cap];
|
|
9
|
+
quality ??= name === 'nvenc-hevc' ? 20 : 21;
|
|
10
|
+
if (!Number.isInteger(quality) || quality < 1 || quality > 51) throw invalidOption('NVENC', 'NVENC CQ must be an integer from 1 to 51.');
|
|
11
|
+
return ['-c:v', name === 'nvenc-h264' ? 'h264_nvenc' : 'hevc_nvenc',
|
|
12
|
+
'-preset', 'p7', '-tune', 'hq', '-rc', 'vbr', '-cq', String(quality), '-b:v', '0',
|
|
13
|
+
...(maxMbps ? cap : ['-qmin', '0', '-qmax', String(Math.min(51, quality + 2))]),
|
|
14
|
+
'-multipass', 'fullres', '-spatial-aq', '1', '-temporal-aq', '1', '-rc-lookahead', '32', '-bf', '3',
|
|
15
|
+
...(name === 'nvenc-h264' ? ['-profile:v', 'high'] : ['-tag:v', 'hvc1'])];
|
|
16
|
+
}
|