multiplane 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -202
- package/README.en.md +3 -0
- package/README.md +3 -0
- package/THIRD_PARTY_NOTICES.md +30 -30
- package/bin/multiplane +24 -14
- package/examples/alpha.html +18 -18
- package/examples/animation.html +17 -17
- package/examples/assets/OFL.txt +93 -93
- package/examples/assets/card.svg +6 -6
- package/examples/three-cards.html +17 -17
- package/examples/typography.html +17 -17
- package/licenses/GPL-3.0.txt +232 -232
- package/licenses/LGPL-3.0.txt +304 -304
- package/licenses/acorn-LICENSE +21 -21
- package/licenses/colour-LICENSE.md +82 -82
- package/licenses/detect-libc-LICENSE +201 -201
- package/licenses/entities-LICENSE +11 -11
- package/licenses/nanoid-LICENSE +20 -20
- package/licenses/parse5-LICENSE +19 -19
- package/licenses/picocolors-LICENSE +15 -15
- package/licenses/playwright-NOTICE +5 -5
- package/licenses/playwright-ThirdPartyNotices.txt +14 -14
- package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
- package/licenses/postcss-LICENSE +20 -20
- package/licenses/postcss-value-parser-LICENSE +22 -22
- package/licenses/semver-LICENSE +15 -15
- package/licenses/sharp-LICENSE +191 -191
- package/licenses/sharp-win32-x64-NOTICES.md +45 -45
- package/licenses/source-map-js-LICENSE +28 -28
- package/licenses/three-LICENSE +21 -21
- package/package.json +92 -92
- package/skills/multiplane/SKILL.md +3 -0
- package/src/alpha.mjs +69 -69
- package/src/artifacts.mjs +61 -61
- package/src/browser-recovery.mjs +26 -26
- package/src/capture-watchdog.mjs +29 -0
- package/src/capture.mjs +34 -18
- package/src/encoder.mjs +16 -16
- package/src/environment.mjs +56 -56
- package/src/errors.mjs +1 -0
- package/src/events.mjs +12 -10
- package/src/ffmpeg.mjs +32 -32
- package/src/format.mjs +27 -27
- package/src/frame-rate.mjs +28 -28
- package/src/home-migration.mjs +54 -54
- package/src/hosted.mjs +3 -3
- package/src/index.mjs +1 -0
- package/src/input-records.mjs +33 -0
- package/src/jobs.mjs +3 -1
- package/src/lint.mjs +248 -248
- package/src/output-alpha.mjs +36 -36
- package/src/output-check.mjs +110 -110
- package/src/platform.mjs +114 -114
- package/src/png-native.mjs +12 -12
- package/src/png.mjs +74 -74
- package/src/post.mjs +56 -39
- package/src/preview.mjs +23 -23
- package/src/process.mjs +9 -9
- package/src/render-pool.mjs +41 -14
- package/src/render-worker.mjs +14 -3
- package/src/render.mjs +148 -52
- package/src/resource-path.mjs +29 -29
- package/src/resources.mjs +27 -27
- package/src/resume.mjs +23 -0
- package/src/runtime/camera.js +37 -37
- package/src/runtime/shot.css +9 -9
- package/src/segment-encoder.mjs +91 -0
- package/src/segments.mjs +123 -0
- package/src/server.mjs +11 -7
- package/src/setup.mjs +51 -51
- package/src/skills.mjs +128 -128
- package/src/stills.mjs +1 -1
- package/src/three/index.js +39 -31
- package/src/verify.mjs +2 -2
- package/src/video-pipe.mjs +6 -6
- package/tools/bench/shots/canvas-2d.html +1 -1
- package/tools/bench/shots/dom-light.html +1 -1
- package/tools/bench/shots/scene.js +44 -44
- package/tools/bench/shots/webgl.html +1 -1
- package/tools/install-browser.mjs +18 -18
package/src/segments.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync, statSync, fsyncSync } from 'node:fs';
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { fileDigest } from './input-records.mjs';
|
|
5
|
+
import { MultiplaneError } from './errors.mjs';
|
|
6
|
+
import { retryFileOperation } from './user-home.mjs';
|
|
7
|
+
|
|
8
|
+
export const unavailable = (reason, changes) => new MultiplaneError('resume_unavailable', undefined,
|
|
9
|
+
{ details: { reason, ...(changes ? { changes } : {}) } });
|
|
10
|
+
|
|
11
|
+
// TODO(design): Document the checkpoint schema and resume.json directory pointer.
|
|
12
|
+
|
|
13
|
+
export function atomicJSON(file, data) {
|
|
14
|
+
const temporary = file + '.tmp';
|
|
15
|
+
const fd = openSync(temporary, 'w');
|
|
16
|
+
try { writeFileSync(fd, JSON.stringify(data) + '\n'); fsyncSync(fd); } finally { closeSync(fd); }
|
|
17
|
+
retryFileOperation(() => renameSync(temporary, file), performance.now() + 1000);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function segmentDirectory(jobDir) {
|
|
21
|
+
try {
|
|
22
|
+
const { directory } = JSON.parse(readFileSync(path.join(jobDir, 'resume.json'), 'utf8'));
|
|
23
|
+
if (!path.isAbsolute(directory) || path.basename(directory) !== 'segments') throw Error('Invalid segment directory.');
|
|
24
|
+
return directory;
|
|
25
|
+
}
|
|
26
|
+
catch { return path.join(jobDir, 'segments'); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readManifest(directory) {
|
|
30
|
+
try {
|
|
31
|
+
const value = JSON.parse(readFileSync(path.join(directory, 'manifest.json'), 'utf8'));
|
|
32
|
+
if (value.schema !== 'multiplane-segments/1' || !value.options || !Array.isArray(value.segments) || !value.inputs) return null;
|
|
33
|
+
if (typeof value.shot !== 'string' || !Number.isSafeInteger(value.first) || value.first < 0
|
|
34
|
+
|| !Number.isSafeInteger(value.end) || value.end < value.first || value.total !== value.end - value.first
|
|
35
|
+
|| (value.segmentFrames !== null && (!Number.isSafeInteger(value.segmentFrames) || value.segmentFrames < 1))) return null;
|
|
36
|
+
const starts = new Set();
|
|
37
|
+
for (const segment of value.segments) {
|
|
38
|
+
if (!Number.isSafeInteger(segment.first) || segment.first < value.first || segment.first >= value.end
|
|
39
|
+
|| (segment.first - value.first) % value.segmentFrames !== 0
|
|
40
|
+
|| segment.end !== Math.min(value.end, segment.first + value.segmentFrames)
|
|
41
|
+
|| starts.has(segment.first) || typeof segment.file !== 'string' || segment.file !== path.basename(segment.file)) return null;
|
|
42
|
+
starts.add(segment.first);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
} catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function lockSegments(directory) {
|
|
49
|
+
mkdirSync(directory, { recursive: true });
|
|
50
|
+
const file = path.join(directory, 'render.lock');
|
|
51
|
+
for (;;) {
|
|
52
|
+
let fd;
|
|
53
|
+
try { fd = openSync(file, 'wx'); }
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== 'EEXIST') throw error;
|
|
56
|
+
try {
|
|
57
|
+
const before = statSync(file);
|
|
58
|
+
let owner;
|
|
59
|
+
try { owner = JSON.parse(readFileSync(file, 'utf8')); } catch { /* Do not steal a lock being written. */ }
|
|
60
|
+
let dead = false;
|
|
61
|
+
if (Number.isSafeInteger(owner?.pid) && owner.pid > 0) {
|
|
62
|
+
try { process.kill(owner.pid, 0); } catch (error) { dead = error.code === 'ESRCH'; }
|
|
63
|
+
}
|
|
64
|
+
if (dead || (!owner && Date.now() - before.mtimeMs > 10000)) {
|
|
65
|
+
const after = statSync(file);
|
|
66
|
+
if (before.ino === after.ino && before.mtimeMs === after.mtimeMs) unlinkSync(file);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
} catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
70
|
+
throw unavailable('running');
|
|
71
|
+
}
|
|
72
|
+
try { writeFileSync(fd, JSON.stringify({ pid: process.pid })); fsyncSync(fd); } finally { closeSync(fd); }
|
|
73
|
+
return () => { try { unlinkSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; } };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function manifestStore(directory, manifest) {
|
|
78
|
+
const save = () => atomicJSON(path.join(directory, 'manifest.json'), manifest);
|
|
79
|
+
return { manifest, save,
|
|
80
|
+
input(file, record) {
|
|
81
|
+
const previous = manifest.inputs[file];
|
|
82
|
+
if (previous && previous.sha256 !== record.sha256) throw unavailable('inputs_changed', [file]);
|
|
83
|
+
if (!previous) { manifest.inputs[file] = record; save(); }
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function checkInputs(manifest) {
|
|
89
|
+
const changes = [];
|
|
90
|
+
for (const [file, expected] of Object.entries(manifest.inputs)) {
|
|
91
|
+
try { if (await fileDigest(file) !== expected.sha256) changes.push(file); }
|
|
92
|
+
catch { changes.push(file); }
|
|
93
|
+
}
|
|
94
|
+
if (changes.length) throw unavailable('inputs_changed', changes);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function checkEnvironment(expected, actual) {
|
|
98
|
+
if (!expected) return;
|
|
99
|
+
const changes = Object.keys(actual).filter(key => key !== 'renderer' && JSON.stringify(expected[key]) !== JSON.stringify(actual[key]));
|
|
100
|
+
if (changes.length) throw unavailable('environment_changed', changes);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function reusableSegments(directory, manifest) {
|
|
104
|
+
const valid = [];
|
|
105
|
+
for (const segment of manifest.segments) {
|
|
106
|
+
if (segment.file !== path.basename(segment.file)) continue;
|
|
107
|
+
try {
|
|
108
|
+
const file = path.join(directory, segment.file);
|
|
109
|
+
if ((await stat(file)).size === segment.bytes && await fileDigest(file) === segment.sha256) valid.push(segment);
|
|
110
|
+
} catch { /* Missing or damaged segments are rendered again. */ }
|
|
111
|
+
}
|
|
112
|
+
return valid;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function resumeStatus(status, directory) {
|
|
116
|
+
const manifest = readManifest(segmentDirectory(directory));
|
|
117
|
+
// TODO(design): Specify missing_manifest for legacy, missing or invalid checkpoints.
|
|
118
|
+
const reason = status.state === 'succeeded' ? 'succeeded' : !['failed', 'cancelled'].includes(status.state) ? 'running' : !manifest ? 'missing_manifest' : null;
|
|
119
|
+
return { available: reason === null, reason,
|
|
120
|
+
framesDone: manifest?.segments.reduce((sum, segment) => sum + segment.end - segment.first, 0)
|
|
121
|
+
?? (status.state === 'succeeded' ? status.result?.data?.frames ?? status.done ?? 0 : 0),
|
|
122
|
+
framesTotal: manifest?.total ?? status.total ?? status.result?.data?.frames ?? 0 };
|
|
123
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { MultiplaneError, invalidOption } from './errors.mjs';
|
|
8
8
|
import { resolveThree } from './three-package.mjs';
|
|
9
|
+
import { recordInput } from './input-records.mjs';
|
|
9
10
|
|
|
10
11
|
export const root = process.cwd();
|
|
11
12
|
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
@@ -61,16 +62,17 @@ export async function startServer(port = 0, root = process.cwd()) {
|
|
|
61
62
|
}
|
|
62
63
|
let target = path.resolve(resourceRoot, `.${resourcePath}`);
|
|
63
64
|
if (!await isInsideResourceRoot(resourceRoot, target)) { res.writeHead(403); return res.end(); }
|
|
64
|
-
let targetInfo;
|
|
65
|
-
try { targetInfo = await stat(target); }
|
|
66
|
-
catch (error) {
|
|
67
|
-
if (pathname === '/favicon.ico' && error.code === 'ENOENT') { res.writeHead(204); return res.end(); }
|
|
68
|
-
throw error;
|
|
69
|
-
}
|
|
70
|
-
if (targetInfo.isDirectory()) target = path.join(target, 'index.html');
|
|
65
|
+
let targetInfo;
|
|
66
|
+
try { targetInfo = await stat(target); }
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (pathname === '/favicon.ico' && error.code === 'ENOENT') { res.writeHead(204); return res.end(); }
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
if (targetInfo.isDirectory()) target = path.join(target, 'index.html');
|
|
71
72
|
target = await normalizeResourcePath(target);
|
|
72
73
|
if (!await isInsideResourceRoot(resourceBase, target)) { res.writeHead(403); return res.end(); }
|
|
73
74
|
const info = await stat(target);
|
|
75
|
+
const recorded = await recordInput(target);
|
|
74
76
|
const contentType = { '.wav': 'audio/wav', '.srt': 'text/plain; charset=utf-8', '.mp4': 'video/mp4' }[path.extname(target)] ?? mime[path.extname(target)] ?? 'application/octet-stream';
|
|
75
77
|
const headers = { 'Content-Type': contentType, 'Content-Length': info.size, 'Accept-Ranges': 'bytes' };
|
|
76
78
|
if (req.headers.range) {
|
|
@@ -82,10 +84,12 @@ export async function startServer(port = 0, root = process.cwd()) {
|
|
|
82
84
|
}
|
|
83
85
|
res.writeHead(206, { ...headers, 'Content-Length': end - start + 1, 'Content-Range': `bytes ${start}-${end}/${info.size}` });
|
|
84
86
|
if (req.method === 'HEAD') return res.end();
|
|
87
|
+
if (recorded) return res.end(recorded.subarray(start, end + 1));
|
|
85
88
|
return createReadStream(target, { start, end }).on('error', () => res.destroy()).pipe(res);
|
|
86
89
|
}
|
|
87
90
|
res.writeHead(200, headers);
|
|
88
91
|
if (req.method === 'HEAD') return res.end();
|
|
92
|
+
if (recorded) return res.end(recorded);
|
|
89
93
|
createReadStream(target).on('error', () => res.destroy()).pipe(res);
|
|
90
94
|
} catch (error) {
|
|
91
95
|
if (error.code === 'ERR_MODULE_NOT_FOUND') { res.writeHead(503, { 'x-multiplane-error': 'three_not_installed' }); return res.end('Optional three.js is not installed.'); }
|
package/src/setup.mjs
CHANGED
|
@@ -1,51 +1,51 @@
|
|
|
1
|
-
import { fileURLToPath } from 'node:url';
|
|
2
|
-
import { Configuration } from './config.mjs';
|
|
3
|
-
import { Skills } from './skills.mjs';
|
|
4
|
-
import { createEvents, version } from './events.mjs';
|
|
5
|
-
import { asError, invalidOption, MultiplaneError } from './errors.mjs';
|
|
6
|
-
|
|
7
|
-
export async function setup(command, args, onEvent) {
|
|
8
|
-
const events = createEvents(command, null, { onEvent });
|
|
9
|
-
events.start();
|
|
10
|
-
try {
|
|
11
|
-
const positional = [], options = {};
|
|
12
|
-
for (let i = 0; i < args.length; i++) {
|
|
13
|
-
const token = args[i];
|
|
14
|
-
if (!token.startsWith('--')) { positional.push(token); continue; }
|
|
15
|
-
const key = token.slice(2);
|
|
16
|
-
if (!['json', ...(command === 'skill' ? ['host', 'force', 'expect'] : [])].includes(key) || key in options) throw invalidOption(token, 'Invalid or duplicate option.');
|
|
17
|
-
if (['json', 'force'].includes(key)) options[key] = true;
|
|
18
|
-
else { if (args[i + 1] === undefined || args[i + 1].startsWith('--')) throw invalidOption(token, 'Missing option value.'); options[key] = args[++i]; }
|
|
19
|
-
}
|
|
20
|
-
const [action, key, value] = positional;
|
|
21
|
-
let data;
|
|
22
|
-
if (command === 'config') {
|
|
23
|
-
if (!['get', 'set', 'unset'].includes(action) || (action === 'get' ? positional.length > 2 : positional.length !== (action === 'set' ? 3 : 2))) throw invalidOption('action', 'Use config get [key], set <key> <value>, or unset <key>.');
|
|
24
|
-
const config = new Configuration(undefined, process.env, warning => events.warning(warning));
|
|
25
|
-
data = action === 'get' ? await config.read(key) : await config.save(key, action === 'unset' ? null : value);
|
|
26
|
-
if (data.check && !data.check.usable) events.warning({ code: 'ffmpeg_unusable', message: 'The saved FFmpeg path is unusable.', details: { reason: data.check.reason } });
|
|
27
|
-
} else {
|
|
28
|
-
if (!['status', 'install', 'remove'].includes(action) || positional.length !== 1 || (action !== 'status' && !options.host) || !['all', 'claude', 'codex'].includes(options.host ?? 'all') || (action === 'status' && ('force' in options || 'expect' in options))) throw invalidOption('host', 'Use skill status, install, or remove with --host claude|codex|all.');
|
|
29
|
-
const skills = new Skills(fileURLToPath(new URL('../skills/multiplane/', import.meta.url)), version);
|
|
30
|
-
const hosts = await skills.list(!options.host || options.host === 'all' ? ['claude', 'codex'] : [options.host]);
|
|
31
|
-
if (action === 'status') data = { hosts };
|
|
32
|
-
else {
|
|
33
|
-
data = { hosts: [] }; let first;
|
|
34
|
-
for (const status of hosts) {
|
|
35
|
-
const item = { host: status.host, action, ok: true, code: null, message: null, target: status.target, version: action === 'install' ? version : null, backup: null };
|
|
36
|
-
try {
|
|
37
|
-
if (status.blocked) throw new MultiplaneError('skill_blocked', status.error);
|
|
38
|
-
// TODO(design): Document --expect null for the missing-copy signature.
|
|
39
|
-
const expected = options.expect === 'null' ? null : options.expect;
|
|
40
|
-
Object.assign(item, await skills.change(status.host, action, expected, undefined, !!options.force));
|
|
41
|
-
} catch (failure) { const error = asError(failure); first ??= error; Object.assign(item, { ok: false, code: error.code, message: error.message, version: status.version }); }
|
|
42
|
-
data.hosts.push(item);
|
|
43
|
-
}
|
|
44
|
-
if (first) { first.result = { data }; throw first; }
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return events.finish(data);
|
|
48
|
-
} catch (failure) {
|
|
49
|
-
const error = asError(failure); error.result = events.finish(error.result?.data ?? {}, error); throw error;
|
|
50
|
-
} finally { events.dispose(); }
|
|
51
|
-
}
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { Configuration } from './config.mjs';
|
|
3
|
+
import { Skills } from './skills.mjs';
|
|
4
|
+
import { createEvents, version } from './events.mjs';
|
|
5
|
+
import { asError, invalidOption, MultiplaneError } from './errors.mjs';
|
|
6
|
+
|
|
7
|
+
export async function setup(command, args, onEvent) {
|
|
8
|
+
const events = createEvents(command, null, { onEvent });
|
|
9
|
+
events.start();
|
|
10
|
+
try {
|
|
11
|
+
const positional = [], options = {};
|
|
12
|
+
for (let i = 0; i < args.length; i++) {
|
|
13
|
+
const token = args[i];
|
|
14
|
+
if (!token.startsWith('--')) { positional.push(token); continue; }
|
|
15
|
+
const key = token.slice(2);
|
|
16
|
+
if (!['json', ...(command === 'skill' ? ['host', 'force', 'expect'] : [])].includes(key) || key in options) throw invalidOption(token, 'Invalid or duplicate option.');
|
|
17
|
+
if (['json', 'force'].includes(key)) options[key] = true;
|
|
18
|
+
else { if (args[i + 1] === undefined || args[i + 1].startsWith('--')) throw invalidOption(token, 'Missing option value.'); options[key] = args[++i]; }
|
|
19
|
+
}
|
|
20
|
+
const [action, key, value] = positional;
|
|
21
|
+
let data;
|
|
22
|
+
if (command === 'config') {
|
|
23
|
+
if (!['get', 'set', 'unset'].includes(action) || (action === 'get' ? positional.length > 2 : positional.length !== (action === 'set' ? 3 : 2))) throw invalidOption('action', 'Use config get [key], set <key> <value>, or unset <key>.');
|
|
24
|
+
const config = new Configuration(undefined, process.env, warning => events.warning(warning));
|
|
25
|
+
data = action === 'get' ? await config.read(key) : await config.save(key, action === 'unset' ? null : value);
|
|
26
|
+
if (data.check && !data.check.usable) events.warning({ code: 'ffmpeg_unusable', message: 'The saved FFmpeg path is unusable.', details: { reason: data.check.reason } });
|
|
27
|
+
} else {
|
|
28
|
+
if (!['status', 'install', 'remove'].includes(action) || positional.length !== 1 || (action !== 'status' && !options.host) || !['all', 'claude', 'codex'].includes(options.host ?? 'all') || (action === 'status' && ('force' in options || 'expect' in options))) throw invalidOption('host', 'Use skill status, install, or remove with --host claude|codex|all.');
|
|
29
|
+
const skills = new Skills(fileURLToPath(new URL('../skills/multiplane/', import.meta.url)), version);
|
|
30
|
+
const hosts = await skills.list(!options.host || options.host === 'all' ? ['claude', 'codex'] : [options.host]);
|
|
31
|
+
if (action === 'status') data = { hosts };
|
|
32
|
+
else {
|
|
33
|
+
data = { hosts: [] }; let first;
|
|
34
|
+
for (const status of hosts) {
|
|
35
|
+
const item = { host: status.host, action, ok: true, code: null, message: null, target: status.target, version: action === 'install' ? version : null, backup: null };
|
|
36
|
+
try {
|
|
37
|
+
if (status.blocked) throw new MultiplaneError('skill_blocked', status.error);
|
|
38
|
+
// TODO(design): Document --expect null for the missing-copy signature.
|
|
39
|
+
const expected = options.expect === 'null' ? null : options.expect;
|
|
40
|
+
Object.assign(item, await skills.change(status.host, action, expected, undefined, !!options.force));
|
|
41
|
+
} catch (failure) { const error = asError(failure); first ??= error; Object.assign(item, { ok: false, code: error.code, message: error.message, version: status.version }); }
|
|
42
|
+
data.hosts.push(item);
|
|
43
|
+
}
|
|
44
|
+
if (first) { first.result = { data }; throw first; }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return events.finish(data);
|
|
48
|
+
} catch (failure) {
|
|
49
|
+
const error = asError(failure); error.result = events.finish(error.result?.data ?? {}, error); throw error;
|
|
50
|
+
} finally { events.dispose(); }
|
|
51
|
+
}
|
package/src/skills.mjs
CHANGED
|
@@ -1,128 +1,128 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import * as fs from 'node:fs/promises';
|
|
4
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
-
import { MultiplaneError } from './errors.mjs';
|
|
6
|
-
export async function findCommand(name, env = process.env) {
|
|
7
|
-
const extensions = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
|
|
8
|
-
const search = Object.entries(env).find(([k]) => k.toLowerCase() === 'path')?.[1] ?? '';
|
|
9
|
-
for (const dir of search.split(path.delimiter).filter(Boolean)) for (const ext of extensions) {
|
|
10
|
-
const file = path.resolve(dir.replace(/^"|"$/g, ''), name + ext);
|
|
11
|
-
try { if ((await fs.stat(file)).isFile()) { await fs.access(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK); return file; } } catch {}
|
|
12
|
-
}
|
|
13
|
-
return null;
|
|
14
|
-
}
|
|
15
|
-
const marker = '.multiplane.json';
|
|
16
|
-
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
17
|
-
async function exists(file) { try { await fs.lstat(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
|
|
18
|
-
// Resolve existing ancestors without creating directories during inspection.
|
|
19
|
-
async function realRoot(root) {
|
|
20
|
-
try {
|
|
21
|
-
const resolved = await fs.realpath(root);
|
|
22
|
-
if (!(await fs.stat(resolved)).isDirectory()) throw Object.assign(Error('Skill root is not a directory'), { code: 'ENOTDIR' });
|
|
23
|
-
return resolved;
|
|
24
|
-
}
|
|
25
|
-
catch (error) {
|
|
26
|
-
if (error.code !== 'ENOENT' || await exists(root)) throw error;
|
|
27
|
-
const parent = path.dirname(root);
|
|
28
|
-
if (parent === root) throw error;
|
|
29
|
-
return path.join(await realRoot(parent), path.basename(root));
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
const linkedSkill = () => Object.assign(Error('Skill directories cannot contain symbolic links'), { code: 'SKILL_LINK' });
|
|
33
|
-
export async function treeHashes(root) {
|
|
34
|
-
const files = Object.create(null);
|
|
35
|
-
async function walk(dir, prefix = '') {
|
|
36
|
-
if ((await fs.lstat(dir)).isSymbolicLink()) throw linkedSkill();
|
|
37
|
-
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : 1)) {
|
|
38
|
-
const name = prefix + entry.name;
|
|
39
|
-
if (entry.isSymbolicLink()) throw linkedSkill();
|
|
40
|
-
if (entry.isDirectory()) { files[name + '/'] = null; await walk(path.join(dir, entry.name), name + '/'); }
|
|
41
|
-
else if (entry.isFile()) { if (name !== marker) files[name] = hash(await fs.readFile(path.join(dir, entry.name))); }
|
|
42
|
-
else throw Object.assign(Error('Unsupported skill file type'), { code: 'SKILL_LINK' });
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
await walk(root); return files;
|
|
46
|
-
}
|
|
47
|
-
export class Skills {
|
|
48
|
-
queue = Promise.resolve();
|
|
49
|
-
constructor(source, version, { user = process.env.MULTIPLANE_AGENT_HOME || os.homedir(), env = process.env } = {}) { this.source = source; this.version = version; this.user = user; this.env = env; }
|
|
50
|
-
target(host) {
|
|
51
|
-
if (!['claude', 'codex'].includes(host)) throw Error('Invalid agent');
|
|
52
|
-
return path.join(this.user, host === 'claude' ? '.claude' : '.agents', 'skills', 'multiplane');
|
|
53
|
-
}
|
|
54
|
-
async status(host) {
|
|
55
|
-
const target = path.join(await realRoot(path.dirname(this.target(host))), 'multiplane');
|
|
56
|
-
const detected = await exists(path.join(this.user, host === 'claude' ? '.claude' : '.codex')) || !!await findCommand(host, this.env);
|
|
57
|
-
const base = { host, target, detected, availableVersion: this.version, version: null, state: 'missing', signature: null, blocked: false, error: null };
|
|
58
|
-
if (!await exists(target)) return base;
|
|
59
|
-
let files;
|
|
60
|
-
try { files = await treeHashes(target); }
|
|
61
|
-
catch (error) { if (error.code === 'SKILL_LINK') return { ...base, state: 'modified', blocked: true, error: error.message }; throw error; }
|
|
62
|
-
const signature = hash(JSON.stringify(files));
|
|
63
|
-
let record;
|
|
64
|
-
try { record = JSON.parse(await fs.readFile(path.join(target, marker), 'utf8')); } catch {}
|
|
65
|
-
const current = await treeHashes(this.source);
|
|
66
|
-
const known = record?.v === 1 && record.host === host && record.skill === 'multiplane' && Array.isArray(record.releases)
|
|
67
|
-
? record.releases.filter(r => r && typeof r.version === 'string' && r.files && typeof r.files === 'object' && !Array.isArray(r.files)) : [];
|
|
68
|
-
const release = [{ version: this.version, files: current }, ...known].find(r => JSON.stringify(r.files) === JSON.stringify(files));
|
|
69
|
-
return { ...base, signature, version: release?.version ?? record?.version ?? null, state: release ? release.version === this.version ? 'latest' : 'update' : 'modified' };
|
|
70
|
-
}
|
|
71
|
-
list(hosts = ['claude', 'codex']) { return Promise.all(hosts.map(async host => {
|
|
72
|
-
try { return await this.status(host); }
|
|
73
|
-
catch (error) { return { host, target: this.target(host), detected: false, availableVersion: this.version, version: null, state: 'error', blocked: true, error: error.message }; }
|
|
74
|
-
})); }
|
|
75
|
-
async backupName(host, date = new Date()) {
|
|
76
|
-
const pad = n => String(n).padStart(2, '0');
|
|
77
|
-
const base = `multiplane.bak-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
78
|
-
const parent = await realRoot(path.dirname(this.target(host)));
|
|
79
|
-
let name = base;
|
|
80
|
-
for (let i = 2; await exists(path.join(parent, name)); i++) name = `${base}-${i}`;
|
|
81
|
-
return name;
|
|
82
|
-
}
|
|
83
|
-
change(host, action, expected, backupName, force = false) {
|
|
84
|
-
const operation = this.queue.then(async () => {
|
|
85
|
-
if (!['install', 'remove'].includes(action)) throw Error('Invalid skill action');
|
|
86
|
-
backupName ??= await this.backupName(host);
|
|
87
|
-
if (!/^multiplane\.bak-\d{8}-\d{6}(?:-[2-9]\d*|-1\d+)?$/.test(backupName)) throw Error('Invalid backup name');
|
|
88
|
-
const before = await this.status(host);
|
|
89
|
-
if (before.blocked) throw new MultiplaneError('skill_blocked', before.error);
|
|
90
|
-
if (expected !== undefined && before.signature !== expected) throw new MultiplaneError('skill_changed');
|
|
91
|
-
if (before.state === 'modified' && !force) throw new MultiplaneError('skill_modified', undefined, { hint: 'Use --force to back up the modified directory before continuing.' });
|
|
92
|
-
const parent = path.dirname(before.target);
|
|
93
|
-
await fs.mkdir(parent, { recursive: true });
|
|
94
|
-
if (await fs.realpath(parent) !== parent) throw Error('Skill path changed');
|
|
95
|
-
const token = randomUUID(), stage = path.join(parent, '.multiplane-new-' + token);
|
|
96
|
-
const old = path.join(parent, backupName);
|
|
97
|
-
if (await exists(old)) throw Error('Backup already exists');
|
|
98
|
-
let moved = false, installed = false;
|
|
99
|
-
const remove = async target => {
|
|
100
|
-
if (path.dirname(path.resolve(target)) !== path.resolve(parent) || path.resolve(target) === path.resolve(parent)) throw Error('Invalid removal target');
|
|
101
|
-
await fs.rm(target, { recursive: true, force: true });
|
|
102
|
-
};
|
|
103
|
-
try {
|
|
104
|
-
if (action === 'install') {
|
|
105
|
-
const files = await treeHashes(this.source);
|
|
106
|
-
await fs.cp(this.source, stage, { recursive: true });
|
|
107
|
-
let previous;
|
|
108
|
-
try { previous = JSON.parse(await fs.readFile(path.join(before.target, marker), 'utf8')); } catch {}
|
|
109
|
-
const releases = previous?.v === 1 && previous.host === host && Array.isArray(previous.releases)
|
|
110
|
-
? previous.releases.filter(r => r && typeof r.version === 'string' && r.files && typeof r.files === 'object' && !Array.isArray(r.files)) : [];
|
|
111
|
-
await fs.writeFile(path.join(stage, marker), JSON.stringify({ v: 1, host, skill: 'multiplane', version: this.version, installedAt: Date.now(), files, releases: [...releases.filter(r => r.version !== this.version), { version: this.version, files }] }, null, 2));
|
|
112
|
-
if (JSON.stringify(await treeHashes(stage)) !== JSON.stringify(files)) throw Error('Skill source changed during copy');
|
|
113
|
-
}
|
|
114
|
-
const latest = await this.status(host);
|
|
115
|
-
if (latest.blocked || latest.target !== before.target || latest.signature !== before.signature || await fs.realpath(parent) !== parent) throw new MultiplaneError('skill_changed');
|
|
116
|
-
if (before.state !== 'missing') { await fs.rename(before.target, old); moved = true; }
|
|
117
|
-
if (action === 'install') { await fs.rename(stage, before.target); installed = true; }
|
|
118
|
-
} catch (error) {
|
|
119
|
-
if (installed) await remove(before.target);
|
|
120
|
-
if (moved) await fs.rename(old, before.target);
|
|
121
|
-
throw error;
|
|
122
|
-
} finally { await remove(stage); }
|
|
123
|
-
if (moved && before.state !== 'modified') await remove(old);
|
|
124
|
-
return { backup: moved && before.state === 'modified' ? old : null };
|
|
125
|
-
});
|
|
126
|
-
this.queue = operation.catch(() => {}); return operation;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { MultiplaneError } from './errors.mjs';
|
|
6
|
+
export async function findCommand(name, env = process.env) {
|
|
7
|
+
const extensions = process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
|
|
8
|
+
const search = Object.entries(env).find(([k]) => k.toLowerCase() === 'path')?.[1] ?? '';
|
|
9
|
+
for (const dir of search.split(path.delimiter).filter(Boolean)) for (const ext of extensions) {
|
|
10
|
+
const file = path.resolve(dir.replace(/^"|"$/g, ''), name + ext);
|
|
11
|
+
try { if ((await fs.stat(file)).isFile()) { await fs.access(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK); return file; } } catch {}
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const marker = '.multiplane.json';
|
|
16
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
17
|
+
async function exists(file) { try { await fs.lstat(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
|
|
18
|
+
// Resolve existing ancestors without creating directories during inspection.
|
|
19
|
+
async function realRoot(root) {
|
|
20
|
+
try {
|
|
21
|
+
const resolved = await fs.realpath(root);
|
|
22
|
+
if (!(await fs.stat(resolved)).isDirectory()) throw Object.assign(Error('Skill root is not a directory'), { code: 'ENOTDIR' });
|
|
23
|
+
return resolved;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code !== 'ENOENT' || await exists(root)) throw error;
|
|
27
|
+
const parent = path.dirname(root);
|
|
28
|
+
if (parent === root) throw error;
|
|
29
|
+
return path.join(await realRoot(parent), path.basename(root));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const linkedSkill = () => Object.assign(Error('Skill directories cannot contain symbolic links'), { code: 'SKILL_LINK' });
|
|
33
|
+
export async function treeHashes(root) {
|
|
34
|
+
const files = Object.create(null);
|
|
35
|
+
async function walk(dir, prefix = '') {
|
|
36
|
+
if ((await fs.lstat(dir)).isSymbolicLink()) throw linkedSkill();
|
|
37
|
+
for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : 1)) {
|
|
38
|
+
const name = prefix + entry.name;
|
|
39
|
+
if (entry.isSymbolicLink()) throw linkedSkill();
|
|
40
|
+
if (entry.isDirectory()) { files[name + '/'] = null; await walk(path.join(dir, entry.name), name + '/'); }
|
|
41
|
+
else if (entry.isFile()) { if (name !== marker) files[name] = hash(await fs.readFile(path.join(dir, entry.name))); }
|
|
42
|
+
else throw Object.assign(Error('Unsupported skill file type'), { code: 'SKILL_LINK' });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
await walk(root); return files;
|
|
46
|
+
}
|
|
47
|
+
export class Skills {
|
|
48
|
+
queue = Promise.resolve();
|
|
49
|
+
constructor(source, version, { user = process.env.MULTIPLANE_AGENT_HOME || os.homedir(), env = process.env } = {}) { this.source = source; this.version = version; this.user = user; this.env = env; }
|
|
50
|
+
target(host) {
|
|
51
|
+
if (!['claude', 'codex'].includes(host)) throw Error('Invalid agent');
|
|
52
|
+
return path.join(this.user, host === 'claude' ? '.claude' : '.agents', 'skills', 'multiplane');
|
|
53
|
+
}
|
|
54
|
+
async status(host) {
|
|
55
|
+
const target = path.join(await realRoot(path.dirname(this.target(host))), 'multiplane');
|
|
56
|
+
const detected = await exists(path.join(this.user, host === 'claude' ? '.claude' : '.codex')) || !!await findCommand(host, this.env);
|
|
57
|
+
const base = { host, target, detected, availableVersion: this.version, version: null, state: 'missing', signature: null, blocked: false, error: null };
|
|
58
|
+
if (!await exists(target)) return base;
|
|
59
|
+
let files;
|
|
60
|
+
try { files = await treeHashes(target); }
|
|
61
|
+
catch (error) { if (error.code === 'SKILL_LINK') return { ...base, state: 'modified', blocked: true, error: error.message }; throw error; }
|
|
62
|
+
const signature = hash(JSON.stringify(files));
|
|
63
|
+
let record;
|
|
64
|
+
try { record = JSON.parse(await fs.readFile(path.join(target, marker), 'utf8')); } catch {}
|
|
65
|
+
const current = await treeHashes(this.source);
|
|
66
|
+
const known = record?.v === 1 && record.host === host && record.skill === 'multiplane' && Array.isArray(record.releases)
|
|
67
|
+
? record.releases.filter(r => r && typeof r.version === 'string' && r.files && typeof r.files === 'object' && !Array.isArray(r.files)) : [];
|
|
68
|
+
const release = [{ version: this.version, files: current }, ...known].find(r => JSON.stringify(r.files) === JSON.stringify(files));
|
|
69
|
+
return { ...base, signature, version: release?.version ?? record?.version ?? null, state: release ? release.version === this.version ? 'latest' : 'update' : 'modified' };
|
|
70
|
+
}
|
|
71
|
+
list(hosts = ['claude', 'codex']) { return Promise.all(hosts.map(async host => {
|
|
72
|
+
try { return await this.status(host); }
|
|
73
|
+
catch (error) { return { host, target: this.target(host), detected: false, availableVersion: this.version, version: null, state: 'error', blocked: true, error: error.message }; }
|
|
74
|
+
})); }
|
|
75
|
+
async backupName(host, date = new Date()) {
|
|
76
|
+
const pad = n => String(n).padStart(2, '0');
|
|
77
|
+
const base = `multiplane.bak-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
|
78
|
+
const parent = await realRoot(path.dirname(this.target(host)));
|
|
79
|
+
let name = base;
|
|
80
|
+
for (let i = 2; await exists(path.join(parent, name)); i++) name = `${base}-${i}`;
|
|
81
|
+
return name;
|
|
82
|
+
}
|
|
83
|
+
change(host, action, expected, backupName, force = false) {
|
|
84
|
+
const operation = this.queue.then(async () => {
|
|
85
|
+
if (!['install', 'remove'].includes(action)) throw Error('Invalid skill action');
|
|
86
|
+
backupName ??= await this.backupName(host);
|
|
87
|
+
if (!/^multiplane\.bak-\d{8}-\d{6}(?:-[2-9]\d*|-1\d+)?$/.test(backupName)) throw Error('Invalid backup name');
|
|
88
|
+
const before = await this.status(host);
|
|
89
|
+
if (before.blocked) throw new MultiplaneError('skill_blocked', before.error);
|
|
90
|
+
if (expected !== undefined && before.signature !== expected) throw new MultiplaneError('skill_changed');
|
|
91
|
+
if (before.state === 'modified' && !force) throw new MultiplaneError('skill_modified', undefined, { hint: 'Use --force to back up the modified directory before continuing.' });
|
|
92
|
+
const parent = path.dirname(before.target);
|
|
93
|
+
await fs.mkdir(parent, { recursive: true });
|
|
94
|
+
if (await fs.realpath(parent) !== parent) throw Error('Skill path changed');
|
|
95
|
+
const token = randomUUID(), stage = path.join(parent, '.multiplane-new-' + token);
|
|
96
|
+
const old = path.join(parent, backupName);
|
|
97
|
+
if (await exists(old)) throw Error('Backup already exists');
|
|
98
|
+
let moved = false, installed = false;
|
|
99
|
+
const remove = async target => {
|
|
100
|
+
if (path.dirname(path.resolve(target)) !== path.resolve(parent) || path.resolve(target) === path.resolve(parent)) throw Error('Invalid removal target');
|
|
101
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
if (action === 'install') {
|
|
105
|
+
const files = await treeHashes(this.source);
|
|
106
|
+
await fs.cp(this.source, stage, { recursive: true });
|
|
107
|
+
let previous;
|
|
108
|
+
try { previous = JSON.parse(await fs.readFile(path.join(before.target, marker), 'utf8')); } catch {}
|
|
109
|
+
const releases = previous?.v === 1 && previous.host === host && Array.isArray(previous.releases)
|
|
110
|
+
? previous.releases.filter(r => r && typeof r.version === 'string' && r.files && typeof r.files === 'object' && !Array.isArray(r.files)) : [];
|
|
111
|
+
await fs.writeFile(path.join(stage, marker), JSON.stringify({ v: 1, host, skill: 'multiplane', version: this.version, installedAt: Date.now(), files, releases: [...releases.filter(r => r.version !== this.version), { version: this.version, files }] }, null, 2));
|
|
112
|
+
if (JSON.stringify(await treeHashes(stage)) !== JSON.stringify(files)) throw Error('Skill source changed during copy');
|
|
113
|
+
}
|
|
114
|
+
const latest = await this.status(host);
|
|
115
|
+
if (latest.blocked || latest.target !== before.target || latest.signature !== before.signature || await fs.realpath(parent) !== parent) throw new MultiplaneError('skill_changed');
|
|
116
|
+
if (before.state !== 'missing') { await fs.rename(before.target, old); moved = true; }
|
|
117
|
+
if (action === 'install') { await fs.rename(stage, before.target); installed = true; }
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (installed) await remove(before.target);
|
|
120
|
+
if (moved) await fs.rename(old, before.target);
|
|
121
|
+
throw error;
|
|
122
|
+
} finally { await remove(stage); }
|
|
123
|
+
if (moved && before.state !== 'modified') await remove(old);
|
|
124
|
+
return { backup: moved && before.state === 'modified' ? old : null };
|
|
125
|
+
});
|
|
126
|
+
this.queue = operation.catch(() => {}); return operation;
|
|
127
|
+
}
|
|
128
|
+
}
|
package/src/stills.mjs
CHANGED
|
@@ -55,7 +55,7 @@ export async function stillsShot(shot, options = {}) {
|
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
for await (const { t, png: buffer, blurSamples, dofSamples } of verifiedFrames(source, { ...options, check: events.check }, times.length)) {
|
|
58
|
-
dofHistogram[dofSamples] = (dofHistogram[dofSamples] ?? 0) + 1;
|
|
58
|
+
dofHistogram[dofSamples] = (dofHistogram[dofSamples] ?? 0) + 1;
|
|
59
59
|
histogram[blurSamples] = (histogram[blurSamples] ?? 0) + 1;
|
|
60
60
|
events.check();
|
|
61
61
|
await writeArtifact(path.join(out, `t-${t.toFixed(2)}.png`), buffer);
|