multiplane 0.3.3 → 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/README.en.md +327 -326
- package/README.md +327 -326
- 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 +1 -1
- package/skills/multiplane/SKILL.md +215 -213
- package/src/alpha.mjs +69 -69
- package/src/artifacts.mjs +61 -61
- package/src/browser-recovery.mjs +26 -26
- package/src/capture.mjs +16 -6
- package/src/encoder.mjs +16 -16
- package/src/environment.mjs +56 -56
- package/src/events.mjs +3 -0
- 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 +55 -21
- package/src/preview.mjs +23 -23
- package/src/process.mjs +9 -9
- package/src/render-pool.mjs +16 -6
- package/src/render-worker.mjs +5 -3
- package/src/render.mjs +5 -5
- 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 -1
- package/src/setup.mjs +51 -51
- package/src/skills.mjs +128 -128
- package/src/stills.mjs +5 -4
- package/src/three/index.js +44 -14
- package/src/verify.mjs +3 -1
- package/src/video-pipe.mjs +6 -6
- package/src/workers.mjs +5 -1
- 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/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;
|
|
123
|
-
const draw = async (t, { motionBlur = 1 } = {}) => {
|
|
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, 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');
|
|
@@ -135,17 +135,23 @@ async function openShotOnce(shot, scale = 1, options = {}) {
|
|
|
135
135
|
globalThis.__multiplaneStatic?.resume();
|
|
136
136
|
const previous = SHOT.sampleContext;
|
|
137
137
|
let contextUsed = false;
|
|
138
|
-
|
|
138
|
+
// TODO(design): Document motionBlurBase, stable across nested refinements.
|
|
139
|
+
SHOT.sampleContext = { time: t, motionBlurBase, get motionBlur() { contextUsed = true; return motionBlur; } };
|
|
139
140
|
let failure;
|
|
141
|
+
// Private renderer/layer handshake; never part of the shot contract.
|
|
142
|
+
const probe = globalThis.__multiplaneMotionProbe = { time: motionProbeTime, used: false, motionSamples: dofMotionSamples, motionIndex: dofMotionIndex, samples: 0 };
|
|
140
143
|
try { SHOT.render(t); } catch (error) { failure = String(error?.message ?? error); } finally {
|
|
144
|
+
delete globalThis.__multiplaneMotionProbe;
|
|
141
145
|
if (previous === undefined) delete SHOT.sampleContext;
|
|
142
146
|
else SHOT.sampleContext = previous;
|
|
143
147
|
}
|
|
144
148
|
stage.getBoundingClientRect();
|
|
145
149
|
const changed = globalThis.__multiplaneStatic?.read() ?? true;
|
|
146
|
-
return { failure, contextUsed, changed, reason: globalThis.__multiplaneStatic?.reason };
|
|
147
|
-
}, { t, motionBlur, alpha }).then(({ failure, changed, contextUsed, 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 }) => {
|
|
148
152
|
sampleContextUsed = contextUsed;
|
|
153
|
+
motionProbeUsed = probeUsed;
|
|
154
|
+
dofSamples = count;
|
|
149
155
|
const index = observationReasons.indexOf(reason);
|
|
150
156
|
if (index >= 0) Atomics.add(reuseState(options), index + 4, 1);
|
|
151
157
|
if (failure !== undefined) throw new MultiplaneError('render_threw', undefined, { details: { t, reason: failure } });
|
|
@@ -171,6 +177,8 @@ async function openShotOnce(shot, scale = 1, options = {}) {
|
|
|
171
177
|
return {
|
|
172
178
|
page, browser, server, ...metadata, alpha, beginFrame, timings,
|
|
173
179
|
get sampleContextUsed() { return sampleContextUsed; },
|
|
180
|
+
get motionProbeUsed() { return motionProbeUsed; },
|
|
181
|
+
get dofSamples() { return dofSamples; },
|
|
174
182
|
...(reuseEnabled(options) ? { observe: draw } : {}),
|
|
175
183
|
isCrash: error => browserClosed(error, browser, page, crashed),
|
|
176
184
|
...(metadata.hasBlurAt ? { blurAt: async t => {
|
|
@@ -212,6 +220,8 @@ export async function openShot(shot, scale = 1, options = {}) {
|
|
|
212
220
|
get page() { return current.page; },
|
|
213
221
|
get browser() { return current.browser; },
|
|
214
222
|
get sampleContextUsed() { return current.sampleContextUsed; },
|
|
223
|
+
get motionProbeUsed() { return current.motionProbeUsed; },
|
|
224
|
+
get dofSamples() { return current.dofSamples; },
|
|
215
225
|
...(current.observe ? { observe: (...args) => current.observe(...args) } : {}),
|
|
216
226
|
beginFrame: (...args) => current.beginFrame(...args),
|
|
217
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
|
+
}
|
package/src/environment.mjs
CHANGED
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
import { chromium } from 'playwright';
|
|
2
|
-
import { browserOptions, browserHint } from './platform.mjs';
|
|
3
|
-
import { MultiplaneError } from './errors.mjs';
|
|
4
|
-
export async function launchBrowser(options = {}, launcher = chromium, platform = process.platform) {
|
|
5
|
-
let browser;
|
|
6
|
-
try {
|
|
7
|
-
try {
|
|
8
|
-
browser = await launcher.launch(browserOptions(options.raster ?? 'gpu', options, platform));
|
|
9
|
-
if (platform !== 'linux' || options.raster === 'software' || (await environment(browser)).gpu.hardware) return browser;
|
|
10
|
-
await browser.close(); browser = undefined;
|
|
11
|
-
} catch (error) {
|
|
12
|
-
await browser?.close(); browser = undefined;
|
|
13
|
-
if (platform !== 'linux' || options.raster === 'software' || /Executable doesn't exist/.test(error.message)) throw error;
|
|
14
|
-
}
|
|
15
|
-
// ANGLE can launch without a working GL device on a headless Linux host.
|
|
16
|
-
// Relaunch with SwiftShader so WebGL shots remain usable and repeatable.
|
|
17
|
-
return await launcher.launch(browserOptions('software', options, platform));
|
|
18
|
-
} catch (cause) { throw new MultiplaneError(/Executable doesn't exist/.test(cause.message) ? 'browser_not_installed' : 'browser_launch_failed', undefined,
|
|
19
|
-
{ cause, hint: browserHint(/Executable doesn't exist/.test(cause.message), platform), details: { reason: cause.message.split(/\r?\n/)[0] } }); }
|
|
20
|
-
}
|
|
21
|
-
export async function environment(browser, ffmpeg = null) {
|
|
22
|
-
const session = await browser.newBrowserCDPSession();
|
|
23
|
-
try {
|
|
24
|
-
const { gpu } = await session.send('SystemInfo.getInfo');
|
|
25
|
-
const renderer = gpu.auxAttributes?.glRenderer ?? gpu.devices?.[0]?.deviceString ?? 'unknown';
|
|
26
|
-
return { node: process.version, platform: process.platform, chromium: browser.version(),
|
|
27
|
-
gpu: { renderer, hardware: !/swiftshader|llvmpipe|software|unknown/i.test(renderer) }, ffmpeg };
|
|
28
|
-
} finally { await session.detach(); }
|
|
29
|
-
}
|
|
30
|
-
export async function loaded(events, capture, ffmpeg = null) {
|
|
31
|
-
events.options.alpha = capture.alpha;
|
|
32
|
-
events.start(true);
|
|
33
|
-
const { width, height, duration, fps, cuts, post } = capture;
|
|
34
|
-
events.emit('shot', { width, height, duration, fps, cuts, post });
|
|
35
|
-
events.check();
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function warnGpuFallback(events, info, options = {}) {
|
|
39
|
-
if ((options.raster ?? 'gpu') === 'gpu' && !info.gpu.hardware) {
|
|
40
|
-
events.emit('warning', { code: 'gpu_software_fallback', message: 'Hardware GPU rasterization is unavailable.' });
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export const observeBrowser = (events, ffmpeg = null) => {
|
|
45
|
-
let observed = false;
|
|
46
|
-
return async browser => {
|
|
47
|
-
const unsubscribe = events.onCancel(() => browser.close());
|
|
48
|
-
browser.once('disconnected', unsubscribe);
|
|
49
|
-
events.check();
|
|
50
|
-
if (observed) return;
|
|
51
|
-
const info = await environment(browser, ffmpeg);
|
|
52
|
-
observed = true;
|
|
53
|
-
events.emit('environment', info);
|
|
54
|
-
warnGpuFallback(events, info, events.options);
|
|
55
|
-
};
|
|
56
|
-
};
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import { browserOptions, browserHint } from './platform.mjs';
|
|
3
|
+
import { MultiplaneError } from './errors.mjs';
|
|
4
|
+
export async function launchBrowser(options = {}, launcher = chromium, platform = process.platform) {
|
|
5
|
+
let browser;
|
|
6
|
+
try {
|
|
7
|
+
try {
|
|
8
|
+
browser = await launcher.launch(browserOptions(options.raster ?? 'gpu', options, platform));
|
|
9
|
+
if (platform !== 'linux' || options.raster === 'software' || (await environment(browser)).gpu.hardware) return browser;
|
|
10
|
+
await browser.close(); browser = undefined;
|
|
11
|
+
} catch (error) {
|
|
12
|
+
await browser?.close(); browser = undefined;
|
|
13
|
+
if (platform !== 'linux' || options.raster === 'software' || /Executable doesn't exist/.test(error.message)) throw error;
|
|
14
|
+
}
|
|
15
|
+
// ANGLE can launch without a working GL device on a headless Linux host.
|
|
16
|
+
// Relaunch with SwiftShader so WebGL shots remain usable and repeatable.
|
|
17
|
+
return await launcher.launch(browserOptions('software', options, platform));
|
|
18
|
+
} catch (cause) { throw new MultiplaneError(/Executable doesn't exist/.test(cause.message) ? 'browser_not_installed' : 'browser_launch_failed', undefined,
|
|
19
|
+
{ cause, hint: browserHint(/Executable doesn't exist/.test(cause.message), platform), details: { reason: cause.message.split(/\r?\n/)[0] } }); }
|
|
20
|
+
}
|
|
21
|
+
export async function environment(browser, ffmpeg = null) {
|
|
22
|
+
const session = await browser.newBrowserCDPSession();
|
|
23
|
+
try {
|
|
24
|
+
const { gpu } = await session.send('SystemInfo.getInfo');
|
|
25
|
+
const renderer = gpu.auxAttributes?.glRenderer ?? gpu.devices?.[0]?.deviceString ?? 'unknown';
|
|
26
|
+
return { node: process.version, platform: process.platform, chromium: browser.version(),
|
|
27
|
+
gpu: { renderer, hardware: !/swiftshader|llvmpipe|software|unknown/i.test(renderer) }, ffmpeg };
|
|
28
|
+
} finally { await session.detach(); }
|
|
29
|
+
}
|
|
30
|
+
export async function loaded(events, capture, ffmpeg = null) {
|
|
31
|
+
events.options.alpha = capture.alpha;
|
|
32
|
+
events.start(true);
|
|
33
|
+
const { width, height, duration, fps, cuts, post } = capture;
|
|
34
|
+
events.emit('shot', { width, height, duration, fps, cuts, post });
|
|
35
|
+
events.check();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function warnGpuFallback(events, info, options = {}) {
|
|
39
|
+
if ((options.raster ?? 'gpu') === 'gpu' && !info.gpu.hardware) {
|
|
40
|
+
events.emit('warning', { code: 'gpu_software_fallback', message: 'Hardware GPU rasterization is unavailable.' });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const observeBrowser = (events, ffmpeg = null) => {
|
|
45
|
+
let observed = false;
|
|
46
|
+
return async browser => {
|
|
47
|
+
const unsubscribe = events.onCancel(() => browser.close());
|
|
48
|
+
browser.once('disconnected', unsubscribe);
|
|
49
|
+
events.check();
|
|
50
|
+
if (observed) return;
|
|
51
|
+
const info = await environment(browser, ffmpeg);
|
|
52
|
+
observed = true;
|
|
53
|
+
events.emit('environment', info);
|
|
54
|
+
warnGpuFallback(events, info, events.options);
|
|
55
|
+
};
|
|
56
|
+
};
|
package/src/events.mjs
CHANGED
|
@@ -229,6 +229,9 @@ export async function runCommand(command, shot, options, action, job = true) {
|
|
|
229
229
|
events.check();
|
|
230
230
|
},
|
|
231
231
|
}));
|
|
232
|
+
// Draft merges explicit options again; retain the absolute output path
|
|
233
|
+
// required by atomic directory publication and artifact relocation.
|
|
234
|
+
events.options.out = path.resolve(events.options.out);
|
|
232
235
|
}
|
|
233
236
|
events.start();
|
|
234
237
|
for (const warning of user.warnings) events.warning(warning);
|
package/src/ffmpeg.mjs
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { MultiplaneError } from './errors.mjs';
|
|
3
|
-
import { ffmpegName, ffmpegHint } from './platform.mjs';
|
|
4
|
-
|
|
5
|
-
export function ffmpegOutput(executable, args, env = process.env) {
|
|
6
|
-
return new Promise((resolve, reject) => {
|
|
7
|
-
const child = spawn(executable, args, { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8
|
-
const timer = setTimeout(() => child.kill('SIGKILL'), 15000);
|
|
9
|
-
child.once('close', () => clearTimeout(timer));
|
|
10
|
-
child.once('error', () => clearTimeout(timer));
|
|
11
|
-
let output = '';
|
|
12
|
-
child.stdout.on('data', bytes => { output += bytes; });
|
|
13
|
-
child.stderr.on('data', bytes => { output += bytes; });
|
|
14
|
-
child.once('error', reject);
|
|
15
|
-
child.once('close', code => code === 0 ? resolve(output) : reject(new Error(`FFmpeg exited with code ${code}.`)));
|
|
16
|
-
});
|
|
17
|
-
}
|
|
18
|
-
export async function inspectFFmpeg(explicit, env = process.env, encoder, output = ffmpegOutput) {
|
|
19
|
-
const executable = explicit ?? env.MULTIPLANE_FFMPEG ?? ffmpegName();
|
|
20
|
-
try {
|
|
21
|
-
const version = (await output(executable, ['-version'], env)).split(/\r?\n/)[0];
|
|
22
|
-
if (encoder && !new RegExp(`\\b${encoder}\\b`).test(await output(executable, ['-encoders'], env))) throw new Error(`Missing encoder: ${encoder}`);
|
|
23
|
-
return { path: executable, version };
|
|
24
|
-
} catch (cause) {
|
|
25
|
-
throw new MultiplaneError(cause.code === 'ENOENT' ? 'ffmpeg_not_found' : 'ffmpeg_unusable', undefined,
|
|
26
|
-
{ cause, hint: cause.message.startsWith('Missing encoder:') ? `${cause.message}. Use an FFmpeg build that includes it.` : ffmpegHint(), details: { reason: cause.message } });
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function resolveFFmpeg(explicit, env = process.env) {
|
|
31
|
-
return (await inspectFFmpeg(explicit, env)).path;
|
|
32
|
-
}
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { MultiplaneError } from './errors.mjs';
|
|
3
|
+
import { ffmpegName, ffmpegHint } from './platform.mjs';
|
|
4
|
+
|
|
5
|
+
export function ffmpegOutput(executable, args, env = process.env) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn(executable, args, { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), 15000);
|
|
9
|
+
child.once('close', () => clearTimeout(timer));
|
|
10
|
+
child.once('error', () => clearTimeout(timer));
|
|
11
|
+
let output = '';
|
|
12
|
+
child.stdout.on('data', bytes => { output += bytes; });
|
|
13
|
+
child.stderr.on('data', bytes => { output += bytes; });
|
|
14
|
+
child.once('error', reject);
|
|
15
|
+
child.once('close', code => code === 0 ? resolve(output) : reject(new Error(`FFmpeg exited with code ${code}.`)));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export async function inspectFFmpeg(explicit, env = process.env, encoder, output = ffmpegOutput) {
|
|
19
|
+
const executable = explicit ?? env.MULTIPLANE_FFMPEG ?? ffmpegName();
|
|
20
|
+
try {
|
|
21
|
+
const version = (await output(executable, ['-version'], env)).split(/\r?\n/)[0];
|
|
22
|
+
if (encoder && !new RegExp(`\\b${encoder}\\b`).test(await output(executable, ['-encoders'], env))) throw new Error(`Missing encoder: ${encoder}`);
|
|
23
|
+
return { path: executable, version };
|
|
24
|
+
} catch (cause) {
|
|
25
|
+
throw new MultiplaneError(cause.code === 'ENOENT' ? 'ffmpeg_not_found' : 'ffmpeg_unusable', undefined,
|
|
26
|
+
{ cause, hint: cause.message.startsWith('Missing encoder:') ? `${cause.message}. Use an FFmpeg build that includes it.` : ffmpegHint(), details: { reason: cause.message } });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function resolveFFmpeg(explicit, env = process.env) {
|
|
31
|
+
return (await inspectFFmpeg(explicit, env)).path;
|
|
32
|
+
}
|