multiplane 0.3.6 → 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.
Files changed (67) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD_PARTY_NOTICES.md +30 -30
  3. package/examples/alpha.html +18 -18
  4. package/examples/animation.html +17 -17
  5. package/examples/assets/OFL.txt +93 -93
  6. package/examples/assets/card.svg +6 -6
  7. package/examples/three-cards.html +17 -17
  8. package/examples/typography.html +17 -17
  9. package/licenses/GPL-3.0.txt +232 -232
  10. package/licenses/LGPL-3.0.txt +304 -304
  11. package/licenses/acorn-LICENSE +21 -21
  12. package/licenses/colour-LICENSE.md +82 -82
  13. package/licenses/detect-libc-LICENSE +201 -201
  14. package/licenses/entities-LICENSE +11 -11
  15. package/licenses/nanoid-LICENSE +20 -20
  16. package/licenses/parse5-LICENSE +19 -19
  17. package/licenses/picocolors-LICENSE +15 -15
  18. package/licenses/playwright-NOTICE +5 -5
  19. package/licenses/playwright-ThirdPartyNotices.txt +14 -14
  20. package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
  21. package/licenses/postcss-LICENSE +20 -20
  22. package/licenses/postcss-value-parser-LICENSE +22 -22
  23. package/licenses/semver-LICENSE +15 -15
  24. package/licenses/sharp-LICENSE +191 -191
  25. package/licenses/sharp-win32-x64-NOTICES.md +45 -45
  26. package/licenses/source-map-js-LICENSE +28 -28
  27. package/licenses/three-LICENSE +21 -21
  28. package/package.json +92 -92
  29. package/src/alpha.mjs +69 -69
  30. package/src/artifacts.mjs +61 -61
  31. package/src/browser-recovery.mjs +26 -26
  32. package/src/capture.mjs +12 -12
  33. package/src/encoder.mjs +16 -16
  34. package/src/environment.mjs +56 -56
  35. package/src/events.mjs +5 -5
  36. package/src/ffmpeg.mjs +32 -32
  37. package/src/format.mjs +27 -27
  38. package/src/frame-rate.mjs +28 -28
  39. package/src/home-migration.mjs +54 -54
  40. package/src/hosted.mjs +1 -1
  41. package/src/lint.mjs +248 -248
  42. package/src/output-alpha.mjs +36 -36
  43. package/src/output-check.mjs +110 -110
  44. package/src/platform.mjs +114 -114
  45. package/src/png-native.mjs +12 -12
  46. package/src/png.mjs +74 -74
  47. package/src/post.mjs +38 -37
  48. package/src/preview.mjs +23 -23
  49. package/src/process.mjs +9 -9
  50. package/src/render-pool.mjs +2 -2
  51. package/src/render-worker.mjs +1 -1
  52. package/src/resource-path.mjs +29 -29
  53. package/src/resources.mjs +27 -27
  54. package/src/runtime/camera.js +37 -37
  55. package/src/runtime/shot.css +9 -9
  56. package/src/server.mjs +7 -7
  57. package/src/setup.mjs +51 -51
  58. package/src/skills.mjs +128 -128
  59. package/src/stills.mjs +1 -1
  60. package/src/three/index.js +39 -31
  61. package/src/verify.mjs +2 -2
  62. package/src/video-pipe.mjs +6 -6
  63. package/tools/bench/shots/canvas-2d.html +1 -1
  64. package/tools/bench/shots/dom-light.html +1 -1
  65. package/tools/bench/shots/scene.js +44 -44
  66. package/tools/bench/shots/webgl.html +1 -1
  67. package/tools/install-browser.mjs +18 -18
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);
@@ -176,7 +176,7 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
176
176
  }
177
177
  return patterns.get(count);
178
178
  }
179
- function renderAccumulation(focus, radius, count, blur, rotation, budget = count) {
179
+ function renderAccumulation(focus, radius, count, blur, rotation, budget = count, stride = 1, offset = 0) {
180
180
  const position = camera.position.clone(), projection = camera.projectionMatrix.clone();
181
181
  const projectionInverse = camera.projectionMatrixInverse.clone();
182
182
  const autoClear = renderer.autoClear, oldTarget = renderer.getRenderTarget();
@@ -195,7 +195,9 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
195
195
  sumMaterial.uniforms.cameraNear.value = camera.near;
196
196
  sumMaterial.uniforms.cameraFar.value = camera.far;
197
197
  sumMaterial.uniforms.blurScale.value = radius * height * projection.elements[5] * 0.5 * blur / Math.sqrt(budget);
198
- for (const [x, y, jx, jy] of pattern(count)) {
198
+ const points = pattern(count * stride);
199
+ for (let i = offset; i < points.length; i += stride) {
200
+ const [x, y, jx, jy] = points[i];
199
201
  const dx = (x * Math.cos(rotation) - y * Math.sin(rotation)) * radius;
200
202
  const dy = (x * Math.sin(rotation) + y * Math.cos(rotation)) * radius;
201
203
  camera.position.copy(position).addScaledVector(right, dx).addScaledVector(up, dy);
@@ -225,34 +227,34 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
225
227
  textures.add(texture);
226
228
  return texture;
227
229
  }
228
- /**
229
- * @param {object} options
230
- * @param {number} [options.lodBias=0] Texture mip LOD bias; negative values sharpen
231
- * front-facing cards but can cause moire on dense textures at oblique angles.
232
- */
233
- function card({ src, width, height, radius = 0, position = [0, 0, 0], rotation = [0, 0, 0],
234
- border = null, shadow = false, annotations = [], browserFrame = false, textureWidth = 2048, style = {}, lodBias = 0 }) {
235
- if (!Number.isFinite(lodBias)) throw Object.assign(new Error('lodBias must be a finite number.'), { code: 'shot_contract_invalid' });
236
- const textureStyle = { ...layerStyle, ...style };
237
- const composeBackground = annotations.length > 0 || browserFrame || style.textureBackground !== undefined;
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
+ */
235
+ function card({ src, width, height, radius = 0, position = [0, 0, 0], rotation = [0, 0, 0],
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' });
238
+ const textureStyle = { ...layerStyle, ...style };
239
+ const composeBackground = annotations.length > 0 || browserFrame || style.textureBackground !== undefined;
238
240
  const group = new THREE.Group();
239
241
  group.position.fromArray(position); group.rotation.set(...rotation);
240
- const material = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: !composeBackground });
241
- if (lodBias !== 0) {
242
- material.onBeforeCompile = shader => {
243
- shader.uniforms.cardLodBias = { value: lodBias };
244
- shader.fragmentShader = 'uniform float cardLodBias;\n' + shader.fragmentShader.replace('#include <map_fragment>',
245
- THREE.ShaderChunk.map_fragment.replace('texture2D( map, vMapUv )', 'texture2D( map, vMapUv, cardLodBias )'));
246
- };
247
- material.customProgramCacheKey = () => 'card-lod-bias';
248
- }
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
+ }
249
251
  const mesh = new THREE.Mesh(roundedGeometry(width, height, radius), material);
250
252
  group.add(mesh);
251
253
  if (border) {
252
254
  const edge = new THREE.Mesh(roundedGeometry(width + 2 * border.width, height + 2 * border.width, radius + border.width),
253
- new THREE.MeshBasicMaterial({ color: border.color, side: THREE.DoubleSide,
254
- polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 }));
255
- group.add(edge);
255
+ new THREE.MeshBasicMaterial({ color: border.color, side: THREE.DoubleSide,
256
+ polygonOffset: true, polygonOffsetFactor: 1, polygonOffsetUnits: 1 }));
257
+ group.add(edge);
256
258
  }
257
259
  if (shadow) {
258
260
  const c = document.createElement('canvas'); c.width = c.height = 512;
@@ -336,16 +338,22 @@ export function createThreeLayer({ stage = document.querySelector('#stage'), siz
336
338
  });
337
339
  const budget = adaptive ? Math.min(maximum, Math.max(count, Math.ceil((2 * maxRadius / spacing) ** 2))) : count;
338
340
  const probe = globalThis.__multiplaneMotionProbe;
339
- // Probes retain the stable base pattern; final captures share one frame budget.
340
- const divisor = probe?.motionSamples ?? motionBlur;
341
- const minimum = probe?.time === undefined ? 2 : 8;
342
- const actual = rotate && motionBlur > 1 ? Math.min(budget, Math.max(minimum, Math.ceil(budget / divisor))) : budget;
343
- if (probe) probe.samples += actual;
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;
344
346
  if (probe && rotate && motionBlur > 1) probe.used = true;
345
347
  const phase = (probe?.time ?? t) * 997 * Math.sqrt(2);
346
- const rotation = rotate && motionBlur > 1 ? (phase - Math.floor(phase)) * Math.PI * 2 : 0;
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;
347
354
  Object.assign(stats, { samples: actual, maxBlurRadius: maxRadius, rotation });
348
- renderAccumulation(focus, radius, actual, blur, rotation, budget);
355
+ renderAccumulation(focus, radius, actual, blur, rotation, interleaved ? actual * stride : budget,
356
+ stride, interleaved ? probe.motionIndex : 0);
349
357
  }
350
358
  },
351
359
  gpuInfo() {
package/src/verify.mjs CHANGED
@@ -48,11 +48,11 @@ export async function verifyShot(shot, options = {}) {
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
+ dofHistogram[processed.lastDofSamples] = (dofHistogram[processed.lastDofSamples] ?? 0) + 1;
52
52
  histogram[processed.lastBlurSamples] = (histogram[processed.lastBlurSamples] ?? 0) + 1;
53
53
  }
54
54
  data.post = { ...processed.post, adaptiveBlur: Number(options['adaptive-blur'] ?? 0.5) };
55
- data.blurSamples = blurSampleReport(histogram);
55
+ data.blurSamples = blurSampleReport(histogram);
56
56
  data.dofSamples = blurSampleReport(dofHistogram);
57
57
  const check = async (t, actual, name, reference = references.get(t)) => {
58
58
  events.check();
@@ -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
+ }
@@ -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>
@@ -1,45 +1,45 @@
1
- const stage = document.querySelector('#stage');
2
- const kind = stage.dataset.kind;
3
- const tiles = [];
4
- if (kind.startsWith('dom') || kind === 'mixed') {
5
- const count = kind === 'dom-heavy' ? 1200 : 12;
6
- for (let i = 0; i < count; i++) {
7
- const tile = document.createElement('div');
8
- tile.className = 'tile'; tile.textContent = `Item ${i}`;
9
- tile.style.left = `${(i % 40) * 48}px`; tile.style.top = `${Math.floor(i / 40) * 35}px`;
10
- if (kind === 'dom-heavy') tile.style.filter = `blur(${i % 3 * 0.15}px)`;
11
- stage.append(tile); tiles.push(tile);
12
- }
13
- }
14
- let canvas, ctx, gl, program, timeLocation;
15
- if (['canvas-2d', 'webgl', 'mixed'].includes(kind)) {
16
- canvas = document.createElement('canvas'); stage.append(canvas);
17
- canvas.width = 1920 * devicePixelRatio; canvas.height = 1080 * devicePixelRatio;
18
- if (kind === 'canvas-2d') ctx = canvas.getContext('2d', { alpha: false, colorSpace: 'srgb' });
19
- else {
20
- gl = canvas.getContext('webgl', { alpha: kind === 'mixed', premultipliedAlpha: true, antialias: true, preserveDrawingBuffer: true });
21
- if (!gl) throw Error('WebGL unavailable');
22
- const shader = (type, source) => { const s = gl.createShader(type); gl.shaderSource(s, source); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw Error(gl.getShaderInfoLog(s)); return s; };
23
- program = gl.createProgram();
24
- gl.attachShader(program, shader(gl.VERTEX_SHADER, 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}'));
25
- gl.attachShader(program, shader(gl.FRAGMENT_SHADER, `precision highp float;uniform float t;uniform vec2 size;void main(){vec2 p=gl_FragCoord.xy/size;float stripe=step(.5,fract((p.x+p.y)*12.+t));gl_FragColor=vec4(.15+.55*p.x,.2+.5*p.y,.25+.4*stripe,${kind === 'mixed' ? '.5' : '1.'});}`));
26
- gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error(gl.getProgramInfoLog(program));
27
- gl.useProgram(program); const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,b); gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
28
- const loc=gl.getAttribLocation(program,'p');gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0);
29
- gl.uniform2f(gl.getUniformLocation(program,'size'),canvas.width,canvas.height);timeLocation=gl.getUniformLocation(program,'t');gl.viewport(0,0,canvas.width,canvas.height);
30
- window.BENCH_GL = gl;
31
- }
32
- }
33
- window.SHOT = {
34
- duration: 2, fps: 30, ready: document.fonts.load('16px Bench'),
35
- render(t) {
36
- for (let i=0;i<tiles.length;i++) tiles[i].style.transform=`translate(${Math.sin(t*3+i)*20}px,${Math.cos(t*2+i)*10}px)`;
37
- if(ctx){
38
- // Capture can temporarily change DPR; use the fixed backing-store dimensions.
39
- ctx.setTransform(canvas.width/1920,0,0,canvas.height/1080,0,0);ctx.fillStyle='#18202c';ctx.fillRect(0,0,1920,1080);
40
- for(let i=0;i<160;i++){ctx.fillStyle=`hsl(${i*17%360} 60% 60%)`;ctx.beginPath();ctx.arc((i%20)*96+48+Math.sin(t*3+i)*20,Math.floor(i/20)*130+65,28+i%9,0,Math.PI*2);ctx.fill();}
41
- ctx.fillStyle='#ffffff80';ctx.fillRect(300+t*100,250,600,400);
42
- }
43
- if(gl){gl.uniform1f(timeLocation,t);gl.drawArrays(gl.TRIANGLES,0,6);gl.finish();}
44
- }
1
+ const stage = document.querySelector('#stage');
2
+ const kind = stage.dataset.kind;
3
+ const tiles = [];
4
+ if (kind.startsWith('dom') || kind === 'mixed') {
5
+ const count = kind === 'dom-heavy' ? 1200 : 12;
6
+ for (let i = 0; i < count; i++) {
7
+ const tile = document.createElement('div');
8
+ tile.className = 'tile'; tile.textContent = `Item ${i}`;
9
+ tile.style.left = `${(i % 40) * 48}px`; tile.style.top = `${Math.floor(i / 40) * 35}px`;
10
+ if (kind === 'dom-heavy') tile.style.filter = `blur(${i % 3 * 0.15}px)`;
11
+ stage.append(tile); tiles.push(tile);
12
+ }
13
+ }
14
+ let canvas, ctx, gl, program, timeLocation;
15
+ if (['canvas-2d', 'webgl', 'mixed'].includes(kind)) {
16
+ canvas = document.createElement('canvas'); stage.append(canvas);
17
+ canvas.width = 1920 * devicePixelRatio; canvas.height = 1080 * devicePixelRatio;
18
+ if (kind === 'canvas-2d') ctx = canvas.getContext('2d', { alpha: false, colorSpace: 'srgb' });
19
+ else {
20
+ gl = canvas.getContext('webgl', { alpha: kind === 'mixed', premultipliedAlpha: true, antialias: true, preserveDrawingBuffer: true });
21
+ if (!gl) throw Error('WebGL unavailable');
22
+ const shader = (type, source) => { const s = gl.createShader(type); gl.shaderSource(s, source); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw Error(gl.getShaderInfoLog(s)); return s; };
23
+ program = gl.createProgram();
24
+ gl.attachShader(program, shader(gl.VERTEX_SHADER, 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}'));
25
+ gl.attachShader(program, shader(gl.FRAGMENT_SHADER, `precision highp float;uniform float t;uniform vec2 size;void main(){vec2 p=gl_FragCoord.xy/size;float stripe=step(.5,fract((p.x+p.y)*12.+t));gl_FragColor=vec4(.15+.55*p.x,.2+.5*p.y,.25+.4*stripe,${kind === 'mixed' ? '.5' : '1.'});}`));
26
+ gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error(gl.getProgramInfoLog(program));
27
+ gl.useProgram(program); const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,b); gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
28
+ const loc=gl.getAttribLocation(program,'p');gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0);
29
+ gl.uniform2f(gl.getUniformLocation(program,'size'),canvas.width,canvas.height);timeLocation=gl.getUniformLocation(program,'t');gl.viewport(0,0,canvas.width,canvas.height);
30
+ window.BENCH_GL = gl;
31
+ }
32
+ }
33
+ window.SHOT = {
34
+ duration: 2, fps: 30, ready: document.fonts.load('16px Bench'),
35
+ render(t) {
36
+ for (let i=0;i<tiles.length;i++) tiles[i].style.transform=`translate(${Math.sin(t*3+i)*20}px,${Math.cos(t*2+i)*10}px)`;
37
+ if(ctx){
38
+ // Capture can temporarily change DPR; use the fixed backing-store dimensions.
39
+ ctx.setTransform(canvas.width/1920,0,0,canvas.height/1080,0,0);ctx.fillStyle='#18202c';ctx.fillRect(0,0,1920,1080);
40
+ for(let i=0;i<160;i++){ctx.fillStyle=`hsl(${i*17%360} 60% 60%)`;ctx.beginPath();ctx.arc((i%20)*96+48+Math.sin(t*3+i)*20,Math.floor(i/20)*130+65,28+i%9,0,Math.PI*2);ctx.fill();}
41
+ ctx.fillStyle='#ffffff80';ctx.fillRect(300+t*100,250,600,400);
42
+ }
43
+ if(gl){gl.uniform1f(timeLocation,t);gl.drawArrays(gl.TRIANGLES,0,6);gl.finish();}
44
+ }
45
45
  };
@@ -1 +1 @@
1
- <!doctype html><html><head><meta charset="utf-8"><link rel="icon" href="data:,"><title>webgl</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="webgl"></div><script src="scene.js"></script></body></html>
1
+ <!doctype html><html><head><meta charset="utf-8"><link rel="icon" href="data:,"><title>webgl</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="webgl"></div><script src="scene.js"></script></body></html>
@@ -1,18 +1,18 @@
1
- import { createRequire } from 'node:module';
2
- import { spawnSync } from 'node:child_process';
3
- import path from 'node:path';
4
- import { existsSync } from 'node:fs';
5
-
6
- if (!process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD) {
7
- const require = createRequire(import.meta.url);
8
- const playwrightRequire = createRequire(require.resolve('playwright/package.json'));
9
- // Avoid taking Playwright's installation lock when both pinned browsers already exist.
10
- const { registry: { registry } } = playwrightRequire('playwright-core/lib/coreBundle');
11
- const installed = ['chromium', 'chromium-headless-shell'].every(name => existsSync(registry.findExecutable(name).executablePath()));
12
- if (installed) process.exit(0);
13
- const result = spawnSync(process.execPath, [path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js'), 'install', 'chromium'],
14
- { stdio: 'inherit', windowsHide: true });
15
- if (result.error) throw result.error;
16
- if (process.platform === 'linux') console.error('If Chromium cannot launch because system libraries are missing, run npx playwright install-deps chromium.');
17
- process.exitCode = result.status ?? 1;
18
- }
1
+ import { createRequire } from 'node:module';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { existsSync } from 'node:fs';
5
+
6
+ if (!process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD) {
7
+ const require = createRequire(import.meta.url);
8
+ const playwrightRequire = createRequire(require.resolve('playwright/package.json'));
9
+ // Avoid taking Playwright's installation lock when both pinned browsers already exist.
10
+ const { registry: { registry } } = playwrightRequire('playwright-core/lib/coreBundle');
11
+ const installed = ['chromium', 'chromium-headless-shell'].every(name => existsSync(registry.findExecutable(name).executablePath()));
12
+ if (installed) process.exit(0);
13
+ const result = spawnSync(process.execPath, [path.join(path.dirname(require.resolve('playwright/package.json')), 'cli.js'), 'install', 'chromium'],
14
+ { stdio: 'inherit', windowsHide: true });
15
+ if (result.error) throw result.error;
16
+ if (process.platform === 'linux') console.error('If Chromium cannot launch because system libraries are missing, run npx playwright install-deps chromium.');
17
+ process.exitCode = result.status ?? 1;
18
+ }