multiplane 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +202 -202
  2. package/README.en.md +3 -0
  3. package/README.md +3 -0
  4. package/THIRD_PARTY_NOTICES.md +30 -30
  5. package/bin/multiplane +24 -14
  6. package/examples/alpha.html +18 -18
  7. package/examples/animation.html +17 -17
  8. package/examples/assets/OFL.txt +93 -93
  9. package/examples/assets/card.svg +6 -6
  10. package/examples/three-cards.html +17 -17
  11. package/examples/typography.html +17 -17
  12. package/licenses/GPL-3.0.txt +232 -232
  13. package/licenses/LGPL-3.0.txt +304 -304
  14. package/licenses/acorn-LICENSE +21 -21
  15. package/licenses/colour-LICENSE.md +82 -82
  16. package/licenses/detect-libc-LICENSE +201 -201
  17. package/licenses/entities-LICENSE +11 -11
  18. package/licenses/nanoid-LICENSE +20 -20
  19. package/licenses/parse5-LICENSE +19 -19
  20. package/licenses/picocolors-LICENSE +15 -15
  21. package/licenses/playwright-NOTICE +5 -5
  22. package/licenses/playwright-ThirdPartyNotices.txt +14 -14
  23. package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
  24. package/licenses/postcss-LICENSE +20 -20
  25. package/licenses/postcss-value-parser-LICENSE +22 -22
  26. package/licenses/semver-LICENSE +15 -15
  27. package/licenses/sharp-LICENSE +191 -191
  28. package/licenses/sharp-win32-x64-NOTICES.md +45 -45
  29. package/licenses/source-map-js-LICENSE +28 -28
  30. package/licenses/three-LICENSE +21 -21
  31. package/package.json +92 -92
  32. package/skills/multiplane/SKILL.md +3 -0
  33. package/src/alpha.mjs +69 -69
  34. package/src/artifacts.mjs +61 -61
  35. package/src/browser-recovery.mjs +26 -26
  36. package/src/capture-watchdog.mjs +29 -0
  37. package/src/capture.mjs +34 -18
  38. package/src/encoder.mjs +16 -16
  39. package/src/environment.mjs +56 -56
  40. package/src/errors.mjs +1 -0
  41. package/src/events.mjs +12 -10
  42. package/src/ffmpeg.mjs +32 -32
  43. package/src/format.mjs +27 -27
  44. package/src/frame-rate.mjs +28 -28
  45. package/src/home-migration.mjs +54 -54
  46. package/src/hosted.mjs +3 -3
  47. package/src/index.mjs +1 -0
  48. package/src/input-records.mjs +33 -0
  49. package/src/jobs.mjs +3 -1
  50. package/src/lint.mjs +248 -248
  51. package/src/output-alpha.mjs +36 -36
  52. package/src/output-check.mjs +110 -110
  53. package/src/platform.mjs +114 -114
  54. package/src/png-native.mjs +12 -12
  55. package/src/png.mjs +74 -74
  56. package/src/post.mjs +56 -39
  57. package/src/preview.mjs +23 -23
  58. package/src/process.mjs +9 -9
  59. package/src/render-pool.mjs +41 -14
  60. package/src/render-worker.mjs +14 -3
  61. package/src/render.mjs +148 -52
  62. package/src/resource-path.mjs +29 -29
  63. package/src/resources.mjs +27 -27
  64. package/src/resume.mjs +23 -0
  65. package/src/runtime/camera.js +37 -37
  66. package/src/runtime/shot.css +9 -9
  67. package/src/segment-encoder.mjs +91 -0
  68. package/src/segments.mjs +123 -0
  69. package/src/server.mjs +11 -7
  70. package/src/setup.mjs +51 -51
  71. package/src/skills.mjs +128 -128
  72. package/src/stills.mjs +1 -1
  73. package/src/three/index.js +39 -31
  74. package/src/verify.mjs +2 -2
  75. package/src/video-pipe.mjs +6 -6
  76. package/tools/bench/shots/canvas-2d.html +1 -1
  77. package/tools/bench/shots/dom-light.html +1 -1
  78. package/tools/bench/shots/scene.js +44 -44
  79. package/tools/bench/shots/webgl.html +1 -1
  80. package/tools/install-browser.mjs +18 -18
@@ -1,110 +1,110 @@
1
- import { spawn } from 'node:child_process';
2
- import { readFile, rm } from 'node:fs/promises';
3
- import { decodeNativePNG } from './png-native.mjs';
4
- import { MultiplaneError } from './errors.mjs';
5
- import { stopChild } from './process.mjs';
6
-
7
- // Container timestamp precision (notably WebM/Matroska) makes decoded rates
8
- // approximate; apply the same relative tolerance to every output format.
9
- const FRAME_RATE_RELATIVE_TOLERANCE = 1e-4;
10
-
11
- async function probePass(ffmpeg, args, events, spawnProcess, line) {
12
- events?.check?.();
13
- await new Promise((resolve, reject) => {
14
- const child = spawnProcess(ffmpeg, ['-hide_banner', '-nostdin', '-nostats', ...args], { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] });
15
- const unsubscribe = events?.onCancel?.(() => stopChild(child));
16
- let buffer = '';
17
- child.stderr.on('data', bytes => {
18
- buffer += bytes.toString();
19
- let end;
20
- while ((end = buffer.indexOf('\n')) >= 0) { line(buffer.slice(0, end)); buffer = buffer.slice(end + 1); }
21
- if (buffer.length > 65536) buffer = buffer.slice(-65536);
22
- });
23
- child.once('error', error => { unsubscribe?.(); reject(error); });
24
- child.once('close', code => {
25
- unsubscribe?.(); line(buffer);
26
- if (code !== 0) return reject(new Error('FFmpeg probe exited with code ' + code + '.'));
27
- resolve();
28
- });
29
- });
30
- events?.check?.();
31
- }
32
-
33
- export async function probeVideo(ffmpeg, file, events, spawnProcess = spawn, options = {}) {
34
- // Count copied video packets independently of decoding and audio scheduling.
35
- let packetCount;
36
- await probePass(ffmpeg, ['-i', file, '-map', '0:v:0', '-an', '-c', 'copy', '-loglevel', 'verbose', '-f', 'null', '-'], events, spawnProcess, text => {
37
- const count = text.match(/Output stream #0:0 \(video\):\s*(\d+) packets muxed/);
38
- if (count) packetCount = Number(count[1]);
39
- });
40
- if (!Number.isSafeInteger(packetCount)) throw new Error('FFmpeg packet count could not be parsed.');
41
- let frames = 0, minTime = Infinity, maxTime = -Infinity, audioStart = Infinity, audioEnd = -Infinity;
42
- let width, height, fps, codec, pixelFormat, timeBase;
43
- let input = true, hasAudio = false;
44
- const line = text => {
45
- if (text.startsWith('Stream mapping:')) input = false;
46
- if (input && !codec) codec = text.match(/Stream #0:\d+.*Video: (\w+)/)?.[1];
47
- if (input && /Stream #0:\d+.*Audio:/.test(text)) hasAudio = true;
48
- if (text.includes('Parsed_showinfo')) {
49
- const rate = text.match(/config in time_base:\s*(\d+)\/(\d+), frame_rate:\s*(\d+)\/(\d+)/);
50
- if (rate) { timeBase = Number(rate[1]) / Number(rate[2]); fps = Number(rate[3]) / Number(rate[4]); }
51
- const frame = text.match(/\bn:\s*\d+\s+pts:\s*(-?\d+)\s+pts_time:.*?fmt:(\S+).*?\bs:(\d+)x(\d+)/);
52
- if (frame) {
53
- frames++;
54
- const t = Number(frame[1]) * timeBase;
55
- const duration = Number(text.match(/\bduration:\s*(\d+)/)?.[1]) * timeBase;
56
- minTime = Math.min(minTime, t); maxTime = Math.max(maxTime, t + (duration > 0 ? duration : 1 / fps));
57
- pixelFormat = frame[2]; width = Number(frame[3]); height = Number(frame[4]);
58
- }
59
- }
60
- if (text.includes('Parsed_ashowinfo')) {
61
- const audio = text.match(/pts_time:([\d.e+-]+).*?rate:(\d+).*?nb_samples:(\d+)/);
62
- if (audio) { audioStart = Math.min(audioStart, Number(audio[1])); audioEnd = Math.max(audioEnd, Number(audio[1]) + Number(audio[3]) / Number(audio[2])); }
63
- }
64
- };
65
-
66
- await probePass(ffmpeg, [...(options.alpha && options.format === 'webm' ? ['-c:v', 'libvpx-vp9'] : []), '-i', file,
67
- '-map', '0:v:0', '-an', '-vf', 'showinfo=checksum=0', '-fps_mode', 'passthrough', '-f', 'null', '-'], events, spawnProcess, line);
68
- if (packetCount !== frames) throw new MultiplaneError('output_invalid', 'Video packet count and decoded frame count disagree.', {
69
- details: { reason: 'video_count_mismatch', packetCount, decodedFrames: frames },
70
- });
71
- if (!frames || !width || !height || !Number.isFinite(fps) || fps <= 0 || !Number.isFinite(maxTime - minTime) || maxTime <= minTime || !codec || !pixelFormat) throw new Error('FFmpeg probe output could not be parsed.');
72
- if (hasAudio) {
73
- await probePass(ffmpeg, ['-i', file, '-map', '0:a:0', '-vn', '-af', 'ashowinfo', '-f', 'null', '-'], events, spawnProcess, line);
74
- if (!Number.isFinite(audioEnd - audioStart) || audioEnd <= audioStart) throw new Error('FFmpeg audio duration could not be parsed.');
75
- }
76
- return { frames, durationSeconds: maxTime - minTime, width, height, fps, codec, pixelFormat,
77
- // TODO(design): Document the optional audioDurationSeconds field.
78
- ...(Number.isFinite(audioEnd) ? { audioDurationSeconds: audioEnd - audioStart } : {}) };
79
- }
80
-
81
- export async function checkVideo(ffmpeg, file, expected, events, probe = probeVideo) {
82
- let actual;
83
- try { actual = await probe(ffmpeg, file, events, undefined, expected); }
84
- catch (error) {
85
- events.check();
86
- if (error.code === 'output_invalid') throw error;
87
- if (expected.alpha) throw new MultiplaneError('output_invalid', 'Transparent output could not be verified.', { cause: error });
88
- events.warning({ code: 'output_unverified', message: 'Output could not be verified.', details: { reason: error.message } });
89
- return { verified: false, frames: null, durationSeconds: null, width: null, height: null, fps: null, codec: null, pixelFormat: null };
90
- }
91
- events.check();
92
- const durationPrecision = 8 * Number.EPSILON * Math.max(1, expected.durationSeconds, actual.durationSeconds);
93
- const mismatch = ['frames', 'width', 'height'].some(key => actual[key] !== expected[key])
94
- || Math.abs(actual.fps - expected.fps) / expected.fps > FRAME_RATE_RELATIVE_TOLERANCE
95
- || Math.abs(actual.durationSeconds - expected.durationSeconds) - 1 / expected.fps > durationPrecision;
96
- if (expected.alpha && !/^(yuva|gbrap|rgba|bgra|argb|abgr)/.test(actual.pixelFormat)) throw new MultiplaneError('output_invalid', 'Decoded output has no alpha channel.', { details: { actual } });
97
- if (mismatch) throw new MultiplaneError('output_invalid', undefined, { details: { expected, actual } });
98
- return { verified: true, ...actual };
99
- }
100
-
101
- export async function checkStill(file, expected) {
102
- let actual = { width: null, height: null };
103
- try {
104
- const decoded = await decodeNativePNG(await readFile(file));
105
- actual = { width: decoded.width, height: decoded.height };
106
- if (actual.width === expected.width && actual.height === expected.height) return;
107
- } catch {}
108
- await rm(file, { force: true });
109
- throw new MultiplaneError('output_invalid', 'Still image could not be decoded or has incorrect dimensions.', { details: { expected, actual } });
110
- }
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile, rm } from 'node:fs/promises';
3
+ import { decodeNativePNG } from './png-native.mjs';
4
+ import { MultiplaneError } from './errors.mjs';
5
+ import { stopChild } from './process.mjs';
6
+
7
+ // Container timestamp precision (notably WebM/Matroska) makes decoded rates
8
+ // approximate; apply the same relative tolerance to every output format.
9
+ const FRAME_RATE_RELATIVE_TOLERANCE = 1e-4;
10
+
11
+ async function probePass(ffmpeg, args, events, spawnProcess, line) {
12
+ events?.check?.();
13
+ await new Promise((resolve, reject) => {
14
+ const child = spawnProcess(ffmpeg, ['-hide_banner', '-nostdin', '-nostats', ...args], { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] });
15
+ const unsubscribe = events?.onCancel?.(() => stopChild(child));
16
+ let buffer = '';
17
+ child.stderr.on('data', bytes => {
18
+ buffer += bytes.toString();
19
+ let end;
20
+ while ((end = buffer.indexOf('\n')) >= 0) { line(buffer.slice(0, end)); buffer = buffer.slice(end + 1); }
21
+ if (buffer.length > 65536) buffer = buffer.slice(-65536);
22
+ });
23
+ child.once('error', error => { unsubscribe?.(); reject(error); });
24
+ child.once('close', code => {
25
+ unsubscribe?.(); line(buffer);
26
+ if (code !== 0) return reject(new Error('FFmpeg probe exited with code ' + code + '.'));
27
+ resolve();
28
+ });
29
+ });
30
+ events?.check?.();
31
+ }
32
+
33
+ export async function probeVideo(ffmpeg, file, events, spawnProcess = spawn, options = {}) {
34
+ // Count copied video packets independently of decoding and audio scheduling.
35
+ let packetCount;
36
+ await probePass(ffmpeg, ['-i', file, '-map', '0:v:0', '-an', '-c', 'copy', '-loglevel', 'verbose', '-f', 'null', '-'], events, spawnProcess, text => {
37
+ const count = text.match(/Output stream #0:0 \(video\):\s*(\d+) packets muxed/);
38
+ if (count) packetCount = Number(count[1]);
39
+ });
40
+ if (!Number.isSafeInteger(packetCount)) throw new Error('FFmpeg packet count could not be parsed.');
41
+ let frames = 0, minTime = Infinity, maxTime = -Infinity, audioStart = Infinity, audioEnd = -Infinity;
42
+ let width, height, fps, codec, pixelFormat, timeBase;
43
+ let input = true, hasAudio = false;
44
+ const line = text => {
45
+ if (text.startsWith('Stream mapping:')) input = false;
46
+ if (input && !codec) codec = text.match(/Stream #0:\d+.*Video: (\w+)/)?.[1];
47
+ if (input && /Stream #0:\d+.*Audio:/.test(text)) hasAudio = true;
48
+ if (text.includes('Parsed_showinfo')) {
49
+ const rate = text.match(/config in time_base:\s*(\d+)\/(\d+), frame_rate:\s*(\d+)\/(\d+)/);
50
+ if (rate) { timeBase = Number(rate[1]) / Number(rate[2]); fps = Number(rate[3]) / Number(rate[4]); }
51
+ const frame = text.match(/\bn:\s*\d+\s+pts:\s*(-?\d+)\s+pts_time:.*?fmt:(\S+).*?\bs:(\d+)x(\d+)/);
52
+ if (frame) {
53
+ frames++;
54
+ const t = Number(frame[1]) * timeBase;
55
+ const duration = Number(text.match(/\bduration:\s*(\d+)/)?.[1]) * timeBase;
56
+ minTime = Math.min(minTime, t); maxTime = Math.max(maxTime, t + (duration > 0 ? duration : 1 / fps));
57
+ pixelFormat = frame[2]; width = Number(frame[3]); height = Number(frame[4]);
58
+ }
59
+ }
60
+ if (text.includes('Parsed_ashowinfo')) {
61
+ const audio = text.match(/pts_time:([\d.e+-]+).*?rate:(\d+).*?nb_samples:(\d+)/);
62
+ if (audio) { audioStart = Math.min(audioStart, Number(audio[1])); audioEnd = Math.max(audioEnd, Number(audio[1]) + Number(audio[3]) / Number(audio[2])); }
63
+ }
64
+ };
65
+
66
+ await probePass(ffmpeg, [...(options.alpha && options.format === 'webm' ? ['-c:v', 'libvpx-vp9'] : []), '-i', file,
67
+ '-map', '0:v:0', '-an', '-vf', 'showinfo=checksum=0', '-fps_mode', 'passthrough', '-f', 'null', '-'], events, spawnProcess, line);
68
+ if (packetCount !== frames) throw new MultiplaneError('output_invalid', 'Video packet count and decoded frame count disagree.', {
69
+ details: { reason: 'video_count_mismatch', packetCount, decodedFrames: frames },
70
+ });
71
+ if (!frames || !width || !height || !Number.isFinite(fps) || fps <= 0 || !Number.isFinite(maxTime - minTime) || maxTime <= minTime || !codec || !pixelFormat) throw new Error('FFmpeg probe output could not be parsed.');
72
+ if (hasAudio) {
73
+ await probePass(ffmpeg, ['-i', file, '-map', '0:a:0', '-vn', '-af', 'ashowinfo', '-f', 'null', '-'], events, spawnProcess, line);
74
+ if (!Number.isFinite(audioEnd - audioStart) || audioEnd <= audioStart) throw new Error('FFmpeg audio duration could not be parsed.');
75
+ }
76
+ return { frames, durationSeconds: maxTime - minTime, width, height, fps, codec, pixelFormat,
77
+ // TODO(design): Document the optional audioDurationSeconds field.
78
+ ...(Number.isFinite(audioEnd) ? { audioDurationSeconds: audioEnd - audioStart } : {}) };
79
+ }
80
+
81
+ export async function checkVideo(ffmpeg, file, expected, events, probe = probeVideo) {
82
+ let actual;
83
+ try { actual = await probe(ffmpeg, file, events, undefined, expected); }
84
+ catch (error) {
85
+ events.check();
86
+ if (error.code === 'output_invalid') throw error;
87
+ if (expected.alpha) throw new MultiplaneError('output_invalid', 'Transparent output could not be verified.', { cause: error });
88
+ events.warning({ code: 'output_unverified', message: 'Output could not be verified.', details: { reason: error.message } });
89
+ return { verified: false, frames: null, durationSeconds: null, width: null, height: null, fps: null, codec: null, pixelFormat: null };
90
+ }
91
+ events.check();
92
+ const durationPrecision = 8 * Number.EPSILON * Math.max(1, expected.durationSeconds, actual.durationSeconds);
93
+ const mismatch = ['frames', 'width', 'height'].some(key => actual[key] !== expected[key])
94
+ || Math.abs(actual.fps - expected.fps) / expected.fps > FRAME_RATE_RELATIVE_TOLERANCE
95
+ || Math.abs(actual.durationSeconds - expected.durationSeconds) - 1 / expected.fps > durationPrecision;
96
+ if (expected.alpha && !/^(yuva|gbrap|rgba|bgra|argb|abgr)/.test(actual.pixelFormat)) throw new MultiplaneError('output_invalid', 'Decoded output has no alpha channel.', { details: { actual } });
97
+ if (mismatch) throw new MultiplaneError('output_invalid', undefined, { details: { expected, actual } });
98
+ return { verified: true, ...actual };
99
+ }
100
+
101
+ export async function checkStill(file, expected) {
102
+ let actual = { width: null, height: null };
103
+ try {
104
+ const decoded = await decodeNativePNG(await readFile(file));
105
+ actual = { width: decoded.width, height: decoded.height };
106
+ if (actual.width === expected.width && actual.height === expected.height) return;
107
+ } catch {}
108
+ await rm(file, { force: true });
109
+ throw new MultiplaneError('output_invalid', 'Still image could not be decoded or has incorrect dimensions.', { details: { expected, actual } });
110
+ }
package/src/platform.mjs CHANGED
@@ -1,114 +1,114 @@
1
- import path from 'node:path';
2
- import os from 'node:os';
3
- import { execFile, spawn } from 'node:child_process';
4
- import { createInterface } from 'node:readline';
5
- import { promisify } from 'node:util';
6
-
7
- const execute = promisify(execFile);
8
- const windowsMemoryQuery = '$rows = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,WorkingSetSize | Select-Object ProcessId,ParentProcessId,WorkingSetSize); @{ observer = $PID; rows = $rows } | ConvertTo-Json -Compress';
9
- function windowsMemory(stdout, pid) {
10
- const data = JSON.parse(stdout);
11
- const rows = data.rows.map(row => ({ pid: Number(row.ProcessId), parent: Number(row.ParentProcessId), bytes: Number(row.WorkingSetSize) }));
12
- return sumProcessTree(rows, pid, [data.observer]);
13
- }
14
-
15
- // Reuse the Windows observer to avoid launching PowerShell for every sample.
16
- // It reads requests on stdin and writes one JSON line per response.
17
- export function createProcessTreeReader({ platform = process.platform, pid = process.pid } = {}) {
18
- if (platform !== 'win32') return { sample: () => processTreeMemory({ platform, pid }), close() {} };
19
- let child, pending, closed = false, failed = false;
20
- const settle = value => { const current = pending; pending = undefined; if (current) { clearTimeout(current.timer); current.resolve(value); } };
21
- const fail = () => { failed = true; settle(null); child?.kill(); };
22
- return {
23
- sample() {
24
- if (closed || failed) return Promise.resolve(null);
25
- if (pending) return pending.promise;
26
- if (!child) {
27
- const script = `$ErrorActionPreference = 'Stop'; while ($null -ne [Console]::ReadLine()) { try { ${windowsMemoryQuery} } catch { [Console]::WriteLine('null') } }`;
28
- child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
29
- child.on('error', fail); child.on('close', () => { failed = true; settle(null); }); child.stdin.on('error', fail);
30
- createInterface({ input: child.stdout }).on('line', line => {
31
- let bytes = null; try { bytes = windowsMemory(line, pid); } catch {}
32
- settle(bytes);
33
- });
34
- }
35
- let resolve;
36
- const promise = new Promise(done => { resolve = done; });
37
- pending = { promise, resolve, timer: setTimeout(fail, 5000) };
38
- child.stdin.write('\n');
39
- return promise;
40
- },
41
- async close() {
42
- if (closed) return;
43
- closed = true; settle(null);
44
- if (!child || child.exitCode !== null || child.signalCode !== null) return;
45
- await new Promise(resolve => {
46
- const timer = setTimeout(() => child.kill('SIGKILL'), 1000);
47
- child.once('close', () => { clearTimeout(timer); resolve(); });
48
- child.stdin.end();
49
- });
50
- },
51
- };
52
- }
53
- export function sumProcessTree(rows, rootPid, excluded = []) {
54
- const selected = new Set([rootPid]), blocked = new Set(excluded);
55
- for (let changed = true; changed;) {
56
- changed = false;
57
- for (const row of rows) if (!blocked.has(row.pid) && selected.has(row.parent) && !selected.has(row.pid)) { selected.add(row.pid); changed = true; }
58
- }
59
- if (!rows.some(row => row.pid === rootPid)) return null;
60
- let sum = 0;
61
- for (const row of rows) if (selected.has(row.pid)) {
62
- if (!Number.isSafeInteger(row.bytes) || row.bytes < 0) return null;
63
- sum += row.bytes;
64
- }
65
- return Number.isSafeInteger(sum) && sum > 0 ? sum : null;
66
- }
67
-
68
- export async function processTreeMemory({ platform = process.platform, pid = process.pid, run = execute } = {}) {
69
- try {
70
- let rows;
71
- if (platform === 'win32') {
72
- // Exclude the observer itself and its descendants from the target tree.
73
- const script = '$ErrorActionPreference = "Stop"; ' + windowsMemoryQuery;
74
- const { stdout } = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, timeout: 5000, maxBuffer: 8 * 1024 * 1024 });
75
- return windowsMemory(stdout, pid);
76
- }
77
- if (!['linux', 'darwin'].includes(platform)) return null;
78
- const { stdout } = await run('ps', ['-axo', 'pid=,ppid=,rss=,comm='], { timeout: 5000, maxBuffer: 8 * 1024 * 1024 });
79
- rows = stdout.trim().split(/\r?\n/).map(line => {
80
- const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/);
81
- if (!match) throw new Error('Invalid process table.');
82
- return { pid: Number(match[1]), parent: Number(match[2]), bytes: Number(match[3]) * 1024, name: match[4] };
83
- });
84
- return sumProcessTree(rows, pid, rows.filter(row => row.parent === pid && /(^|\/)ps$/.test(row.name)).map(row => row.pid));
85
- } catch { return null; }
86
- }
87
-
88
- // Keep platform-specific Chromium switches isolated from capture logic.
89
- export function browserOptions(raster = 'gpu', options = {}, platform = process.platform) {
90
- const gpu = raster === 'gpu';
91
- return { headless: true, ...(gpu ? { channel: 'chromium' } : {}), args: [
92
- '--disable-partial-raster',
93
- ...(options.rasterThreads ? [`--num-raster-threads=${options.rasterThreads}`] : []),
94
- ...(gpu ? ['--enable-gpu-rasterization', ...(platform === 'win32' ? ['--use-angle=d3d11'] : platform === 'darwin' ? ['--use-angle=metal'] : ['--use-angle=gl']),
95
- '--force-high-performance-gpu', '--ignore-gpu-blocklist'] : []),
96
- ...(!gpu && platform !== 'win32' ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] : []),
97
- ] };
98
- }
99
-
100
- export const ffmpegName = (platform = process.platform) => platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
101
- export const ffmpegHint = (platform = process.platform) => `${platform === 'darwin' ? 'Run brew install ffmpeg' : platform === 'linux' ? 'Install FFmpeg with libx264 using your distribution package manager (for example: sudo apt-get install ffmpeg)' : 'Get FFmpeg with libx264 from https://ffmpeg.org/download.html'} and set --ffmpeg, MULTIPLANE_FFMPEG or PATH.`;
102
- export const browserHint = (missing, platform = process.platform) => missing ? 'Run npx playwright install chromium.'
103
- : platform === 'linux' ? 'Run npx playwright install-deps chromium, then npx playwright install chromium.' : 'Run npx playwright install chromium.';
104
-
105
- export const stopSignals = (platform = process.platform) => platform === 'win32'
106
- ? ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] : ['SIGINT', 'SIGTERM', 'SIGHUP'];
107
- export function userHome(env = process.env, platform = process.platform, home = os.homedir()) {
108
- const paths = platform === 'win32' ? path.win32 : path.posix;
109
- if (env.MULTIPLANE_HOME) return paths.resolve(env.MULTIPLANE_HOME);
110
- if (platform === 'win32') return paths.join(env.USERPROFILE || home, '.multiplane');
111
- const base = platform === 'darwin' ? paths.join(home, 'Library', 'Application Support')
112
- : (env.XDG_CONFIG_HOME || paths.join(home, '.config'));
113
- return paths.join(base, 'multiplane');
114
- }
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import { createInterface } from 'node:readline';
5
+ import { promisify } from 'node:util';
6
+
7
+ const execute = promisify(execFile);
8
+ const windowsMemoryQuery = '$rows = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,WorkingSetSize | Select-Object ProcessId,ParentProcessId,WorkingSetSize); @{ observer = $PID; rows = $rows } | ConvertTo-Json -Compress';
9
+ function windowsMemory(stdout, pid) {
10
+ const data = JSON.parse(stdout);
11
+ const rows = data.rows.map(row => ({ pid: Number(row.ProcessId), parent: Number(row.ParentProcessId), bytes: Number(row.WorkingSetSize) }));
12
+ return sumProcessTree(rows, pid, [data.observer]);
13
+ }
14
+
15
+ // Reuse the Windows observer to avoid launching PowerShell for every sample.
16
+ // It reads requests on stdin and writes one JSON line per response.
17
+ export function createProcessTreeReader({ platform = process.platform, pid = process.pid } = {}) {
18
+ if (platform !== 'win32') return { sample: () => processTreeMemory({ platform, pid }), close() {} };
19
+ let child, pending, closed = false, failed = false;
20
+ const settle = value => { const current = pending; pending = undefined; if (current) { clearTimeout(current.timer); current.resolve(value); } };
21
+ const fail = () => { failed = true; settle(null); child?.kill(); };
22
+ return {
23
+ sample() {
24
+ if (closed || failed) return Promise.resolve(null);
25
+ if (pending) return pending.promise;
26
+ if (!child) {
27
+ const script = `$ErrorActionPreference = 'Stop'; while ($null -ne [Console]::ReadLine()) { try { ${windowsMemoryQuery} } catch { [Console]::WriteLine('null') } }`;
28
+ child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
29
+ child.on('error', fail); child.on('close', () => { failed = true; settle(null); }); child.stdin.on('error', fail);
30
+ createInterface({ input: child.stdout }).on('line', line => {
31
+ let bytes = null; try { bytes = windowsMemory(line, pid); } catch {}
32
+ settle(bytes);
33
+ });
34
+ }
35
+ let resolve;
36
+ const promise = new Promise(done => { resolve = done; });
37
+ pending = { promise, resolve, timer: setTimeout(fail, 5000) };
38
+ child.stdin.write('\n');
39
+ return promise;
40
+ },
41
+ async close() {
42
+ if (closed) return;
43
+ closed = true; settle(null);
44
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
45
+ await new Promise(resolve => {
46
+ const timer = setTimeout(() => child.kill('SIGKILL'), 1000);
47
+ child.once('close', () => { clearTimeout(timer); resolve(); });
48
+ child.stdin.end();
49
+ });
50
+ },
51
+ };
52
+ }
53
+ export function sumProcessTree(rows, rootPid, excluded = []) {
54
+ const selected = new Set([rootPid]), blocked = new Set(excluded);
55
+ for (let changed = true; changed;) {
56
+ changed = false;
57
+ for (const row of rows) if (!blocked.has(row.pid) && selected.has(row.parent) && !selected.has(row.pid)) { selected.add(row.pid); changed = true; }
58
+ }
59
+ if (!rows.some(row => row.pid === rootPid)) return null;
60
+ let sum = 0;
61
+ for (const row of rows) if (selected.has(row.pid)) {
62
+ if (!Number.isSafeInteger(row.bytes) || row.bytes < 0) return null;
63
+ sum += row.bytes;
64
+ }
65
+ return Number.isSafeInteger(sum) && sum > 0 ? sum : null;
66
+ }
67
+
68
+ export async function processTreeMemory({ platform = process.platform, pid = process.pid, run = execute } = {}) {
69
+ try {
70
+ let rows;
71
+ if (platform === 'win32') {
72
+ // Exclude the observer itself and its descendants from the target tree.
73
+ const script = '$ErrorActionPreference = "Stop"; ' + windowsMemoryQuery;
74
+ const { stdout } = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, timeout: 5000, maxBuffer: 8 * 1024 * 1024 });
75
+ return windowsMemory(stdout, pid);
76
+ }
77
+ if (!['linux', 'darwin'].includes(platform)) return null;
78
+ const { stdout } = await run('ps', ['-axo', 'pid=,ppid=,rss=,comm='], { timeout: 5000, maxBuffer: 8 * 1024 * 1024 });
79
+ rows = stdout.trim().split(/\r?\n/).map(line => {
80
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/);
81
+ if (!match) throw new Error('Invalid process table.');
82
+ return { pid: Number(match[1]), parent: Number(match[2]), bytes: Number(match[3]) * 1024, name: match[4] };
83
+ });
84
+ return sumProcessTree(rows, pid, rows.filter(row => row.parent === pid && /(^|\/)ps$/.test(row.name)).map(row => row.pid));
85
+ } catch { return null; }
86
+ }
87
+
88
+ // Keep platform-specific Chromium switches isolated from capture logic.
89
+ export function browserOptions(raster = 'gpu', options = {}, platform = process.platform) {
90
+ const gpu = raster === 'gpu';
91
+ return { headless: true, ...(gpu ? { channel: 'chromium' } : {}), args: [
92
+ '--disable-partial-raster',
93
+ ...(options.rasterThreads ? [`--num-raster-threads=${options.rasterThreads}`] : []),
94
+ ...(gpu ? ['--enable-gpu-rasterization', ...(platform === 'win32' ? ['--use-angle=d3d11'] : platform === 'darwin' ? ['--use-angle=metal'] : ['--use-angle=gl']),
95
+ '--force-high-performance-gpu', '--ignore-gpu-blocklist'] : []),
96
+ ...(!gpu && platform !== 'win32' ? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] : []),
97
+ ] };
98
+ }
99
+
100
+ export const ffmpegName = (platform = process.platform) => platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
101
+ export const ffmpegHint = (platform = process.platform) => `${platform === 'darwin' ? 'Run brew install ffmpeg' : platform === 'linux' ? 'Install FFmpeg with libx264 using your distribution package manager (for example: sudo apt-get install ffmpeg)' : 'Get FFmpeg with libx264 from https://ffmpeg.org/download.html'} and set --ffmpeg, MULTIPLANE_FFMPEG or PATH.`;
102
+ export const browserHint = (missing, platform = process.platform) => missing ? 'Run npx playwright install chromium.'
103
+ : platform === 'linux' ? 'Run npx playwright install-deps chromium, then npx playwright install chromium.' : 'Run npx playwright install chromium.';
104
+
105
+ export const stopSignals = (platform = process.platform) => platform === 'win32'
106
+ ? ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] : ['SIGINT', 'SIGTERM', 'SIGHUP'];
107
+ export function userHome(env = process.env, platform = process.platform, home = os.homedir()) {
108
+ const paths = platform === 'win32' ? path.win32 : path.posix;
109
+ if (env.MULTIPLANE_HOME) return paths.resolve(env.MULTIPLANE_HOME);
110
+ if (platform === 'win32') return paths.join(env.USERPROFILE || home, '.multiplane');
111
+ const base = platform === 'darwin' ? paths.join(home, 'Library', 'Application Support')
112
+ : (env.XDG_CONFIG_HOME || paths.join(home, '.config'));
113
+ return paths.join(base, 'multiplane');
114
+ }
@@ -1,12 +1,12 @@
1
- import { MultiplaneError } from './errors.mjs';
2
- import sharp from 'sharp';
3
-
4
- sharp.concurrency(1);
5
- sharp.cache(false);
6
-
7
- export async function decodeNativePNG(png) {
8
- if (png.length < 33 || !png.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) || png[24] !== 8 || ![2, 6].includes(png[25]) || png[26] || png[27] || png[28]) throw new MultiplaneError('internal_error', 'Only non-interlaced 8-bit RGB/RGBA PNG is supported.');
9
- const { data, info } = await sharp(png, { ignoreIcc: true }).ensureAlpha().raw({ depth: 'uchar' }).toBuffer({ resolveWithObject: true });
10
- if (info.channels !== 4 || data.length !== info.width * info.height * 4) throw new MultiplaneError('internal_error', 'Native PNG decoder returned an invalid format.');
11
- return { width: info.width, height: info.height, data };
12
- }
1
+ import { MultiplaneError } from './errors.mjs';
2
+ import sharp from 'sharp';
3
+
4
+ sharp.concurrency(1);
5
+ sharp.cache(false);
6
+
7
+ export async function decodeNativePNG(png) {
8
+ if (png.length < 33 || !png.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) || png[24] !== 8 || ![2, 6].includes(png[25]) || png[26] || png[27] || png[28]) throw new MultiplaneError('internal_error', 'Only non-interlaced 8-bit RGB/RGBA PNG is supported.');
9
+ const { data, info } = await sharp(png, { ignoreIcc: true }).ensureAlpha().raw({ depth: 'uchar' }).toBuffer({ resolveWithObject: true });
10
+ if (info.channels !== 4 || data.length !== info.width * info.height * 4) throw new MultiplaneError('internal_error', 'Native PNG decoder returned an invalid format.');
11
+ return { width: info.width, height: info.height, data };
12
+ }
package/src/png.mjs CHANGED
@@ -1,74 +1,74 @@
1
- import { MultiplaneError } from './errors.mjs';
2
- import { deflateSync, inflateSync, crc32 } from 'node:zlib';
3
-
4
- export function decodePNG(png) {
5
- if (!png.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) throw new MultiplaneError('internal_error', 'Invalid PNG.');
6
- let width, height, channels;
7
- const chunks = [];
8
- for (let offset = 8; offset < png.length;) {
9
- const size = png.readUInt32BE(offset), type = png.toString('ascii', offset + 4, offset + 8);
10
- const data = png.subarray(offset + 8, offset + 8 + size);
11
- if (type === 'IHDR') {
12
- width = data.readUInt32BE(0); height = data.readUInt32BE(4);
13
- if (data[8] !== 8 || ![2, 6].includes(data[9]) || data[10] || data[11] || data[12]) throw new MultiplaneError('internal_error', 'Only non-interlaced 8-bit RGB/RGBA PNG is supported.');
14
- channels = data[9] === 2 ? 3 : 4;
15
- }
16
- if (type === 'IDAT') chunks.push(data);
17
- offset += size + 12;
18
- }
19
- const raw = inflateSync(Buffer.concat(chunks)), stride = width * channels;
20
- if (raw.length !== (stride + 1) * height) throw new MultiplaneError('internal_error', 'PNG data length mismatch.');
21
- const line = stride + 1;
22
- for (let y = 0; y < height; y++) {
23
- const filter = raw[y * line];
24
- if (filter > 4) throw new MultiplaneError('internal_error', 'Invalid PNG row filter.');
25
- const row = y * line + 1;
26
- if (filter === 0) continue;
27
- if (filter === 1 || (filter === 4 && y === 0)) {
28
- for (let x = channels; x < stride; x++) raw[row + x] += raw[row + x - channels];
29
- continue;
30
- }
31
- if (filter === 2) {
32
- if (y) for (let x = 0; x < stride; x++) raw[row + x] += raw[row + x - line];
33
- continue;
34
- }
35
- if (filter === 3) {
36
- for (let x = 0; x < stride; x++) raw[row + x] += ((x >= channels ? raw[row + x - channels] : 0) + (y ? raw[row + x - line] : 0)) >>> 1;
37
- continue;
38
- }
39
- for (let x = 0; x < stride; x++) {
40
- const p = row + x;
41
- const a = x >= channels ? raw[p - channels] : 0;
42
- const b = raw[p - line];
43
- const c = x >= channels ? raw[p - line - channels] : 0;
44
- const predictor = a + b - c;
45
- const pa = Math.abs(predictor - a), pb = Math.abs(predictor - b), pc = Math.abs(predictor - c);
46
- const correction = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
47
- raw[p] += correction;
48
- }
49
- }
50
- const data = Buffer.alloc(width * height * 4);
51
- for (let y = 0; y < height; y++) {
52
- const row = y * line + 1;
53
- if (channels === 4) { raw.copy(data, y * stride, row, row + stride); continue; }
54
- for (let x = 0, j = y * width * 4; x < stride; x += 3, j += 4) {
55
- data[j] = raw[row + x]; data[j + 1] = raw[row + x + 1]; data[j + 2] = raw[row + x + 2]; data[j + 3] = 255;
56
- }
57
- }
58
- return { width, height, data };
59
- }
60
-
61
- function chunk(type, data) {
62
- const result = Buffer.alloc(data.length + 12);
63
- result.writeUInt32BE(data.length); result.write(type, 4, 'ascii'); data.copy(result, 8);
64
- result.writeUInt32BE(crc32(result.subarray(4, -4)), result.length - 4);
65
- return result;
66
- }
67
-
68
- export function encodePNG({ width, height, data }) {
69
- const header = Buffer.alloc(13);
70
- header.writeUInt32BE(width); header.writeUInt32BE(height, 4); header[8] = 8; header[9] = 6;
71
- const stride = width * 4, raw = Buffer.alloc((stride + 1) * height);
72
- for (let y = 0; y < height; y++) data.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
73
- return Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', header), chunk('IDAT', deflateSync(raw, { level: 1 })), chunk('IEND', Buffer.alloc(0))]);
74
- }
1
+ import { MultiplaneError } from './errors.mjs';
2
+ import { deflateSync, inflateSync, crc32 } from 'node:zlib';
3
+
4
+ export function decodePNG(png) {
5
+ if (!png.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) throw new MultiplaneError('internal_error', 'Invalid PNG.');
6
+ let width, height, channels;
7
+ const chunks = [];
8
+ for (let offset = 8; offset < png.length;) {
9
+ const size = png.readUInt32BE(offset), type = png.toString('ascii', offset + 4, offset + 8);
10
+ const data = png.subarray(offset + 8, offset + 8 + size);
11
+ if (type === 'IHDR') {
12
+ width = data.readUInt32BE(0); height = data.readUInt32BE(4);
13
+ if (data[8] !== 8 || ![2, 6].includes(data[9]) || data[10] || data[11] || data[12]) throw new MultiplaneError('internal_error', 'Only non-interlaced 8-bit RGB/RGBA PNG is supported.');
14
+ channels = data[9] === 2 ? 3 : 4;
15
+ }
16
+ if (type === 'IDAT') chunks.push(data);
17
+ offset += size + 12;
18
+ }
19
+ const raw = inflateSync(Buffer.concat(chunks)), stride = width * channels;
20
+ if (raw.length !== (stride + 1) * height) throw new MultiplaneError('internal_error', 'PNG data length mismatch.');
21
+ const line = stride + 1;
22
+ for (let y = 0; y < height; y++) {
23
+ const filter = raw[y * line];
24
+ if (filter > 4) throw new MultiplaneError('internal_error', 'Invalid PNG row filter.');
25
+ const row = y * line + 1;
26
+ if (filter === 0) continue;
27
+ if (filter === 1 || (filter === 4 && y === 0)) {
28
+ for (let x = channels; x < stride; x++) raw[row + x] += raw[row + x - channels];
29
+ continue;
30
+ }
31
+ if (filter === 2) {
32
+ if (y) for (let x = 0; x < stride; x++) raw[row + x] += raw[row + x - line];
33
+ continue;
34
+ }
35
+ if (filter === 3) {
36
+ for (let x = 0; x < stride; x++) raw[row + x] += ((x >= channels ? raw[row + x - channels] : 0) + (y ? raw[row + x - line] : 0)) >>> 1;
37
+ continue;
38
+ }
39
+ for (let x = 0; x < stride; x++) {
40
+ const p = row + x;
41
+ const a = x >= channels ? raw[p - channels] : 0;
42
+ const b = raw[p - line];
43
+ const c = x >= channels ? raw[p - line - channels] : 0;
44
+ const predictor = a + b - c;
45
+ const pa = Math.abs(predictor - a), pb = Math.abs(predictor - b), pc = Math.abs(predictor - c);
46
+ const correction = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
47
+ raw[p] += correction;
48
+ }
49
+ }
50
+ const data = Buffer.alloc(width * height * 4);
51
+ for (let y = 0; y < height; y++) {
52
+ const row = y * line + 1;
53
+ if (channels === 4) { raw.copy(data, y * stride, row, row + stride); continue; }
54
+ for (let x = 0, j = y * width * 4; x < stride; x += 3, j += 4) {
55
+ data[j] = raw[row + x]; data[j + 1] = raw[row + x + 1]; data[j + 2] = raw[row + x + 2]; data[j + 3] = 255;
56
+ }
57
+ }
58
+ return { width, height, data };
59
+ }
60
+
61
+ function chunk(type, data) {
62
+ const result = Buffer.alloc(data.length + 12);
63
+ result.writeUInt32BE(data.length); result.write(type, 4, 'ascii'); data.copy(result, 8);
64
+ result.writeUInt32BE(crc32(result.subarray(4, -4)), result.length - 4);
65
+ return result;
66
+ }
67
+
68
+ export function encodePNG({ width, height, data }) {
69
+ const header = Buffer.alloc(13);
70
+ header.writeUInt32BE(width); header.writeUInt32BE(height, 4); header[8] = 8; header[9] = 6;
71
+ const stride = width * 4, raw = Buffer.alloc((stride + 1) * height);
72
+ for (let y = 0; y < height; y++) data.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
73
+ return Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', header), chunk('IDAT', deflateSync(raw, { level: 1 })), chunk('IEND', Buffer.alloc(0))]);
74
+ }