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/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
|
@@ -44,17 +44,18 @@ export async function stillsShot(shot, options = {}) {
|
|
|
44
44
|
await mkdir(out, { recursive: true });
|
|
45
45
|
for (const file of [...times.map(t => `t-${t.toFixed(2)}.png`), '_sheet.png']) await requireNewOutput(path.join(out, file));
|
|
46
46
|
const frames = [];
|
|
47
|
-
const histogram = {};
|
|
47
|
+
const histogram = {}, dofHistogram = {};
|
|
48
48
|
const source = async function* (first, end) {
|
|
49
49
|
for (let index = first; index < end; index++) {
|
|
50
50
|
const t = times[index];
|
|
51
51
|
events.check();
|
|
52
52
|
const png = await processed.frame(t, Math.round(t * fps), index === end - 1);
|
|
53
53
|
events.progress('capture', index + 1, times.length);
|
|
54
|
-
yield { t, png, blurSamples: processed.lastBlurSamples };
|
|
54
|
+
yield { t, png, blurSamples: processed.lastBlurSamples, dofSamples: processed.lastDofSamples };
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
|
-
for await (const { t, png: buffer, blurSamples } of verifiedFrames(source, { ...options, check: events.check }, times.length)) {
|
|
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
59
|
histogram[blurSamples] = (histogram[blurSamples] ?? 0) + 1;
|
|
59
60
|
events.check();
|
|
60
61
|
await writeArtifact(path.join(out, `t-${t.toFixed(2)}.png`), buffer);
|
|
@@ -73,7 +74,7 @@ export async function stillsShot(shot, options = {}) {
|
|
|
73
74
|
await writeArtifact(path.join(out, '_sheet.png'), sheetPNG);
|
|
74
75
|
await checkStill(path.join(out, '_sheet.png'), { width: 1920, height: Math.ceil(Math.ceil(frames.length / 4) * rowHeight) });
|
|
75
76
|
events.artifact(path.join(out, '_sheet.png'), 'contact-sheet');
|
|
76
|
-
result = { staticReuse: processed.staticReuse, post: { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) }, blurSamples: blurSampleReport(histogram), format: 'png', alpha: capture.alpha, stills: times.map(t => ({ t, frame: Math.round(t * fps), path: path.join(out, `t-${t.toFixed(2)}.png`) })), contactSheet: path.join(out, '_sheet.png') };
|
|
77
|
+
result = { staticReuse: processed.staticReuse, post: { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) }, blurSamples: blurSampleReport(histogram), dofSamples: blurSampleReport(dofHistogram), format: 'png', alpha: capture.alpha, stills: times.map(t => ({ t, frame: Math.round(t * fps), path: path.join(out, `t-${t.toFixed(2)}.png`) })), contactSheet: path.join(out, '_sheet.png') };
|
|
77
78
|
} finally { await cleanup(); }
|
|
78
79
|
|
|
79
80
|
return options.draft ? { ...result, draft: true } : result;
|
package/src/three/index.js
CHANGED
|
@@ -79,7 +79,6 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
|
|
|
79
79
|
previewAperture = 0.008, maxBlur = 0.016, toneMapping = false, zIndex = 1, style = {} } = {}) {
|
|
80
80
|
// TODO(design): Document the shared layer/card style fields and single-family font values.
|
|
81
81
|
const layerStyle = { ...defaultStyle, ...style };
|
|
82
|
-
const layerBackground = style.textureBackground !== undefined;
|
|
83
82
|
const renderer = new THREE.WebGLRenderer({ alpha: true, premultipliedAlpha: false, antialias: true, preserveDrawingBuffer: true });
|
|
84
83
|
renderer.setPixelRatio(pixelRatio);
|
|
85
84
|
renderer.setSize(size.w, size.h);
|
|
@@ -177,7 +176,7 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
|
|
|
177
176
|
}
|
|
178
177
|
return patterns.get(count);
|
|
179
178
|
}
|
|
180
|
-
function renderAccumulation(focus, radius, count, blur, rotation) {
|
|
179
|
+
function renderAccumulation(focus, radius, count, blur, rotation, budget = count, stride = 1, offset = 0) {
|
|
181
180
|
const position = camera.position.clone(), projection = camera.projectionMatrix.clone();
|
|
182
181
|
const projectionInverse = camera.projectionMatrixInverse.clone();
|
|
183
182
|
const autoClear = renderer.autoClear, oldTarget = renderer.getRenderTarget();
|
|
@@ -195,8 +194,10 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
|
|
|
195
194
|
sumMaterial.uniforms.focus.value = focus;
|
|
196
195
|
sumMaterial.uniforms.cameraNear.value = camera.near;
|
|
197
196
|
sumMaterial.uniforms.cameraFar.value = camera.far;
|
|
198
|
-
sumMaterial.uniforms.blurScale.value = radius * height * projection.elements[5] * 0.5 * blur / Math.sqrt(
|
|
199
|
-
|
|
197
|
+
sumMaterial.uniforms.blurScale.value = radius * height * projection.elements[5] * 0.5 * blur / Math.sqrt(budget);
|
|
198
|
+
const points = pattern(count * stride);
|
|
199
|
+
for (let i = offset; i < points.length; i += stride) {
|
|
200
|
+
const [x, y, jx, jy] = points[i];
|
|
200
201
|
const dx = (x * Math.cos(rotation) - y * Math.sin(rotation)) * radius;
|
|
201
202
|
const dy = (x * Math.sin(rotation) + y * Math.cos(rotation)) * radius;
|
|
202
203
|
camera.position.copy(position).addScaledVector(right, dx).addScaledVector(up, dy);
|
|
@@ -226,19 +227,34 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
|
|
|
226
227
|
textures.add(texture);
|
|
227
228
|
return texture;
|
|
228
229
|
}
|
|
230
|
+
/**
|
|
231
|
+
* @param {object} options
|
|
232
|
+
* @param {number} [options.lodBias=0] Texture mip LOD bias; negative values sharpen
|
|
233
|
+
* front-facing cards but can cause moire on dense textures at oblique angles.
|
|
234
|
+
*/
|
|
229
235
|
function card({ src, width, height, radius = 0, position = [0, 0, 0], rotation = [0, 0, 0],
|
|
230
|
-
border = null, shadow = false, annotations = [], browserFrame = false, textureWidth = 2048, style = {} }) {
|
|
236
|
+
border = null, shadow = false, annotations = [], browserFrame = false, textureWidth = 2048, style = {}, lodBias = 0 }) {
|
|
237
|
+
if (!Number.isFinite(lodBias)) throw Object.assign(new Error('lodBias must be a finite number.'), { code: 'shot_contract_invalid' });
|
|
231
238
|
const textureStyle = { ...layerStyle, ...style };
|
|
232
|
-
const composeBackground =
|
|
239
|
+
const composeBackground = annotations.length > 0 || browserFrame || style.textureBackground !== undefined;
|
|
233
240
|
const group = new THREE.Group();
|
|
234
241
|
group.position.fromArray(position); group.rotation.set(...rotation);
|
|
235
|
-
const material = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide });
|
|
242
|
+
const material = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: !composeBackground });
|
|
243
|
+
if (lodBias !== 0) {
|
|
244
|
+
material.onBeforeCompile = shader => {
|
|
245
|
+
shader.uniforms.cardLodBias = { value: lodBias };
|
|
246
|
+
shader.fragmentShader = 'uniform float cardLodBias;\n' + shader.fragmentShader.replace('#include <map_fragment>',
|
|
247
|
+
THREE.ShaderChunk.map_fragment.replace('texture2D( map, vMapUv )', 'texture2D( map, vMapUv, cardLodBias )'));
|
|
248
|
+
};
|
|
249
|
+
material.customProgramCacheKey = () => 'card-lod-bias';
|
|
250
|
+
}
|
|
236
251
|
const mesh = new THREE.Mesh(roundedGeometry(width, height, radius), material);
|
|
237
252
|
group.add(mesh);
|
|
238
253
|
if (border) {
|
|
239
254
|
const edge = new THREE.Mesh(roundedGeometry(width + 2 * border.width, height + 2 * border.width, radius + border.width),
|
|
240
|
-
new THREE.MeshBasicMaterial({ color: border.color, side: THREE.DoubleSide
|
|
241
|
-
|
|
255
|
+
new THREE.MeshBasicMaterial({ color: border.color, side: THREE.DoubleSide,
|
|
256
|
+
polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 }));
|
|
257
|
+
group.add(edge);
|
|
242
258
|
}
|
|
243
259
|
if (shadow) {
|
|
244
260
|
const c = document.createElement('canvas'); c.width = c.height = 512;
|
|
@@ -288,7 +304,8 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
|
|
|
288
304
|
adaptiveSamples: adaptive = sampling.adaptiveSamples, maxSamples: maximum = sampling.maxSamples,
|
|
289
305
|
sampleSpacing: spacing = sampling.sampleSpacing, sampleBlur: blur = sampling.sampleBlur,
|
|
290
306
|
rotateSamples: rotate = sampling.rotateSamples,
|
|
291
|
-
t = window.SHOT?.sampleContext?.time ?? 0,
|
|
307
|
+
t = window.SHOT?.sampleContext?.time ?? 0,
|
|
308
|
+
motionBlur = window.SHOT?.sampleContext?.motionBlurBase ?? window.SHOT?.sampleContext?.motionBlur ?? 1,
|
|
292
309
|
previewAperture: previewRadius = previewAperture, maxBlur: previewMax = maxBlur } = {}) {
|
|
293
310
|
if (!Number.isFinite(focus) || focus <= 0 || !Number.isFinite(radius) || radius < 0
|
|
294
311
|
|| !Number.isInteger(count) || count < 1 || !['preview', 'final'].includes(mode)
|
|
@@ -319,11 +336,24 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
|
|
|
319
336
|
maxRadius = Math.max(maxRadius, radius * height * camera.projectionMatrix.elements[5] * 0.5 * Math.abs(1 / z - 1 / focus));
|
|
320
337
|
}
|
|
321
338
|
});
|
|
322
|
-
const
|
|
323
|
-
const
|
|
324
|
-
|
|
339
|
+
const budget = adaptive ? Math.min(maximum, Math.max(count, Math.ceil((2 * maxRadius / spacing) ** 2))) : count;
|
|
340
|
+
const probe = globalThis.__multiplaneMotionProbe;
|
|
341
|
+
// Probes retain the stable base pattern; final captures share one frame budget.
|
|
342
|
+
const divisor = probe?.motionSamples ?? motionBlur;
|
|
343
|
+
const minimum = probe?.time === undefined ? 2 : 8;
|
|
344
|
+
const actual = rotate && motionBlur > 1 ? Math.min(budget, Math.max(minimum, Math.ceil(budget / divisor))) : budget;
|
|
345
|
+
if (probe) probe.samples += actual;
|
|
346
|
+
if (probe && rotate && motionBlur > 1) probe.used = true;
|
|
347
|
+
const phase = (probe?.time ?? t) * 997 * Math.sqrt(2);
|
|
348
|
+
// Final temporal samples partition one lens pattern, including its jitter.
|
|
349
|
+
// Fixed-phase probes and direct layer calls retain their existing pattern.
|
|
350
|
+
const interleaved = rotate && motionBlur > 1 && probe?.time === undefined
|
|
351
|
+
&& Number.isInteger(probe?.motionIndex) && probe.motionIndex >= 0;
|
|
352
|
+
const stride = interleaved ? divisor : 1;
|
|
353
|
+
const rotation = !interleaved && rotate && motionBlur > 1 ? (phase - Math.floor(phase)) * Math.PI * 2 : 0;
|
|
325
354
|
Object.assign(stats, { samples: actual, maxBlurRadius: maxRadius, rotation });
|
|
326
|
-
renderAccumulation(focus, radius, actual, blur, rotation
|
|
355
|
+
renderAccumulation(focus, radius, actual, blur, rotation, interleaved ? actual * stride : budget,
|
|
356
|
+
stride, interleaved ? probe.motionIndex : 0);
|
|
327
357
|
}
|
|
328
358
|
},
|
|
329
359
|
gpuInfo() {
|
package/src/verify.mjs
CHANGED
|
@@ -43,15 +43,17 @@ export async function verifyShot(shot, options = {}) {
|
|
|
43
43
|
const references = new Map(), hashes = [], rangeReferences = new Map();
|
|
44
44
|
if (compareReuse) referenceDirectory = await mkdtemp(path.join(tmpdir(), 'multiplane-verify-static-'));
|
|
45
45
|
await mkdir(out, { recursive: true });
|
|
46
|
-
const histogram = {};
|
|
46
|
+
const histogram = {}, dofHistogram = {};
|
|
47
47
|
const jitterChecks = new Set();
|
|
48
48
|
let errors = 0, totalChangedPixels = 0;
|
|
49
49
|
for (const t of times) {
|
|
50
50
|
events.check(); references.set(t, decodePNG(await processed.frame(t)));
|
|
51
|
+
dofHistogram[processed.lastDofSamples] = (dofHistogram[processed.lastDofSamples] ?? 0) + 1;
|
|
51
52
|
histogram[processed.lastBlurSamples] = (histogram[processed.lastBlurSamples] ?? 0) + 1;
|
|
52
53
|
}
|
|
53
54
|
data.post = { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) };
|
|
54
55
|
data.blurSamples = blurSampleReport(histogram);
|
|
56
|
+
data.dofSamples = blurSampleReport(dofHistogram);
|
|
55
57
|
const check = async (t, actual, name, reference = references.get(t)) => {
|
|
56
58
|
events.check();
|
|
57
59
|
if (reference.file) reference = { ...reference, data: await readFile(reference.file) };
|
package/src/video-pipe.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export async function writeFrame(stream, bytes) {
|
|
2
|
-
const chunkSize = 1024 * 1024;
|
|
3
|
-
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
4
|
-
await new Promise((resolve, reject) => stream.write(bytes.subarray(offset, offset + chunkSize), error => error ? reject(error) : resolve()));
|
|
5
|
-
}
|
|
6
|
-
}
|
|
1
|
+
export async function writeFrame(stream, bytes) {
|
|
2
|
+
const chunkSize = 1024 * 1024;
|
|
3
|
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
4
|
+
await new Promise((resolve, reject) => stream.write(bytes.subarray(offset, offset + chunkSize), error => error ? reject(error) : resolve()));
|
|
5
|
+
}
|
|
6
|
+
}
|
package/src/workers.mjs
CHANGED
|
@@ -41,7 +41,11 @@ export function selectWorkers({ workers, source, record, machine, type, scale =
|
|
|
41
41
|
if (validBenchmark(record, machine) && shotTypes.includes(type)) {
|
|
42
42
|
return { workers: Math.max(1, Math.min(record.shots[type].workers, memoryWorkers(memoryScale, hardware))), source: 'benchmark' };
|
|
43
43
|
}
|
|
44
|
-
|
|
44
|
+
const estimate = defaultWorkers(scale, hardware);
|
|
45
|
+
// Unbenchmarked WebGL contexts share one GPU and retain large render targets.
|
|
46
|
+
const free = Math.min(hardware.free ?? freemem(), hardware.total ?? totalmem());
|
|
47
|
+
const threeLimit = Math.max(1, Math.floor((free - 2 * 1024 ** 3) / ((0.5 + 0.5 * memoryScale ** 2) * 1024 ** 3)));
|
|
48
|
+
return { workers: type === 'three' ? Math.min(4, estimate, threeLimit) : estimate, source: 'estimate' };
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
// Inspect the loaded probe only; its context is discarded before real rendering.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<!doctype html><html><head><meta charset="utf-8"><link rel="icon" href="data:,"><title>canvas-2d</title><style>@font-face{font-family:Bench;src:url('../../../examples/assets/Roboto-Variable.ttf')}html,body{margin:0}#stage{width:1920px;height:1080px;position:relative;overflow:hidden;background:#18202c}canvas{position:absolute;inset:0;width:1920px;height:1080px}.tile{position:absolute;color:#fff;background:#375575;font:16px Bench;padding:4px;box-shadow:2px 3px 5px #0008}</style></head><body><div id="stage" data-kind="canvas-2d"></div><script src="scene.js"></script></body></html>
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><link rel="icon" href="data:,"><title>canvas-2d</title><style>@font-face{font-family:Bench;src:url('../../../examples/assets/Roboto-Variable.ttf')}html,body{margin:0}#stage{width:1920px;height:1080px;position:relative;overflow:hidden;background:#18202c}canvas{position:absolute;inset:0;width:1920px;height:1080px}.tile{position:absolute;color:#fff;background:#375575;font:16px Bench;padding:4px;box-shadow:2px 3px 5px #0008}</style></head><body><div id="stage" data-kind="canvas-2d"></div><script src="scene.js"></script></body></html>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<!doctype html><html><head><meta charset="utf-8"><link rel="icon" href="data:,"><title>dom-light</title><style>@font-face{font-family:Bench;src:url('../../../examples/assets/Roboto-Variable.ttf')}html,body{margin:0}#stage{width:1920px;height:1080px;position:relative;overflow:hidden;background:#18202c}canvas{position:absolute;inset:0;width:1920px;height:1080px}.tile{position:absolute;color:#fff;background:#375575;font:16px Bench;padding:4px;box-shadow:2px 3px 5px #0008}</style></head><body><div id="stage" data-kind="dom-light"></div><script src="scene.js"></script></body></html>
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><link rel="icon" href="data:,"><title>dom-light</title><style>@font-face{font-family:Bench;src:url('../../../examples/assets/Roboto-Variable.ttf')}html,body{margin:0}#stage{width:1920px;height:1080px;position:relative;overflow:hidden;background:#18202c}canvas{position:absolute;inset:0;width:1920px;height:1080px}.tile{position:absolute;color:#fff;background:#375575;font:16px Bench;padding:4px;box-shadow:2px 3px 5px #0008}</style></head><body><div id="stage" data-kind="dom-light"></div><script src="scene.js"></script></body></html>
|