multiplane 0.0.0-stage → 0.3.0
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 -0
- package/README.en.md +322 -0
- package/README.md +321 -2
- package/THIRD_PARTY_NOTICES.md +30 -0
- package/bin/multiplane +98 -0
- package/examples/alpha.html +18 -0
- package/examples/animation.html +17 -0
- package/examples/assets/OFL.txt +93 -0
- package/examples/assets/Roboto-Variable.ttf +0 -0
- package/examples/assets/card.svg +6 -0
- package/examples/assets/readme-card-0.svg +1 -0
- package/examples/assets/readme-card-1.svg +1 -0
- package/examples/assets/readme-card-2.svg +1 -0
- package/examples/assets/readme-detail.svg +1 -0
- package/examples/readme-cards.html +23 -0
- package/examples/readme-cut.html +13 -0
- package/examples/readme-depth.html +15 -0
- package/examples/readme-wheel.html +14 -0
- package/examples/three-cards.html +17 -0
- package/examples/typography.html +17 -0
- package/licenses/GPL-3.0.txt +232 -0
- package/licenses/LGPL-3.0.txt +304 -0
- package/licenses/acorn-LICENSE +21 -0
- package/licenses/colour-LICENSE.md +82 -0
- package/licenses/detect-libc-LICENSE +201 -0
- package/licenses/entities-LICENSE +11 -0
- package/licenses/nanoid-LICENSE +20 -0
- package/licenses/native/README.ijg +258 -0
- package/licenses/native/aom-LICENSE +27 -0
- package/licenses/native/aom-PATENTS +108 -0
- package/licenses/native/archive-COPYING +65 -0
- package/licenses/native/cairo-COPYING +33 -0
- package/licenses/native/cairo-COPYING-MPL-1.1 +470 -0
- package/licenses/native/cgif-LICENSE +21 -0
- package/licenses/native/exif-COPYING +504 -0
- package/licenses/native/expat-COPYING +21 -0
- package/licenses/native/ffi-LICENSE +21 -0
- package/licenses/native/fontconfig-COPYING +200 -0
- package/licenses/native/freetype-FTL.TXT +169 -0
- package/licenses/native/fribidi-COPYING +501 -0
- package/licenses/native/glib-COPYING +175 -0
- package/licenses/native/harfbuzz-COPYING +42 -0
- package/licenses/native/heif-COPYING +871 -0
- package/licenses/native/highway-LICENSE +371 -0
- package/licenses/native/imagequant-COPYRIGHT +36 -0
- package/licenses/native/lcms-COPYING +22 -0
- package/licenses/native/mozjpeg-LICENSE.md +135 -0
- package/licenses/native/nsgif-COPYING +21 -0
- package/licenses/native/pango-COPYING +482 -0
- package/licenses/native/pixman-COPYING +42 -0
- package/licenses/native/png-LICENSE +134 -0
- package/licenses/native/proxy-libintl-COPYING +482 -0
- package/licenses/native/rsvg-COPYING.LIB +502 -0
- package/licenses/native/sources.json +240 -0
- package/licenses/native/tiff-LICENSE.md +49 -0
- package/licenses/native/uhdr-LICENSE +230 -0
- package/licenses/native/vips-COPYING +502 -0
- package/licenses/native/webp-COPYING +30 -0
- package/licenses/native/webp-PATENTS +23 -0
- package/licenses/native/xml2-Copyright +24 -0
- package/licenses/native/zlib-ng-LICENSE.md +19 -0
- package/licenses/parse5-LICENSE +19 -0
- package/licenses/picocolors-LICENSE +15 -0
- package/licenses/playwright-LICENSE +202 -0
- package/licenses/playwright-NOTICE +5 -0
- package/licenses/playwright-ThirdPartyNotices.txt +14 -0
- package/licenses/playwright-core-ThirdPartyNotices.txt +13 -0
- package/licenses/postcss-LICENSE +20 -0
- package/licenses/postcss-value-parser-LICENSE +22 -0
- package/licenses/semver-LICENSE +15 -0
- package/licenses/sharp-LICENSE +191 -0
- package/licenses/sharp-win32-x64-LICENSE +191 -0
- package/licenses/sharp-win32-x64-NOTICES.md +59 -0
- package/licenses/source-map-js-LICENSE +28 -0
- package/licenses/three-LICENSE +21 -0
- package/package.json +92 -6
- package/skills/multiplane/SKILL.md +213 -0
- package/src/alpha.mjs +69 -0
- package/src/artifacts.mjs +61 -0
- package/src/benchmark.mjs +140 -0
- package/src/browser-recovery.mjs +26 -0
- package/src/capture.mjs +224 -0
- package/src/config.mjs +79 -0
- package/src/doctor.mjs +56 -0
- package/src/draft.mjs +30 -0
- package/src/encoder.mjs +16 -0
- package/src/environment.mjs +56 -0
- package/src/errors.mjs +51 -0
- package/src/events.mjs +252 -0
- package/src/ffmpeg.mjs +32 -0
- package/src/format.mjs +27 -0
- package/src/frame-rate.mjs +28 -0
- package/src/home-migration.mjs +54 -0
- package/src/hosted.mjs +134 -0
- package/src/index.mjs +14 -0
- package/src/inspect.mjs +20 -0
- package/src/jobs.mjs +51 -0
- package/src/lint.mjs +248 -0
- package/src/output-alpha.mjs +36 -0
- package/src/output-check.mjs +110 -0
- package/src/pixel-comparison.mjs +43 -0
- package/src/platform.mjs +114 -0
- package/src/png-native.mjs +12 -0
- package/src/png.mjs +74 -0
- package/src/post.mjs +433 -0
- package/src/preview.mjs +23 -0
- package/src/process-identity.mjs +17 -0
- package/src/process.mjs +9 -0
- package/src/render-pool.mjs +93 -0
- package/src/render-worker.mjs +59 -0
- package/src/render.mjs +157 -0
- package/src/resource-path.mjs +29 -0
- package/src/resources.mjs +27 -0
- package/src/runtime/camera.js +37 -0
- package/src/runtime/shot.css +9 -0
- package/src/runtime/shot.js +71 -0
- package/src/serve.mjs +23 -0
- package/src/server.mjs +93 -0
- package/src/setup.mjs +51 -0
- package/src/skills.mjs +128 -0
- package/src/static-reuse.mjs +265 -0
- package/src/stills.mjs +81 -0
- package/src/three/index.js +348 -0
- package/src/user-home.mjs +105 -0
- package/src/verify.mjs +161 -0
- package/src/video-pipe.mjs +6 -0
- package/src/workers.mjs +62 -0
- package/tools/bench/shots/canvas-2d.html +1 -0
- package/tools/bench/shots/dom-light.html +1 -0
- package/tools/bench/shots/scene.js +45 -0
- package/tools/bench/shots/webgl.html +1 -0
- package/tools/install-browser.mjs +18 -0
package/src/alpha.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
4
|
+
import { renameSync } from 'node:fs';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { availableParallelism } from 'node:os';
|
|
7
|
+
import { runCommand } from './events.mjs';
|
|
8
|
+
import { launchBrowser, environment, warnGpuFallback } from './environment.mjs';
|
|
9
|
+
import { createRenderPool } from './render-pool.mjs';
|
|
10
|
+
import { benchmarkMachine, memoryWorkers, shotTypes } from './workers.mjs';
|
|
11
|
+
import { userHome, stopSignals } from './platform.mjs';
|
|
12
|
+
import { invalidOption, MultiplaneError } from './errors.mjs';
|
|
13
|
+
|
|
14
|
+
export function recommendedPoint(points) {
|
|
15
|
+
const threshold = Math.max(...points.map(point => point.fps)) * 0.95;
|
|
16
|
+
return points.filter(point => point.fps >= threshold).sort((a, b) => a.workers - b.workers)[0];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function benchmarkCurves({ measure, maxWorkers, cores = availableParallelism(), memory = () => memoryWorkers(), check = () => {}, now = () => performance.now(), budgetMs = 170000 }) {
|
|
20
|
+
const shots = {}, deadline = now() + budgetMs;
|
|
21
|
+
const limit = Math.min(maxWorkers, cores);
|
|
22
|
+
const candidates = [1, 2, 4, 6, 8, 12, 16, 24].filter(workers => workers <= limit);
|
|
23
|
+
if (!candidates.includes(2)) throw new MultiplaneError('internal_error', 'Benchmark requires workers 1 and 2 for every shot type; no result was saved.');
|
|
24
|
+
let memoryLimit = Infinity, lastCost = 0, frameScale = 1;
|
|
25
|
+
for (const type of shotTypes) {
|
|
26
|
+
const points = [];
|
|
27
|
+
let stoppedWorkers;
|
|
28
|
+
for (const workers of candidates) {
|
|
29
|
+
check();
|
|
30
|
+
// Keep a memory ceiling for later shot types, even if free memory recovers.
|
|
31
|
+
if (workers > memoryLimit) { stoppedWorkers = workers; break; }
|
|
32
|
+
const available = memory();
|
|
33
|
+
if (workers > available) {
|
|
34
|
+
memoryLimit = Math.min(memoryLimit, available);
|
|
35
|
+
stoppedWorkers = workers;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
if (now() >= deadline) throw new MultiplaneError('cancelled', 'Benchmark time budget exceeded; no result was saved.');
|
|
39
|
+
// Divide the time budget only among points still allowed by memory.
|
|
40
|
+
const remaining = candidates.filter(n => n >= workers && n <= memoryLimit).length
|
|
41
|
+
+ (shotTypes.length - shotTypes.indexOf(type) - 1) * candidates.filter(n => n <= memoryLimit).length;
|
|
42
|
+
const allowance = (deadline - now()) / remaining;
|
|
43
|
+
if (lastCost > allowance) frameScale *= allowance / lastCost;
|
|
44
|
+
const count = Math.max(workers, Math.floor(Math.max(24, workers * 4) * frameScale));
|
|
45
|
+
const started = now();
|
|
46
|
+
const fps = await measure(type, workers, { count });
|
|
47
|
+
check();
|
|
48
|
+
if (now() >= deadline) throw new MultiplaneError('cancelled', 'Benchmark time budget exceeded; no result was saved.');
|
|
49
|
+
if (!Number.isFinite(fps) || fps <= 0) throw new MultiplaneError('internal_error', 'Invalid benchmark throughput.');
|
|
50
|
+
lastCost = now() - started;
|
|
51
|
+
points.push({ workers, fps });
|
|
52
|
+
}
|
|
53
|
+
if (!points.some(point => point.workers === 2)) throw new MultiplaneError('internal_error', 'Insufficient free memory to benchmark workers 1 and 2 for every shot type; no result was saved.');
|
|
54
|
+
shots[type] = { ...recommendedPoint(points), points,
|
|
55
|
+
...(stoppedWorkers === undefined ? {} : { limitedBy: 'memory', stoppedWorkers,
|
|
56
|
+
detail: `受内存限制,只测到 ${points.at(-1).workers} 个并发。` }) };
|
|
57
|
+
}
|
|
58
|
+
check();
|
|
59
|
+
return shots;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function saveBenchmark(record, { home = userHome(), check = () => {} } = {}) {
|
|
63
|
+
const file = path.join(home, 'benchmark.json'), temporary = file + '.' + randomUUID() + '.tmp';
|
|
64
|
+
await mkdir(home, { recursive: true });
|
|
65
|
+
try {
|
|
66
|
+
check();
|
|
67
|
+
await writeFile(temporary, JSON.stringify(record, null, 2) + '\n', { flag: 'wx' });
|
|
68
|
+
check();
|
|
69
|
+
// Commit synchronously with the last cancellation check; no event-loop gap.
|
|
70
|
+
renameSync(temporary, file);
|
|
71
|
+
} finally { await rm(temporary, { force: true }); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function measureBenchmark(type, workers, { raster = 'gpu', check = () => {}, onWarning, onPool = () => {}, count = Math.max(24, workers * 4), createPool = createRenderPool, now = () => performance.now() } = {}) {
|
|
75
|
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
76
|
+
const names = { dom: 'dom-light', canvas: 'canvas-2d', three: 'webgl' };
|
|
77
|
+
const fps = Math.max(30, Math.ceil(count / 2));
|
|
78
|
+
check();
|
|
79
|
+
const pool = createPool(path.join(root, 'tools/bench/shots', names[type] + '.html'),
|
|
80
|
+
{ root, raster, scale: 1, 'render-scale': 1, 'no-post': true, 'no-static-reuse': true, transport: 'raw' },
|
|
81
|
+
fps, workers, { width: 1920, height: 1080 }, onWarning);
|
|
82
|
+
onPool(pool);
|
|
83
|
+
try {
|
|
84
|
+
// Warm every worker, excluding browser startup from capture throughput.
|
|
85
|
+
for await (const frame of pool.frames(0, workers)) { check(); }
|
|
86
|
+
const samples = [];
|
|
87
|
+
for (let repeat = 0; repeat < 2; repeat++) {
|
|
88
|
+
check();
|
|
89
|
+
const start = now();
|
|
90
|
+
for await (const frame of pool.frames(0, count)) { check(); }
|
|
91
|
+
const throughput = count * 1000 / (now() - start);
|
|
92
|
+
if (!Number.isFinite(throughput) || throughput <= 0) throw new MultiplaneError('internal_error', 'Invalid benchmark throughput.');
|
|
93
|
+
samples.push(throughput);
|
|
94
|
+
}
|
|
95
|
+
return (samples[0] + samples[1]) / 2;
|
|
96
|
+
} finally { await pool.close(); onPool(undefined); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const benchmark = (options = {}) => runCommand('benchmark', null, options, async events => {
|
|
100
|
+
options = events.options;
|
|
101
|
+
const raster = options.raster ?? 'gpu';
|
|
102
|
+
if (!['gpu', 'software'].includes(raster)) throw invalidOption('--raster', '--raster must be gpu or software.');
|
|
103
|
+
const maxWorkers = Number(options['max-workers'] ?? Math.max(1, availableParallelism()));
|
|
104
|
+
if (!Number.isSafeInteger(maxWorkers) || maxWorkers < 1) throw invalidOption('--max-workers', '--max-workers must be a positive integer.');
|
|
105
|
+
let browser, pool, stopped = false;
|
|
106
|
+
const stop = () => { if (stopped) return; stopped = true; void browser?.close().catch(() => {}); void pool?.cancel().catch(() => {}); };
|
|
107
|
+
const check = () => { if (stopped) throw new MultiplaneError('cancelled', 'Benchmark interrupted; no result was saved.'); };
|
|
108
|
+
const signals = stopSignals();
|
|
109
|
+
for (const signal of signals) process.on(signal, stop);
|
|
110
|
+
// Reserve cleanup time inside the three-minute command budget.
|
|
111
|
+
const timer = setTimeout(stop, 175000);
|
|
112
|
+
const started = performance.now();
|
|
113
|
+
try {
|
|
114
|
+
browser = await launchBrowser({ raster });
|
|
115
|
+
check();
|
|
116
|
+
const info = await environment(browser);
|
|
117
|
+
const machine = benchmarkMachine(info, raster);
|
|
118
|
+
warnGpuFallback(events, info, { raster });
|
|
119
|
+
await browser.close(); browser = undefined;
|
|
120
|
+
const shots = await benchmarkCurves({ maxWorkers, check, budgetMs: Math.max(1, 170000 - (performance.now() - started)),
|
|
121
|
+
measure: async (type, workers, { count }) => {
|
|
122
|
+
const fps = await measureBenchmark(type, workers, { raster, check, count, onWarning: events.warning, onPool: value => { pool = value; } });
|
|
123
|
+
events.emit('progress', { phase: 'capture', done: count * 2, total: count * 2, unit: 'frame', fps, etaMs: 0 });
|
|
124
|
+
return fps;
|
|
125
|
+
} });
|
|
126
|
+
check();
|
|
127
|
+
// TODO(design): Document the benchmark result and cache fields in the protocol.
|
|
128
|
+
const record = { schema: 'multiplane/1', testedAt: new Date().toISOString(), machine, shots };
|
|
129
|
+
await saveBenchmark(record, { check });
|
|
130
|
+
return record;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
check();
|
|
133
|
+
throw error;
|
|
134
|
+
} finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
for (const signal of signals) process.off(signal, stop);
|
|
137
|
+
await browser?.close();
|
|
138
|
+
await pool?.close();
|
|
139
|
+
}
|
|
140
|
+
}, false);
|
|
@@ -0,0 +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) });
|
package/src/capture.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { installStaticObserver, reuseEnabled, reuseState, observationReasons } from './static-reuse.mjs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { browserClosed, crashedError, recoveryBudget, MAX_BROWSER_RESTARTS } from './browser-recovery.mjs';
|
|
4
|
+
import { access } from 'node:fs/promises';
|
|
5
|
+
import { launchBrowser } from './environment.mjs';
|
|
6
|
+
import { MultiplaneError, invalidOption } from './errors.mjs';
|
|
7
|
+
import { startServer } from './server.mjs';
|
|
8
|
+
import { normalizeResourcePath, isInsideResourceRoot } from './resource-path.mjs';
|
|
9
|
+
|
|
10
|
+
export function number(value, label, { positive = false } = {}) {
|
|
11
|
+
const result = Number(value);
|
|
12
|
+
if (!String(value).trim() || !Number.isFinite(result) || result < 0 || (positive && result === 0)) throw invalidOption(label, `${label} must be a finite ${positive ? 'positive' : 'nonnegative'} number.`);
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function validateScale(width, height, scale, label = '--scale') {
|
|
17
|
+
const valid = s => [width * s, height * s].every(v => Number.isSafeInteger(v) && v > 0 && v % 2 === 0);
|
|
18
|
+
if (!valid(scale)) {
|
|
19
|
+
const suggestions = [0.25, 0.4, 0.5, 1, 2, 4].filter(valid);
|
|
20
|
+
throw new MultiplaneError('invalid_scale', undefined, { details: { option: label, width, height, scale }, hint: `Use ${label}: ${suggestions.join(', ') || 'a scale producing even integer dimensions'}.` });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function openShotOnce(shot, scale = 1, options = {}) {
|
|
25
|
+
const raster = options.raster ?? 'gpu';
|
|
26
|
+
if (!['software', 'gpu'].includes(raster)) throw invalidOption('--raster', '--raster must be software or gpu.');
|
|
27
|
+
number(scale, '--scale', { positive: true });
|
|
28
|
+
const inputRoot = path.resolve(options.root ?? process.cwd());
|
|
29
|
+
const root = await normalizeResourcePath(inputRoot);
|
|
30
|
+
const entry = path.resolve(inputRoot, shot);
|
|
31
|
+
if (!await isInsideResourceRoot(root, entry)) throw new MultiplaneError('shot_outside_root');
|
|
32
|
+
// Preserve the requested URL for in-root symlinks and their relative resources.
|
|
33
|
+
let relative = path.relative(inputRoot, entry);
|
|
34
|
+
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
|
|
35
|
+
relative = path.relative(root, await normalizeResourcePath(entry));
|
|
36
|
+
}
|
|
37
|
+
if (path.extname(relative).toLowerCase() !== '.html') throw invalidOption('shot', 'Shot must be an HTML file.');
|
|
38
|
+
try { await access(entry); } catch { throw new MultiplaneError('shot_not_found'); }
|
|
39
|
+
const server = await startServer(0, root);
|
|
40
|
+
let browser, page, crashed = false;
|
|
41
|
+
try {
|
|
42
|
+
browser = await launchBrowser(options);
|
|
43
|
+
await options.onBrowser?.(browser);
|
|
44
|
+
page = await browser.newPage({ viewport: { width: 1920, height: 1080 }, deviceScaleFactor: scale });
|
|
45
|
+
if (reuseEnabled(options)) await page.addInitScript(installStaticObserver);
|
|
46
|
+
page.on('crash', () => { crashed = true; });
|
|
47
|
+
const errors = [];
|
|
48
|
+
const observerProbe = request => {
|
|
49
|
+
const headers = request.headers();
|
|
50
|
+
return headers['x-multiplane-image-probe'] === '1'
|
|
51
|
+
|| headers['access-control-request-headers']?.toLowerCase().split(',').some(name => name.trim() === 'x-multiplane-image-probe');
|
|
52
|
+
};
|
|
53
|
+
page.on('pageerror', error => errors.push(new MultiplaneError('shot_load_failed', undefined, { details: { reason: error.message } })));
|
|
54
|
+
page.on('console', message => { if (['log', 'warning', 'error'].includes(message.type())) process.stderr.write(message.text() + '\n'); });
|
|
55
|
+
// Optional image inspection may fail CORS/CSP even when the actual image
|
|
56
|
+
// loads. Its failure means no reuse, not a shot resource failure.
|
|
57
|
+
page.on('response', response => { if (response.status() >= 400 && !observerProbe(response.request())) errors.push(new MultiplaneError(response.headers()['x-multiplane-error'] === 'three_not_installed' ? 'three_not_installed' : 'resource_failed', undefined, { details: { status: response.status(), url: response.url() } })); });
|
|
58
|
+
page.on('requestfailed', request => { if (!observerProbe(request)) errors.push(new MultiplaneError('resource_failed', undefined, { details: { url: request.url() } })); });
|
|
59
|
+
const url = `${server.url}/${relative.split(path.sep).map(encodeURIComponent).join('/')}?mode=render`;
|
|
60
|
+
const response = await page.goto(url).catch(cause => { throw new MultiplaneError('shot_load_failed', undefined, { cause }); });
|
|
61
|
+
if (!response.ok()) throw new MultiplaneError('shot_load_failed');
|
|
62
|
+
if (errors.length) throw errors[0];
|
|
63
|
+
if (!await page.evaluate(() => typeof window.SHOT?.render === 'function')) throw new MultiplaneError('shot_contract_invalid');
|
|
64
|
+
const metadata = await page.evaluate(async () => {
|
|
65
|
+
await Promise.race([
|
|
66
|
+
(async () => { await SHOT.ready; await document.fonts.ready; })(),
|
|
67
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('ready_timeout')), 30000)),
|
|
68
|
+
]);
|
|
69
|
+
const stage = document.querySelector('#stage')?.getBoundingClientRect();
|
|
70
|
+
if (!stage || ![stage.width, stage.height].every(v => Number.isSafeInteger(v) && v > 0)) throw new Error('stage_size_invalid');
|
|
71
|
+
let meta;
|
|
72
|
+
try {
|
|
73
|
+
const json = JSON.stringify(SHOT.meta === undefined ? null : SHOT.meta);
|
|
74
|
+
if (typeof json !== 'string' || new TextEncoder().encode(json).length > 1024 * 1024) throw new Error('Invalid metadata.');
|
|
75
|
+
meta = JSON.parse(json);
|
|
76
|
+
} catch { throw new Error('shot_contract_invalid'); }
|
|
77
|
+
return { width: stage.width, height: stage.height, duration: SHOT.duration, fps: SHOT.fps ?? 30, cuts: SHOT.cuts ?? [], post: SHOT.post ?? {}, hasBlurAt: typeof SHOT.blurAt === 'function', meta };
|
|
78
|
+
}).catch(cause => { throw new MultiplaneError(['ready_timeout', 'stage_size_invalid', 'shot_contract_invalid'].find(code => cause.message.includes(code)) ?? 'resource_failed', undefined, { cause }); });
|
|
79
|
+
const { width, height } = metadata;
|
|
80
|
+
if (width !== 1920 || height !== 1080) {
|
|
81
|
+
await page.setViewportSize({ width, height });
|
|
82
|
+
const size = await page.locator('#stage').evaluate(stage => {
|
|
83
|
+
const rect = stage.getBoundingClientRect();
|
|
84
|
+
return { width: rect.width, height: rect.height };
|
|
85
|
+
});
|
|
86
|
+
if (size.width !== width || size.height !== height) throw new MultiplaneError('stage_size_invalid');
|
|
87
|
+
}
|
|
88
|
+
validateScale(width, height, scale, options['render-scale'] !== undefined ? '--render-scale' : '--scale');
|
|
89
|
+
if (![metadata.duration, metadata.fps].every(v => typeof v === 'number' && Number.isFinite(v) && v > 0)) throw new MultiplaneError('shot_contract_invalid');
|
|
90
|
+
if (!Array.isArray(metadata.cuts) || metadata.cuts.some(c => typeof c !== 'number' || !Number.isFinite(c) || c < 0 || c > metadata.duration)) throw new MultiplaneError('shot_contract_invalid');
|
|
91
|
+
const check = () => { if (errors.length) throw errors[0]; };
|
|
92
|
+
check();
|
|
93
|
+
const cdp = (options.capture ?? 'cdp') === 'cdp' ? await page.context().newCDPSession(page) : null;
|
|
94
|
+
if (reuseEnabled(options)) {
|
|
95
|
+
const inspection = cdp ?? await page.context().newCDPSession(page);
|
|
96
|
+
try {
|
|
97
|
+
// Include closed declarative shadow roots, which page JavaScript cannot inspect.
|
|
98
|
+
const { root } = await inspection.send('DOM.getDocument', { depth: -1, pierce: true });
|
|
99
|
+
const shadowed = node => node.shadowRoots?.some(root => root.shadowRootType !== 'user-agent') || node.children?.some(shadowed);
|
|
100
|
+
if (shadowed(root)) await page.evaluate(() => globalThis.__multiplaneStatic?.disqualify());
|
|
101
|
+
} catch {
|
|
102
|
+
await page.evaluate(() => globalThis.__multiplaneStatic?.disqualify());
|
|
103
|
+
} finally { if (!cdp) await inspection.detach(); }
|
|
104
|
+
}
|
|
105
|
+
const alpha = options.alpha ?? metadata.post.alpha ?? false;
|
|
106
|
+
if (typeof alpha !== 'boolean') throw invalidOption('--alpha', 'alpha must be a boolean.');
|
|
107
|
+
if (alpha && scale < 1) {
|
|
108
|
+
// Defer downsampling to the linear-light premultiplied post path.
|
|
109
|
+
const metrics = cdp ?? await page.context().newCDPSession(page);
|
|
110
|
+
await metrics.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
|
|
111
|
+
}
|
|
112
|
+
if (alpha && cdp) await cdp.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } });
|
|
113
|
+
const timings = { renderCallMs: 0, screenshotMs: 0 };
|
|
114
|
+
let closing, sampleContextUsed = false;
|
|
115
|
+
const draw = async (t, { motionBlur = 1 } = {}) => {
|
|
116
|
+
const renderStart = performance.now();
|
|
117
|
+
const changed = await page.evaluate(({ t, motionBlur, alpha }) => {
|
|
118
|
+
globalThis.__multiplaneStatic?.suspend();
|
|
119
|
+
if (alpha) document.documentElement.style.setProperty('--multiplane-page-background', 'transparent');
|
|
120
|
+
const stage = document.querySelector('#stage');
|
|
121
|
+
const display = stage.style.getPropertyValue('display');
|
|
122
|
+
const priority = stage.style.getPropertyPriority('display');
|
|
123
|
+
stage.style.setProperty('display', 'none', 'important');
|
|
124
|
+
stage.getBoundingClientRect();
|
|
125
|
+
if (display) stage.style.setProperty('display', display, priority);
|
|
126
|
+
else stage.style.removeProperty('display');
|
|
127
|
+
globalThis.__multiplaneStatic?.resume();
|
|
128
|
+
const previous = SHOT.sampleContext;
|
|
129
|
+
let contextUsed = false;
|
|
130
|
+
SHOT.sampleContext = { time: t, get motionBlur() { contextUsed = true; return motionBlur; } };
|
|
131
|
+
let failure;
|
|
132
|
+
try { SHOT.render(t); } catch (error) { failure = String(error?.message ?? error); } finally {
|
|
133
|
+
if (previous === undefined) delete SHOT.sampleContext;
|
|
134
|
+
else SHOT.sampleContext = previous;
|
|
135
|
+
}
|
|
136
|
+
stage.getBoundingClientRect();
|
|
137
|
+
const changed = globalThis.__multiplaneStatic?.read() ?? true;
|
|
138
|
+
return { failure, contextUsed, changed, reason: globalThis.__multiplaneStatic?.reason };
|
|
139
|
+
}, { t, motionBlur, alpha }).then(({ failure, changed, contextUsed, reason }) => {
|
|
140
|
+
sampleContextUsed = contextUsed;
|
|
141
|
+
const index = observationReasons.indexOf(reason);
|
|
142
|
+
if (index >= 0) Atomics.add(reuseState(options), index + 4, 1);
|
|
143
|
+
if (failure !== undefined) throw new MultiplaneError('render_threw', undefined, { details: { t, reason: failure } });
|
|
144
|
+
return changed;
|
|
145
|
+
});
|
|
146
|
+
timings.renderCallMs += performance.now() - renderStart;
|
|
147
|
+
check();
|
|
148
|
+
return changed;
|
|
149
|
+
};
|
|
150
|
+
const beginFrame = async (t, context) => {
|
|
151
|
+
if (!context?.observed) await draw(t, context);
|
|
152
|
+
const screenshotStart = performance.now();
|
|
153
|
+
const png = cdp ? cdp.send('Page.captureScreenshot', { format: 'png', optimizeForSpeed: true, captureBeyondViewport: false,
|
|
154
|
+
clip: { x: 0, y: 0, width, height, scale: alpha ? Math.max(1, scale) : scale } }).then(({ data }) => {
|
|
155
|
+
const png = Buffer.from(data, 'base64');
|
|
156
|
+
if (png.readUInt32BE(16) !== width * (alpha ? Math.max(1, scale) : scale) || png.readUInt32BE(20) !== height * (alpha ? Math.max(1, scale) : scale)) throw new MultiplaneError('internal_error', 'CDP screenshot dimensions do not match output.');
|
|
157
|
+
return png;
|
|
158
|
+
}) : page.screenshot({ type: 'png', animations: 'allow', ...(alpha ? { omitBackground: true } : {}) });
|
|
159
|
+
const measured = png.then(buffer => { timings.screenshotMs += performance.now() - screenshotStart; return buffer; });
|
|
160
|
+
measured.catch(() => {});
|
|
161
|
+
return { png: measured };
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
page, browser, server, ...metadata, alpha, beginFrame, timings,
|
|
165
|
+
get sampleContextUsed() { return sampleContextUsed; },
|
|
166
|
+
...(reuseEnabled(options) ? { observe: draw } : {}),
|
|
167
|
+
isCrash: error => browserClosed(error, browser, page, crashed),
|
|
168
|
+
...(metadata.hasBlurAt ? { blurAt: async t => {
|
|
169
|
+
const result = await page.evaluate(t => { try { return { value: SHOT.blurAt(t) }; } catch (error) { return { reason: String(error?.message ?? error) }; } }, t);
|
|
170
|
+
if ('reason' in result) throw new MultiplaneError('render_threw', undefined, { details: { t, reason: result.reason } });
|
|
171
|
+
return result.value;
|
|
172
|
+
} } : {}),
|
|
173
|
+
async frame(t, context) { return (await beginFrame(t, context)).png; },
|
|
174
|
+
close() { return closing ??= (async () => { try { await browser.close(); } finally { await server.close(); } })(); },
|
|
175
|
+
};
|
|
176
|
+
} catch (error) { const failure = browserClosed(error, browser, page, crashed) ? crashedError(error) : error; try { await browser?.close(); } finally { await server.close(); } throw failure; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Recovery belongs to a complete output frame, never to an individual blur sample.
|
|
180
|
+
export async function openShot(shot, scale = 1, options = {}) {
|
|
181
|
+
const budget = options._browserRecovery ?? recoveryBudget();
|
|
182
|
+
let current, closed = false;
|
|
183
|
+
const recover = async (error, t) => {
|
|
184
|
+
options.check?.();
|
|
185
|
+
if (closed) throw error;
|
|
186
|
+
const failure = crashedError(error, t);
|
|
187
|
+
if (Atomics.load(budget, 0) >= MAX_BROWSER_RESTARTS) throw failure;
|
|
188
|
+
const attempt = Atomics.add(budget, 0, 1) + 1;
|
|
189
|
+
if (attempt > MAX_BROWSER_RESTARTS) throw failure;
|
|
190
|
+
await current?.close().catch(() => {});
|
|
191
|
+
try { current = await openShotOnce(shot, scale, options); }
|
|
192
|
+
catch (restartError) {
|
|
193
|
+
options.check?.();
|
|
194
|
+
const restarted = crashedError(restartError, t);
|
|
195
|
+
restarted.details = { ...failure.details, ...restarted.details };
|
|
196
|
+
throw restarted;
|
|
197
|
+
}
|
|
198
|
+
options.onWarning?.({ code: 'browser_restarted', message: 'Browser restarted; retrying the affected frame.', details: { ...failure.details, attempt } });
|
|
199
|
+
};
|
|
200
|
+
try { current = await openShotOnce(shot, scale, options); }
|
|
201
|
+
catch (error) { if (error.code !== 'browser_crashed') throw error; await recover(error); }
|
|
202
|
+
const api = {
|
|
203
|
+
...current,
|
|
204
|
+
get page() { return current.page; },
|
|
205
|
+
get browser() { return current.browser; },
|
|
206
|
+
get sampleContextUsed() { return current.sampleContextUsed; },
|
|
207
|
+
...(current.observe ? { observe: (...args) => current.observe(...args) } : {}),
|
|
208
|
+
beginFrame: (...args) => current.beginFrame(...args),
|
|
209
|
+
frame: (...args) => current.frame(...args),
|
|
210
|
+
...(current.blurAt ? { blurAt: (...args) => current.blurAt(...args) } : {}),
|
|
211
|
+
async retryFrame(t, operation) {
|
|
212
|
+
for (;;) {
|
|
213
|
+
try { return await operation(); }
|
|
214
|
+
catch (error) {
|
|
215
|
+
options.check?.();
|
|
216
|
+
if (closed || !current.isCrash(error)) throw error;
|
|
217
|
+
await recover(error, t);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
async close() { closed = true; await current.close(); },
|
|
222
|
+
};
|
|
223
|
+
return api;
|
|
224
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { migrateUserHome } from './home-migration.mjs';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { userHome } from './platform.mjs';
|
|
6
|
+
import { MultiplaneError, invalidOption } from './errors.mjs';
|
|
7
|
+
import { ffmpegOutput } from './ffmpeg.mjs';
|
|
8
|
+
|
|
9
|
+
export const configKeys = ['ffmpeg', 'raster', 'encoder', 'workers', 'jobsDir', 'hosted'];
|
|
10
|
+
export async function probeFfmpeg(file) {
|
|
11
|
+
const check = { usable: false, version: null, encoders: { libx264: false, 'libvpx-vp9': false, prores_ks: false }, reason: null };
|
|
12
|
+
try {
|
|
13
|
+
check.version = (await ffmpegOutput(file, ['-version'])).split(/\r?\n/)[0];
|
|
14
|
+
if (!/^ffmpeg version\s/i.test(check.version)) { check.reason = 'not-ffmpeg'; return check; }
|
|
15
|
+
const list = await ffmpegOutput(file, ['-encoders']);
|
|
16
|
+
for (const name of Object.keys(check.encoders)) check.encoders[name] = new RegExp('\\b' + name + '\\b').test(list);
|
|
17
|
+
check.usable = check.encoders.libx264;
|
|
18
|
+
if (!check.usable) check.reason = 'missing-libx264';
|
|
19
|
+
} catch (error) { check.reason = error.code === 'ENOENT' ? 'not-found' : 'cannot-run'; }
|
|
20
|
+
return check;
|
|
21
|
+
}
|
|
22
|
+
export class Configuration {
|
|
23
|
+
constructor(home = userHome(), env = process.env, warning = () => {}) { this.file = path.join(home, 'config.json'); this.env = env; this.warning = warning; }
|
|
24
|
+
prepare() {
|
|
25
|
+
if (path.resolve(path.dirname(this.file)) === path.resolve(userHome(this.env))) {
|
|
26
|
+
for (const warning of migrateUserHome(this.env).warnings) this.warning(warning);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async document() {
|
|
30
|
+
let text;
|
|
31
|
+
try { text = await fs.readFile(this.file, 'utf8'); }
|
|
32
|
+
catch (error) { if (error.code === 'ENOENT') return { exists: false, values: {} }; throw error; }
|
|
33
|
+
try {
|
|
34
|
+
const values = JSON.parse(text);
|
|
35
|
+
if (!values || Array.isArray(values) || typeof values !== 'object') throw Error();
|
|
36
|
+
return { exists: true, values };
|
|
37
|
+
} catch { throw new MultiplaneError('config_invalid', `Invalid configuration: ${this.file}`, { details: { path: this.file }, hint: 'Repair or delete this configuration file before changing settings.' }); }
|
|
38
|
+
}
|
|
39
|
+
async read(key, prepared = false) {
|
|
40
|
+
if (!prepared) this.prepare();
|
|
41
|
+
if (key !== undefined && !configKeys.includes(key)) throw invalidOption('key', 'Unknown configuration key.');
|
|
42
|
+
const { exists, values } = await this.document();
|
|
43
|
+
return { file: this.file, exists, values: Object.fromEntries((key ? [key] : configKeys).map(k => [k, values[k] ?? null])), overrides: { ffmpeg: this.env.MULTIPLANE_FFMPEG || null, jobsDir: this.env.MULTIPLANE_JOBS_DIR || null } };
|
|
44
|
+
}
|
|
45
|
+
async save(key, value) {
|
|
46
|
+
if (!configKeys.includes(key)) throw invalidOption('key', 'Unknown configuration key.');
|
|
47
|
+
if (key === 'workers' && value === 'auto') value = null;
|
|
48
|
+
if (value !== null) {
|
|
49
|
+
if (key === 'hosted' && ['true', 'false'].includes(value)) value = value === 'true';
|
|
50
|
+
if (key === 'workers' && /^\d+$/.test(String(value))) value = Number(value);
|
|
51
|
+
const valid = ['ffmpeg', 'jobsDir'].includes(key) ? typeof value === 'string' && !!value.trim() && !value.includes('\0')
|
|
52
|
+
: key === 'raster' ? ['gpu', 'software'].includes(value) : key === 'encoder' ? ['x264', 'nvenc-h264', 'nvenc-hevc'].includes(value)
|
|
53
|
+
: key === 'hosted' ? typeof value === 'boolean' : Number.isSafeInteger(value) && value > 0;
|
|
54
|
+
if (!valid) throw invalidOption(key, 'Invalid configuration value.');
|
|
55
|
+
if (['ffmpeg', 'jobsDir'].includes(key)) value = path.resolve(value);
|
|
56
|
+
}
|
|
57
|
+
this.prepare();
|
|
58
|
+
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
|
59
|
+
const lock = this.file + '.lock', deadline = performance.now() + 10000;
|
|
60
|
+
let handle;
|
|
61
|
+
while (!handle) {
|
|
62
|
+
try { handle = await fs.open(lock, 'wx'); }
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code !== 'EEXIST' || performance.now() >= deadline) throw error;
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const temporary = this.file + '.' + randomUUID() + '.tmp';
|
|
69
|
+
let result;
|
|
70
|
+
try {
|
|
71
|
+
const { values } = await this.document();
|
|
72
|
+
await fs.writeFile(temporary, JSON.stringify({ ...values, schema: 'multiplane/1', [key]: value }, null, 2) + '\n', { flag: 'wx' });
|
|
73
|
+
await fs.rename(temporary, this.file);
|
|
74
|
+
result = { ...await this.read(undefined, true), changed: key };
|
|
75
|
+
} finally { await fs.unlink(temporary).catch(() => {}); await handle.close(); await fs.unlink(lock); }
|
|
76
|
+
if (key === 'ffmpeg' && value !== null) result.check = await probeFfmpeg(value);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
}
|