multiplane 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +202 -202
  2. package/README.en.md +3 -0
  3. package/README.md +3 -0
  4. package/THIRD_PARTY_NOTICES.md +30 -30
  5. package/bin/multiplane +24 -14
  6. package/examples/alpha.html +18 -18
  7. package/examples/animation.html +17 -17
  8. package/examples/assets/OFL.txt +93 -93
  9. package/examples/assets/card.svg +6 -6
  10. package/examples/three-cards.html +17 -17
  11. package/examples/typography.html +17 -17
  12. package/licenses/GPL-3.0.txt +232 -232
  13. package/licenses/LGPL-3.0.txt +304 -304
  14. package/licenses/acorn-LICENSE +21 -21
  15. package/licenses/colour-LICENSE.md +82 -82
  16. package/licenses/detect-libc-LICENSE +201 -201
  17. package/licenses/entities-LICENSE +11 -11
  18. package/licenses/nanoid-LICENSE +20 -20
  19. package/licenses/parse5-LICENSE +19 -19
  20. package/licenses/picocolors-LICENSE +15 -15
  21. package/licenses/playwright-NOTICE +5 -5
  22. package/licenses/playwright-ThirdPartyNotices.txt +14 -14
  23. package/licenses/playwright-core-ThirdPartyNotices.txt +13 -13
  24. package/licenses/postcss-LICENSE +20 -20
  25. package/licenses/postcss-value-parser-LICENSE +22 -22
  26. package/licenses/semver-LICENSE +15 -15
  27. package/licenses/sharp-LICENSE +191 -191
  28. package/licenses/sharp-win32-x64-NOTICES.md +45 -45
  29. package/licenses/source-map-js-LICENSE +28 -28
  30. package/licenses/three-LICENSE +21 -21
  31. package/package.json +92 -92
  32. package/skills/multiplane/SKILL.md +3 -0
  33. package/src/alpha.mjs +69 -69
  34. package/src/artifacts.mjs +61 -61
  35. package/src/browser-recovery.mjs +26 -26
  36. package/src/capture-watchdog.mjs +29 -0
  37. package/src/capture.mjs +34 -18
  38. package/src/encoder.mjs +16 -16
  39. package/src/environment.mjs +56 -56
  40. package/src/errors.mjs +1 -0
  41. package/src/events.mjs +12 -10
  42. package/src/ffmpeg.mjs +32 -32
  43. package/src/format.mjs +27 -27
  44. package/src/frame-rate.mjs +28 -28
  45. package/src/home-migration.mjs +54 -54
  46. package/src/hosted.mjs +3 -3
  47. package/src/index.mjs +1 -0
  48. package/src/input-records.mjs +33 -0
  49. package/src/jobs.mjs +3 -1
  50. package/src/lint.mjs +248 -248
  51. package/src/output-alpha.mjs +36 -36
  52. package/src/output-check.mjs +110 -110
  53. package/src/platform.mjs +114 -114
  54. package/src/png-native.mjs +12 -12
  55. package/src/png.mjs +74 -74
  56. package/src/post.mjs +56 -39
  57. package/src/preview.mjs +23 -23
  58. package/src/process.mjs +9 -9
  59. package/src/render-pool.mjs +41 -14
  60. package/src/render-worker.mjs +14 -3
  61. package/src/render.mjs +148 -52
  62. package/src/resource-path.mjs +29 -29
  63. package/src/resources.mjs +27 -27
  64. package/src/resume.mjs +23 -0
  65. package/src/runtime/camera.js +37 -37
  66. package/src/runtime/shot.css +9 -9
  67. package/src/segment-encoder.mjs +91 -0
  68. package/src/segments.mjs +123 -0
  69. package/src/server.mjs +11 -7
  70. package/src/setup.mjs +51 -51
  71. package/src/skills.mjs +128 -128
  72. package/src/stills.mjs +1 -1
  73. package/src/three/index.js +39 -31
  74. package/src/verify.mjs +2 -2
  75. package/src/video-pipe.mjs +6 -6
  76. package/tools/bench/shots/canvas-2d.html +1 -1
  77. package/tools/bench/shots/dom-light.html +1 -1
  78. package/tools/bench/shots/scene.js +44 -44
  79. package/tools/bench/shots/webgl.html +1 -1
  80. package/tools/install-browser.mjs +18 -18
package/src/lint.mjs CHANGED
@@ -1,248 +1,248 @@
1
- import { normalizeResourcePath, isInsideResourceRoot } from './resource-path.mjs';
2
- import path from 'node:path';
3
- import { readFile, stat } from 'node:fs/promises';
4
- import { parse as parseJS } from 'acorn';
5
- import { parse as parseHTML } from 'parse5';
6
- import postcss from 'postcss';
7
- import values from 'postcss-value-parser';
8
- import { MultiplaneError } from './errors.mjs';
9
-
10
- const rules = {
11
- wall_clock: ['error', 'Wall-clock time is nondeterministic.', 'Calculate time from the render(t) argument.'],
12
- unseeded_random: ['error', 'Unseeded randomness is nondeterministic.', 'Use a seeded random generator with a fixed seed.'],
13
- css_animation: ['error', 'An autonomous CSS animation or transition is used.', 'Set animation and transition to none and assign styles from render(t).'],
14
- web_animations: ['error', 'The Web Animations timeline is used.', 'Calculate and assign each animated property in render(t).'],
15
- timer_driven: ['warning', 'A timer can change the frame independently of t.', 'Move visual updates into render(t).'],
16
- media_playback: ['error', 'Autonomous media playback is used.', 'Use a still image or predecoded frame selected by t.'],
17
- animated_image: ['warning', 'An animated image may advance independently of t.', 'Replace it with still frames selected by t.'],
18
- remote_resource: ['error', 'A resource is loaded from the network.', 'Download the resource and reference a local file inside the resource root.'],
19
- resource_missing: ['error', 'A local resource is missing or outside the resource root.', 'Place the file inside the resource root and correct the relative URL.'],
20
- stage_missing: ['error', 'The stage element is missing.', 'Add an element with id="stage".'],
21
- stage_size_unfixed: ['warning', 'Fixed stage dimensions could not be found.', 'Set #stage width and height to positive px values.'],
22
- shot_missing: ['error', 'The shot definition is missing.', 'Call Shot.define({...}) or assign window.SHOT.'],
23
- runtime_missing: ['error', 'Shot.define is used without the runtime.', 'Include /multiplane/runtime/shot.js before defining the shot.'],
24
- async_render: ['error', 'The render function is asynchronous.', 'Await resources in setup and make render(t) synchronous.'],
25
- font_not_awaited: ['warning', 'Custom fonts have no explicit loading declaration.', 'Add fonts to Shot.define or await document.fonts.load in setup.'],
26
- lint_directive_invalid: ['warning', 'The lint directive has no valid rule codes.', 'List rule codes after multiplane-disable-next-line.'],
27
- lint_incomplete: ['warning', 'Source parsing failed.', 'Correct the source syntax to complete lint coverage.'],
28
- };
29
- const children = node => Object.values(node).flatMap(v => Array.isArray(v) ? v : [v]).filter(v => v && typeof v.type === 'string');
30
- const walk = (node, fn, parents = []) => { fn(node, parents); for (const child of children(node)) walk(child, fn, [...parents, node]); };
31
- const member = n => n?.type === 'Identifier' ? n.name : n?.type === 'MemberExpression' && !n.computed ? `${member(n.object)}.${n.property.name}` : '';
32
- const literal = n => n?.type === 'Literal' && typeof n.value === 'string' ? n.value : n?.type === 'TemplateLiteral' && !n.expressions.length ? n.quasis[0].value.cooked : null;
33
-
34
- export async function scanShot(shot, options = {}) {
35
- const root = await normalizeResourcePath(path.resolve(options.root ?? process.cwd()));
36
- const entry = path.resolve(root, shot);
37
- if (!await isInsideResourceRoot(root, entry)) throw new MultiplaneError('shot_outside_root');
38
- let entryReal;
39
- try { entryReal = await normalizeResourcePath(entry); if (!(await stat(entryReal)).isFile()) throw Error(); }
40
- catch { throw new MultiplaneError('shot_not_found'); }
41
- if (!await isInsideResourceRoot(root, entryReal)) throw new MultiplaneError('shot_outside_root');
42
- const sources = new Map(), pending = [], findings = [], directives = new Map(), seen = new Set();
43
- const state = { stage: null, width: false, height: false, shot: false, define: null, runtime: false, fonts: false, fontFace: null, fontLoad: false };
44
- const location = (file, offset) => {
45
- const prefix = sources.get(file).slice(0, offset), lines = prefix.split('\n');
46
- return { file: path.relative(root, file).split(path.sep).join('/'), line: lines.length, column: lines.at(-1).length + 1 };
47
- };
48
- const add = (code, file, offset, extra = {}) => {
49
- const loc = location(file, offset), [severity, message, hint] = rules[code];
50
- if (findings.some(f => f.code === code && f.location.file === loc.file && f.location.line === loc.line && f.location.column === loc.column)) return;
51
- findings.push({ code, severity, message, hint, location: loc, ...extra, details: { snippet: sources.get(file).split('\n')[loc.line - 1].trim().slice(0, 160), ...extra.details } });
52
- };
53
- const comment = (file, offset, text) => {
54
- const match = /multiplane-disable-next-line\b([^\r\n]*)/.exec(text);
55
- if (!match) return;
56
- const codes = match[1].trim().split(/\s*,\s*/).filter(c => rules[c] && c !== 'lint_directive_invalid');
57
- if (!codes.length) add('lint_directive_invalid', file, offset);
58
- else {
59
- const loc = location(file, offset + text.slice(0, match.index).length);
60
- directives.set(`${loc.file}:${loc.line + 1}`, new Set([...(directives.get(`${loc.file}:${loc.line + 1}`) ?? []), ...codes]));
61
- }
62
- };
63
- const resource = (url, file, offset, kind, module = false) => {
64
- if (url === null) return;
65
- url = url.trim();
66
- if (/^(https?:)?\/\//i.test(url)) { add('remote_resource', file, offset); return; }
67
- if (!url || /^(data:|blob:|#)/i.test(url)) return;
68
- if ((kind === 'js' || module) && url.split(/[?#]/)[0] === '/multiplane/runtime/shot.js') state.runtime = true;
69
- if (url.startsWith('/multiplane/')) return;
70
- if (module && !/^[./]/.test(url)) return;
71
- if (/^[a-z][\w+.-]*:/i.test(url)) return;
72
- if (/\.(gif|apng)(?:[?#]|$)/i.test(url)) add('animated_image', file, offset);
73
- pending.push({ url, file, offset, kind });
74
- };
75
- function css(text, file, base = 0, inlineStage = false) {
76
- let tree;
77
- try { tree = postcss.parse(text, { from: file, map: false }); }
78
- catch (error) { add('lint_incomplete', file, base, { message: 'CSS parsing failed; source fragment was skipped.', details: { reason: error.message.split(/\r?\n/)[0] } }); return; }
79
- tree.walk(node => {
80
- const offset = base + node.source.start.offset;
81
- if (node.type === 'comment') comment(file, offset, node.text);
82
- if (node.type === 'atrule') {
83
- if (/^(?:-\w+-)?keyframes$/i.test(node.name)) add('css_animation', file, offset);
84
- if (node.name.toLowerCase() === 'font-face') state.fontFace ??= { file, offset };
85
- if (node.name.toLowerCase() === 'import') {
86
- const first = values(node.params).nodes.find(n => !['space', 'comment'].includes(n.type));
87
- if (first?.type === 'string') resource(first.value, file, offset, 'css');
88
- }
89
- }
90
- if (node.type === 'decl') {
91
- const prop = node.prop.toLowerCase(), value = node.value.trim().toLowerCase();
92
- if (['animation', 'animation-name'].includes(prop) && value.split(',').some(v => v.trim() !== 'none')) add('css_animation', file, offset);
93
- if (['transition', 'transition-duration'].includes(prop)) {
94
- const durations = value.match(/-?(?:\d*\.)?\d+m?s\b/g) ?? [];
95
- if (durations.some(v => parseFloat(v) !== 0)) add('css_animation', file, offset);
96
- }
97
- if (inlineStage || (node.parent.type === 'rule' && node.parent.selector.split(',').some(s => s.trim() === '#stage'))) {
98
- if (['width', 'height'].includes(prop) && /^\d+(?:\.\d+)?px$/.test(value) && parseFloat(value) > 0) state[prop] = true;
99
- }
100
- }
101
- if (node.type === 'decl' || node.type === 'atrule') {
102
- values(node.value ?? node.params).walk(v => {
103
- if (v.type === 'function' && v.value.toLowerCase() === 'url') {
104
- const url = v.nodes.filter(n => !['space', 'comment'].includes(n.type)).map(n => n.value).join('');
105
- resource(url, file, offset, node.type === 'atrule' && node.name === 'import' ? 'css' : undefined);
106
- return false;
107
- }
108
- });
109
- }
110
- });
111
- }
112
- function js(text, file, base = 0, module = false) {
113
- let tree;
114
- try { tree = parseJS(text, { ecmaVersion: 'latest', sourceType: module ? 'module' : 'script', allowAwaitOutsideFunction: true,
115
- onComment: (_block, value, start) => comment(file, base + start, value) }); }
116
- catch (error) { add('lint_incomplete', file, base, { message: 'JavaScript parsing failed; source fragment was skipped.', details: { reason: error.message.split(/\r?\n/)[0] } }); return; }
117
- const bindings = new Map();
118
- let globalShotVariable = false;
119
- // Track lexical scopes so local variables named Date/Math/etc. do not match globals.
120
- const isScope = n => /Function/.test(n.type) || ['Program', 'BlockStatement', 'ArrowFunctionExpression', 'CatchClause'].includes(n.type);
121
- const bind = (scope, pattern) => {
122
- if (!scope || !pattern) return;
123
- const names = bindings.get(scope) ?? new Set(); bindings.set(scope, names);
124
- if (pattern.type === 'Identifier') names.add(pattern.name);
125
- else for (const c of children(pattern)) bind(scope, c);
126
- };
127
- walk(tree, (n, ancestors) => {
128
- if (/Function/.test(n.type)) { for (const p of n.params ?? []) bind(n, p); if (n.id) bind(n.type === 'FunctionDeclaration' ? ancestors.findLast(isScope) : n, n.id); }
129
- if (n.type === 'VariableDeclaration') for (const d of n.declarations) {
130
- bind(ancestors.findLast(a => n.kind === 'var' ? /Function/.test(a.type) || a.type === 'Program' : isScope(a)), d.id);
131
- if (!module && n.kind === 'var' && d.id.name === 'SHOT' && !ancestors.some(a => /Function/.test(a.type))) {
132
- globalShotVariable = true;
133
- if (d.init) state.shot = true;
134
- }
135
- }
136
- if (n.type === 'ImportDeclaration') for (const s of n.specifiers) bind(tree, s.local);
137
- if (n.type === 'ClassDeclaration') bind(ancestors.findLast(isScope), n.id);
138
- if (n.type === 'CatchClause') bind(n, n.param);
139
- });
140
- walk(tree, (n, ancestors) => {
141
- const offset = base + n.start, call = ['CallExpression', 'NewExpression'].includes(n.type);
142
- const name = member(call ? n.callee : n), global = value => ![...ancestors, n].some(a => bindings.get(a)?.has(value.split('.')[0]));
143
- if (call && global(name)) {
144
- if (['Date.now', 'performance.now'].includes(name) || name === 'Date' && (n.type === 'CallExpression' || !n.arguments.length)) add('wall_clock', file, offset);
145
- if (['Math.random', 'crypto.getRandomValues', 'crypto.randomUUID'].includes(name)) add('unseeded_random', file, offset);
146
- if (['setTimeout', 'setInterval', 'requestAnimationFrame', 'requestIdleCallback'].includes(name.replace(/^(window|globalThis)\./, ''))) add('timer_driven', file, offset);
147
- if (name === 'Animation' && n.type === 'NewExpression') add('web_animations', file, offset);
148
- if (name === 'Audio' && n.type === 'NewExpression') add('media_playback', file, offset);
149
- if (name === 'fetch') resource(literal(n.arguments[0]), file, base + (n.arguments[0]?.start ?? n.start));
150
- if (name === 'FontFace') {
151
- const source = literal(n.arguments[1]);
152
- if (source) values(source).walk(v => { if (v.type === 'function' && v.value === 'url') resource(v.nodes.map(x => x.value).join(''), file, base + n.arguments[1].start); });
153
- }
154
- if (name === 'Shot.define') {
155
- state.shot = true; state.define ??= { file, offset };
156
- // An indirect definition may supply fonts; do not infer their absence.
157
- state.fonts ||= n.arguments[0]?.type !== 'ObjectExpression' || n.arguments[0].properties.some(p => (p.key?.name ?? p.key?.value) === 'fonts' || p.type === 'SpreadElement');
158
- }
159
- if (name === 'document.fonts.load') state.fontLoad = true;
160
- }
161
- if (call && n.callee.type === 'MemberExpression' && !n.callee.computed) {
162
- if (n.callee.property.name === 'animate') add('web_animations', file, offset);
163
- if (n.callee.property.name === 'play') add('media_playback', file, offset);
164
- }
165
- if (name === 'document.timeline' && global(name)) add('web_animations', file, offset);
166
- if (n.type === 'AssignmentExpression') {
167
- const target = member(n.left);
168
- if (['SHOT', 'window.SHOT'].includes(target) && (global(target) || target === 'SHOT' && globalShotVariable
169
- && !ancestors.some(a => a.type !== 'Program' && bindings.get(a)?.has('SHOT')))) state.shot = true;
170
- const value = literal(n.right);
171
- if (/\.style\.(animation|transition)$/.test(target) && value !== null && value !== '') add('css_animation', file, offset);
172
- }
173
- if (n.type === 'ImportDeclaration' || ['ExportNamedDeclaration', 'ExportAllDeclaration'].includes(n.type) && n.source) resource(literal(n.source), file, base + n.source.start, 'js', true);
174
- if (n.type === 'ImportExpression') resource(literal(n.source), file, base + n.source.start, undefined, true);
175
- const render = ['Property', 'MethodDefinition'].includes(n.type) && (n.key.name ?? n.key.value) === 'render' ? n.value
176
- : n.type === 'VariableDeclarator' && n.id.name === 'render' ? n.init
177
- : n.type === 'FunctionDeclaration' && n.id?.name === 'render' ? n
178
- : n.type === 'AssignmentExpression' && /(?:^|\.)render$/.test(member(n.left)) ? n.right : null;
179
- if (render && /Function/.test(render.type)) {
180
- let async = render.async;
181
- walk(render.body, child => { if (child.type === 'AwaitExpression') async = true; });
182
- if (async) add('async_render', file, offset);
183
- }
184
- });
185
- }
186
- async function scan(file, kind) {
187
- if (seen.has(file)) return;
188
- seen.add(file);
189
- const text = await readFile(file, 'utf8'); sources.set(file, text);
190
- if (kind === 'js') return js(text, file, 0, true);
191
- if (kind === 'css') return css(text, file);
192
- const tree = parseHTML(text, { sourceCodeLocationInfo: true });
193
- function visit(node) {
194
- const loc = node.sourceCodeLocation, attrs = Object.fromEntries((node.attrs ?? []).map(a => [a.name, a.value]));
195
- if (node.nodeName === '#comment') comment(file, loc.startOffset, node.data);
196
- if (loc && node.tagName) {
197
- if (attrs.id === 'stage') state.stage ??= { file, offset: loc.startOffset };
198
- if (['video', 'audio'].includes(node.tagName)) add('media_playback', file, loc.startOffset);
199
- for (const a of node.attrs ?? []) {
200
- const attrLoc = loc.attrs[a.name];
201
- if (a.name === 'style') {
202
- const raw = text.slice(attrLoc.startOffset, attrLoc.endOffset), match = /=\s*["']?/.exec(raw);
203
- if (match) {
204
- const valueStart = match.index + match[0].length;
205
- css(raw.slice(valueStart).replace(/["']$/, ''), file, attrLoc.startOffset + valueStart, attrs.id === 'stage');
206
- }
207
- }
208
- if (['src', 'href', 'poster', 'xlink:href'].includes(a.name) && !(node.tagName === 'a' && a.name === 'href')) resource(a.value, file, attrLoc.startOffset,
209
- node.tagName === 'script' ? 'js' : node.tagName === 'link' && attrs.rel?.split(/\s+/).includes('stylesheet') ? 'css' : undefined);
210
- }
211
- if (node.tagName === 'script' && !attrs.src && (!attrs.type || /^(module|(?:text|application)\/(?:java|ecma)script)$/i.test(attrs.type))) js(text.slice(loc.startTag.endOffset, loc.endTag?.startOffset ?? loc.endOffset), file, loc.startTag.endOffset, attrs.type === 'module');
212
- if (node.tagName === 'style') css(text.slice(loc.startTag.endOffset, loc.endTag?.startOffset ?? loc.endOffset), file, loc.startTag.endOffset);
213
- }
214
- for (const child of node.childNodes ?? []) visit(child);
215
- }
216
- visit(tree);
217
- }
218
- await scan(entryReal, 'html');
219
- for (let i = 0; i < pending.length; i++) {
220
- const { url, file, offset, kind } = pending[i];
221
- let target;
222
- try {
223
- const decoded = decodeURIComponent(url.split(/[?#]/)[0]);
224
- target = path.resolve(decoded.startsWith('/') ? root : path.dirname(file), decoded.replace(/^\//, ''));
225
- if (!await isInsideResourceRoot(root, target)) throw Error();
226
- target = await normalizeResourcePath(target);
227
- if (!await isInsideResourceRoot(root, target) || !(await stat(target)).isFile()) throw Error();
228
- } catch { add('resource_missing', file, offset); continue; }
229
- if (kind) await scan(target, kind);
230
- }
231
- if (!state.stage) add('stage_missing', entryReal, 0);
232
- else if (!state.width || !state.height) add('stage_size_unfixed', state.stage.file, state.stage.offset);
233
- if (!state.shot) add('shot_missing', entryReal, 0);
234
- if (state.define && !state.runtime) add('runtime_missing', state.define.file, state.define.offset);
235
- if (state.fontFace && !state.fonts && !state.fontLoad) add('font_not_awaited', state.fontFace.file, state.fontFace.offset);
236
- const visible = findings.filter(f => !directives.get(`${f.location.file}:${f.location.line}`)?.has(f.code));
237
- visible.sort((a, b) => a.location.file.localeCompare(b.location.file) || a.location.line - b.location.line || a.location.column - b.location.column || a.code.localeCompare(b.code));
238
- const data = { files: [...sources.keys()].map(f => path.relative(root, f).split(path.sep).join('/')), errors: visible.filter(f => f.severity === 'error').length,
239
- warnings: visible.filter(f => f.severity === 'warning').length, suppressed: findings.length - visible.length };
240
- return { findings: visible, data };
241
- }
242
-
243
- export async function checkLint(shot, options, events) {
244
- const { findings, data } = await scanShot(shot, options);
245
- for (const finding of findings) events.emit('finding', finding);
246
- if (data.errors) throw new MultiplaneError('lint_failed', undefined, { result: { data } });
247
- return data;
248
- }
1
+ import { normalizeResourcePath, isInsideResourceRoot } from './resource-path.mjs';
2
+ import path from 'node:path';
3
+ import { readFile, stat } from 'node:fs/promises';
4
+ import { parse as parseJS } from 'acorn';
5
+ import { parse as parseHTML } from 'parse5';
6
+ import postcss from 'postcss';
7
+ import values from 'postcss-value-parser';
8
+ import { MultiplaneError } from './errors.mjs';
9
+
10
+ const rules = {
11
+ wall_clock: ['error', 'Wall-clock time is nondeterministic.', 'Calculate time from the render(t) argument.'],
12
+ unseeded_random: ['error', 'Unseeded randomness is nondeterministic.', 'Use a seeded random generator with a fixed seed.'],
13
+ css_animation: ['error', 'An autonomous CSS animation or transition is used.', 'Set animation and transition to none and assign styles from render(t).'],
14
+ web_animations: ['error', 'The Web Animations timeline is used.', 'Calculate and assign each animated property in render(t).'],
15
+ timer_driven: ['warning', 'A timer can change the frame independently of t.', 'Move visual updates into render(t).'],
16
+ media_playback: ['error', 'Autonomous media playback is used.', 'Use a still image or predecoded frame selected by t.'],
17
+ animated_image: ['warning', 'An animated image may advance independently of t.', 'Replace it with still frames selected by t.'],
18
+ remote_resource: ['error', 'A resource is loaded from the network.', 'Download the resource and reference a local file inside the resource root.'],
19
+ resource_missing: ['error', 'A local resource is missing or outside the resource root.', 'Place the file inside the resource root and correct the relative URL.'],
20
+ stage_missing: ['error', 'The stage element is missing.', 'Add an element with id="stage".'],
21
+ stage_size_unfixed: ['warning', 'Fixed stage dimensions could not be found.', 'Set #stage width and height to positive px values.'],
22
+ shot_missing: ['error', 'The shot definition is missing.', 'Call Shot.define({...}) or assign window.SHOT.'],
23
+ runtime_missing: ['error', 'Shot.define is used without the runtime.', 'Include /multiplane/runtime/shot.js before defining the shot.'],
24
+ async_render: ['error', 'The render function is asynchronous.', 'Await resources in setup and make render(t) synchronous.'],
25
+ font_not_awaited: ['warning', 'Custom fonts have no explicit loading declaration.', 'Add fonts to Shot.define or await document.fonts.load in setup.'],
26
+ lint_directive_invalid: ['warning', 'The lint directive has no valid rule codes.', 'List rule codes after multiplane-disable-next-line.'],
27
+ lint_incomplete: ['warning', 'Source parsing failed.', 'Correct the source syntax to complete lint coverage.'],
28
+ };
29
+ const children = node => Object.values(node).flatMap(v => Array.isArray(v) ? v : [v]).filter(v => v && typeof v.type === 'string');
30
+ const walk = (node, fn, parents = []) => { fn(node, parents); for (const child of children(node)) walk(child, fn, [...parents, node]); };
31
+ const member = n => n?.type === 'Identifier' ? n.name : n?.type === 'MemberExpression' && !n.computed ? `${member(n.object)}.${n.property.name}` : '';
32
+ const literal = n => n?.type === 'Literal' && typeof n.value === 'string' ? n.value : n?.type === 'TemplateLiteral' && !n.expressions.length ? n.quasis[0].value.cooked : null;
33
+
34
+ export async function scanShot(shot, options = {}) {
35
+ const root = await normalizeResourcePath(path.resolve(options.root ?? process.cwd()));
36
+ const entry = path.resolve(root, shot);
37
+ if (!await isInsideResourceRoot(root, entry)) throw new MultiplaneError('shot_outside_root');
38
+ let entryReal;
39
+ try { entryReal = await normalizeResourcePath(entry); if (!(await stat(entryReal)).isFile()) throw Error(); }
40
+ catch { throw new MultiplaneError('shot_not_found'); }
41
+ if (!await isInsideResourceRoot(root, entryReal)) throw new MultiplaneError('shot_outside_root');
42
+ const sources = new Map(), pending = [], findings = [], directives = new Map(), seen = new Set();
43
+ const state = { stage: null, width: false, height: false, shot: false, define: null, runtime: false, fonts: false, fontFace: null, fontLoad: false };
44
+ const location = (file, offset) => {
45
+ const prefix = sources.get(file).slice(0, offset), lines = prefix.split('\n');
46
+ return { file: path.relative(root, file).split(path.sep).join('/'), line: lines.length, column: lines.at(-1).length + 1 };
47
+ };
48
+ const add = (code, file, offset, extra = {}) => {
49
+ const loc = location(file, offset), [severity, message, hint] = rules[code];
50
+ if (findings.some(f => f.code === code && f.location.file === loc.file && f.location.line === loc.line && f.location.column === loc.column)) return;
51
+ findings.push({ code, severity, message, hint, location: loc, ...extra, details: { snippet: sources.get(file).split('\n')[loc.line - 1].trim().slice(0, 160), ...extra.details } });
52
+ };
53
+ const comment = (file, offset, text) => {
54
+ const match = /multiplane-disable-next-line\b([^\r\n]*)/.exec(text);
55
+ if (!match) return;
56
+ const codes = match[1].trim().split(/\s*,\s*/).filter(c => rules[c] && c !== 'lint_directive_invalid');
57
+ if (!codes.length) add('lint_directive_invalid', file, offset);
58
+ else {
59
+ const loc = location(file, offset + text.slice(0, match.index).length);
60
+ directives.set(`${loc.file}:${loc.line + 1}`, new Set([...(directives.get(`${loc.file}:${loc.line + 1}`) ?? []), ...codes]));
61
+ }
62
+ };
63
+ const resource = (url, file, offset, kind, module = false) => {
64
+ if (url === null) return;
65
+ url = url.trim();
66
+ if (/^(https?:)?\/\//i.test(url)) { add('remote_resource', file, offset); return; }
67
+ if (!url || /^(data:|blob:|#)/i.test(url)) return;
68
+ if ((kind === 'js' || module) && url.split(/[?#]/)[0] === '/multiplane/runtime/shot.js') state.runtime = true;
69
+ if (url.startsWith('/multiplane/')) return;
70
+ if (module && !/^[./]/.test(url)) return;
71
+ if (/^[a-z][\w+.-]*:/i.test(url)) return;
72
+ if (/\.(gif|apng)(?:[?#]|$)/i.test(url)) add('animated_image', file, offset);
73
+ pending.push({ url, file, offset, kind });
74
+ };
75
+ function css(text, file, base = 0, inlineStage = false) {
76
+ let tree;
77
+ try { tree = postcss.parse(text, { from: file, map: false }); }
78
+ catch (error) { add('lint_incomplete', file, base, { message: 'CSS parsing failed; source fragment was skipped.', details: { reason: error.message.split(/\r?\n/)[0] } }); return; }
79
+ tree.walk(node => {
80
+ const offset = base + node.source.start.offset;
81
+ if (node.type === 'comment') comment(file, offset, node.text);
82
+ if (node.type === 'atrule') {
83
+ if (/^(?:-\w+-)?keyframes$/i.test(node.name)) add('css_animation', file, offset);
84
+ if (node.name.toLowerCase() === 'font-face') state.fontFace ??= { file, offset };
85
+ if (node.name.toLowerCase() === 'import') {
86
+ const first = values(node.params).nodes.find(n => !['space', 'comment'].includes(n.type));
87
+ if (first?.type === 'string') resource(first.value, file, offset, 'css');
88
+ }
89
+ }
90
+ if (node.type === 'decl') {
91
+ const prop = node.prop.toLowerCase(), value = node.value.trim().toLowerCase();
92
+ if (['animation', 'animation-name'].includes(prop) && value.split(',').some(v => v.trim() !== 'none')) add('css_animation', file, offset);
93
+ if (['transition', 'transition-duration'].includes(prop)) {
94
+ const durations = value.match(/-?(?:\d*\.)?\d+m?s\b/g) ?? [];
95
+ if (durations.some(v => parseFloat(v) !== 0)) add('css_animation', file, offset);
96
+ }
97
+ if (inlineStage || (node.parent.type === 'rule' && node.parent.selector.split(',').some(s => s.trim() === '#stage'))) {
98
+ if (['width', 'height'].includes(prop) && /^\d+(?:\.\d+)?px$/.test(value) && parseFloat(value) > 0) state[prop] = true;
99
+ }
100
+ }
101
+ if (node.type === 'decl' || node.type === 'atrule') {
102
+ values(node.value ?? node.params).walk(v => {
103
+ if (v.type === 'function' && v.value.toLowerCase() === 'url') {
104
+ const url = v.nodes.filter(n => !['space', 'comment'].includes(n.type)).map(n => n.value).join('');
105
+ resource(url, file, offset, node.type === 'atrule' && node.name === 'import' ? 'css' : undefined);
106
+ return false;
107
+ }
108
+ });
109
+ }
110
+ });
111
+ }
112
+ function js(text, file, base = 0, module = false) {
113
+ let tree;
114
+ try { tree = parseJS(text, { ecmaVersion: 'latest', sourceType: module ? 'module' : 'script', allowAwaitOutsideFunction: true,
115
+ onComment: (_block, value, start) => comment(file, base + start, value) }); }
116
+ catch (error) { add('lint_incomplete', file, base, { message: 'JavaScript parsing failed; source fragment was skipped.', details: { reason: error.message.split(/\r?\n/)[0] } }); return; }
117
+ const bindings = new Map();
118
+ let globalShotVariable = false;
119
+ // Track lexical scopes so local variables named Date/Math/etc. do not match globals.
120
+ const isScope = n => /Function/.test(n.type) || ['Program', 'BlockStatement', 'ArrowFunctionExpression', 'CatchClause'].includes(n.type);
121
+ const bind = (scope, pattern) => {
122
+ if (!scope || !pattern) return;
123
+ const names = bindings.get(scope) ?? new Set(); bindings.set(scope, names);
124
+ if (pattern.type === 'Identifier') names.add(pattern.name);
125
+ else for (const c of children(pattern)) bind(scope, c);
126
+ };
127
+ walk(tree, (n, ancestors) => {
128
+ if (/Function/.test(n.type)) { for (const p of n.params ?? []) bind(n, p); if (n.id) bind(n.type === 'FunctionDeclaration' ? ancestors.findLast(isScope) : n, n.id); }
129
+ if (n.type === 'VariableDeclaration') for (const d of n.declarations) {
130
+ bind(ancestors.findLast(a => n.kind === 'var' ? /Function/.test(a.type) || a.type === 'Program' : isScope(a)), d.id);
131
+ if (!module && n.kind === 'var' && d.id.name === 'SHOT' && !ancestors.some(a => /Function/.test(a.type))) {
132
+ globalShotVariable = true;
133
+ if (d.init) state.shot = true;
134
+ }
135
+ }
136
+ if (n.type === 'ImportDeclaration') for (const s of n.specifiers) bind(tree, s.local);
137
+ if (n.type === 'ClassDeclaration') bind(ancestors.findLast(isScope), n.id);
138
+ if (n.type === 'CatchClause') bind(n, n.param);
139
+ });
140
+ walk(tree, (n, ancestors) => {
141
+ const offset = base + n.start, call = ['CallExpression', 'NewExpression'].includes(n.type);
142
+ const name = member(call ? n.callee : n), global = value => ![...ancestors, n].some(a => bindings.get(a)?.has(value.split('.')[0]));
143
+ if (call && global(name)) {
144
+ if (['Date.now', 'performance.now'].includes(name) || name === 'Date' && (n.type === 'CallExpression' || !n.arguments.length)) add('wall_clock', file, offset);
145
+ if (['Math.random', 'crypto.getRandomValues', 'crypto.randomUUID'].includes(name)) add('unseeded_random', file, offset);
146
+ if (['setTimeout', 'setInterval', 'requestAnimationFrame', 'requestIdleCallback'].includes(name.replace(/^(window|globalThis)\./, ''))) add('timer_driven', file, offset);
147
+ if (name === 'Animation' && n.type === 'NewExpression') add('web_animations', file, offset);
148
+ if (name === 'Audio' && n.type === 'NewExpression') add('media_playback', file, offset);
149
+ if (name === 'fetch') resource(literal(n.arguments[0]), file, base + (n.arguments[0]?.start ?? n.start));
150
+ if (name === 'FontFace') {
151
+ const source = literal(n.arguments[1]);
152
+ if (source) values(source).walk(v => { if (v.type === 'function' && v.value === 'url') resource(v.nodes.map(x => x.value).join(''), file, base + n.arguments[1].start); });
153
+ }
154
+ if (name === 'Shot.define') {
155
+ state.shot = true; state.define ??= { file, offset };
156
+ // An indirect definition may supply fonts; do not infer their absence.
157
+ state.fonts ||= n.arguments[0]?.type !== 'ObjectExpression' || n.arguments[0].properties.some(p => (p.key?.name ?? p.key?.value) === 'fonts' || p.type === 'SpreadElement');
158
+ }
159
+ if (name === 'document.fonts.load') state.fontLoad = true;
160
+ }
161
+ if (call && n.callee.type === 'MemberExpression' && !n.callee.computed) {
162
+ if (n.callee.property.name === 'animate') add('web_animations', file, offset);
163
+ if (n.callee.property.name === 'play') add('media_playback', file, offset);
164
+ }
165
+ if (name === 'document.timeline' && global(name)) add('web_animations', file, offset);
166
+ if (n.type === 'AssignmentExpression') {
167
+ const target = member(n.left);
168
+ if (['SHOT', 'window.SHOT'].includes(target) && (global(target) || target === 'SHOT' && globalShotVariable
169
+ && !ancestors.some(a => a.type !== 'Program' && bindings.get(a)?.has('SHOT')))) state.shot = true;
170
+ const value = literal(n.right);
171
+ if (/\.style\.(animation|transition)$/.test(target) && value !== null && value !== '') add('css_animation', file, offset);
172
+ }
173
+ if (n.type === 'ImportDeclaration' || ['ExportNamedDeclaration', 'ExportAllDeclaration'].includes(n.type) && n.source) resource(literal(n.source), file, base + n.source.start, 'js', true);
174
+ if (n.type === 'ImportExpression') resource(literal(n.source), file, base + n.source.start, undefined, true);
175
+ const render = ['Property', 'MethodDefinition'].includes(n.type) && (n.key.name ?? n.key.value) === 'render' ? n.value
176
+ : n.type === 'VariableDeclarator' && n.id.name === 'render' ? n.init
177
+ : n.type === 'FunctionDeclaration' && n.id?.name === 'render' ? n
178
+ : n.type === 'AssignmentExpression' && /(?:^|\.)render$/.test(member(n.left)) ? n.right : null;
179
+ if (render && /Function/.test(render.type)) {
180
+ let async = render.async;
181
+ walk(render.body, child => { if (child.type === 'AwaitExpression') async = true; });
182
+ if (async) add('async_render', file, offset);
183
+ }
184
+ });
185
+ }
186
+ async function scan(file, kind) {
187
+ if (seen.has(file)) return;
188
+ seen.add(file);
189
+ const text = await readFile(file, 'utf8'); sources.set(file, text);
190
+ if (kind === 'js') return js(text, file, 0, true);
191
+ if (kind === 'css') return css(text, file);
192
+ const tree = parseHTML(text, { sourceCodeLocationInfo: true });
193
+ function visit(node) {
194
+ const loc = node.sourceCodeLocation, attrs = Object.fromEntries((node.attrs ?? []).map(a => [a.name, a.value]));
195
+ if (node.nodeName === '#comment') comment(file, loc.startOffset, node.data);
196
+ if (loc && node.tagName) {
197
+ if (attrs.id === 'stage') state.stage ??= { file, offset: loc.startOffset };
198
+ if (['video', 'audio'].includes(node.tagName)) add('media_playback', file, loc.startOffset);
199
+ for (const a of node.attrs ?? []) {
200
+ const attrLoc = loc.attrs[a.name];
201
+ if (a.name === 'style') {
202
+ const raw = text.slice(attrLoc.startOffset, attrLoc.endOffset), match = /=\s*["']?/.exec(raw);
203
+ if (match) {
204
+ const valueStart = match.index + match[0].length;
205
+ css(raw.slice(valueStart).replace(/["']$/, ''), file, attrLoc.startOffset + valueStart, attrs.id === 'stage');
206
+ }
207
+ }
208
+ if (['src', 'href', 'poster', 'xlink:href'].includes(a.name) && !(node.tagName === 'a' && a.name === 'href')) resource(a.value, file, attrLoc.startOffset,
209
+ node.tagName === 'script' ? 'js' : node.tagName === 'link' && attrs.rel?.split(/\s+/).includes('stylesheet') ? 'css' : undefined);
210
+ }
211
+ if (node.tagName === 'script' && !attrs.src && (!attrs.type || /^(module|(?:text|application)\/(?:java|ecma)script)$/i.test(attrs.type))) js(text.slice(loc.startTag.endOffset, loc.endTag?.startOffset ?? loc.endOffset), file, loc.startTag.endOffset, attrs.type === 'module');
212
+ if (node.tagName === 'style') css(text.slice(loc.startTag.endOffset, loc.endTag?.startOffset ?? loc.endOffset), file, loc.startTag.endOffset);
213
+ }
214
+ for (const child of node.childNodes ?? []) visit(child);
215
+ }
216
+ visit(tree);
217
+ }
218
+ await scan(entryReal, 'html');
219
+ for (let i = 0; i < pending.length; i++) {
220
+ const { url, file, offset, kind } = pending[i];
221
+ let target;
222
+ try {
223
+ const decoded = decodeURIComponent(url.split(/[?#]/)[0]);
224
+ target = path.resolve(decoded.startsWith('/') ? root : path.dirname(file), decoded.replace(/^\//, ''));
225
+ if (!await isInsideResourceRoot(root, target)) throw Error();
226
+ target = await normalizeResourcePath(target);
227
+ if (!await isInsideResourceRoot(root, target) || !(await stat(target)).isFile()) throw Error();
228
+ } catch { add('resource_missing', file, offset); continue; }
229
+ if (kind) await scan(target, kind);
230
+ }
231
+ if (!state.stage) add('stage_missing', entryReal, 0);
232
+ else if (!state.width || !state.height) add('stage_size_unfixed', state.stage.file, state.stage.offset);
233
+ if (!state.shot) add('shot_missing', entryReal, 0);
234
+ if (state.define && !state.runtime) add('runtime_missing', state.define.file, state.define.offset);
235
+ if (state.fontFace && !state.fonts && !state.fontLoad) add('font_not_awaited', state.fontFace.file, state.fontFace.offset);
236
+ const visible = findings.filter(f => !directives.get(`${f.location.file}:${f.location.line}`)?.has(f.code));
237
+ visible.sort((a, b) => a.location.file.localeCompare(b.location.file) || a.location.line - b.location.line || a.location.column - b.location.column || a.code.localeCompare(b.code));
238
+ const data = { files: [...sources.keys()].map(f => path.relative(root, f).split(path.sep).join('/')), errors: visible.filter(f => f.severity === 'error').length,
239
+ warnings: visible.filter(f => f.severity === 'warning').length, suppressed: findings.length - visible.length };
240
+ return { findings: visible, data };
241
+ }
242
+
243
+ export async function checkLint(shot, options, events) {
244
+ const { findings, data } = await scanShot(shot, options);
245
+ for (const finding of findings) events.emit('finding', finding);
246
+ if (data.errors) throw new MultiplaneError('lint_failed', undefined, { result: { data } });
247
+ return data;
248
+ }
@@ -1,36 +1,36 @@
1
- import { spawn } from 'node:child_process';
2
- import { stopChild } from './process.mjs';
3
- import { MultiplaneError } from './errors.mjs';
4
-
5
- export async function checkAlphaVideo(ffmpeg, file, format, references, width, height, events) {
6
- const indices = [...references.keys()];
7
- const pixels = width * height;
8
- // TODO(design): Document the maximum alpha error of 2/255 for encoded frames.
9
- const tolerance = 2;
10
- return new Promise((resolve, reject) => {
11
- const child = spawn(ffmpeg, ['-hide_banner', '-nostdin', '-loglevel', 'error',
12
- ...(format === 'webm' ? ['-c:v', 'libvpx-vp9'] : []), '-i', file,
13
- '-map', '0:v:0', '-vf', `select=${indices.map(i => `eq(n\\,${i})`).join('+')},alphaextract`,
14
- '-fps_mode', 'passthrough', '-frames:v', String(indices.length), '-pix_fmt', 'gray', '-f', 'rawvideo', 'pipe:1'],
15
- { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
16
- const unsubscribe = events?.onCancel?.(() => stopChild(child));
17
- let count = 0, maxError = 0, stderr = '', overflow = false;
18
- child.stdout.on('data', bytes => {
19
- for (const actual of bytes) {
20
- const reference = references.get(indices[Math.floor(count / pixels)]);
21
- if (!reference) overflow = true;
22
- else maxError = Math.max(maxError, Math.abs(actual - reference[count % pixels]));
23
- count++;
24
- }
25
- });
26
- child.stderr.on('data', bytes => { stderr = (stderr + bytes).slice(-4000); });
27
- child.once('error', cause => reject(new MultiplaneError('output_invalid', 'Alpha could not be decoded.', { cause })));
28
- child.once('close', code => {
29
- unsubscribe?.();
30
- try { events?.check(); } catch (error) { reject(error); return; }
31
- if (code !== 0 || overflow || count !== pixels * indices.length || maxError > tolerance) {
32
- reject(new MultiplaneError('output_invalid', 'Decoded alpha does not match the source frames.', { details: { frames: indices, maxAlphaError: maxError, tolerance, decodedBytes: count, stderr } }));
33
- } else resolve({ frames: indices, maxAlphaError: maxError, tolerance });
34
- });
35
- });
36
- }
1
+ import { spawn } from 'node:child_process';
2
+ import { stopChild } from './process.mjs';
3
+ import { MultiplaneError } from './errors.mjs';
4
+
5
+ export async function checkAlphaVideo(ffmpeg, file, format, references, width, height, events) {
6
+ const indices = [...references.keys()];
7
+ const pixels = width * height;
8
+ // TODO(design): Document the maximum alpha error of 2/255 for encoded frames.
9
+ const tolerance = 2;
10
+ return new Promise((resolve, reject) => {
11
+ const child = spawn(ffmpeg, ['-hide_banner', '-nostdin', '-loglevel', 'error',
12
+ ...(format === 'webm' ? ['-c:v', 'libvpx-vp9'] : []), '-i', file,
13
+ '-map', '0:v:0', '-vf', `select=${indices.map(i => `eq(n\\,${i})`).join('+')},alphaextract`,
14
+ '-fps_mode', 'passthrough', '-frames:v', String(indices.length), '-pix_fmt', 'gray', '-f', 'rawvideo', 'pipe:1'],
15
+ { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
16
+ const unsubscribe = events?.onCancel?.(() => stopChild(child));
17
+ let count = 0, maxError = 0, stderr = '', overflow = false;
18
+ child.stdout.on('data', bytes => {
19
+ for (const actual of bytes) {
20
+ const reference = references.get(indices[Math.floor(count / pixels)]);
21
+ if (!reference) overflow = true;
22
+ else maxError = Math.max(maxError, Math.abs(actual - reference[count % pixels]));
23
+ count++;
24
+ }
25
+ });
26
+ child.stderr.on('data', bytes => { stderr = (stderr + bytes).slice(-4000); });
27
+ child.once('error', cause => reject(new MultiplaneError('output_invalid', 'Alpha could not be decoded.', { cause })));
28
+ child.once('close', code => {
29
+ unsubscribe?.();
30
+ try { events?.check(); } catch (error) { reject(error); return; }
31
+ if (code !== 0 || overflow || count !== pixels * indices.length || maxError > tolerance) {
32
+ reject(new MultiplaneError('output_invalid', 'Decoded alpha does not match the source frames.', { details: { frames: indices, maxAlphaError: maxError, tolerance, decodedBytes: count, stderr } }));
33
+ } else resolve({ frames: indices, maxAlphaError: maxError, tolerance });
34
+ });
35
+ });
36
+ }