multiplane 0.0.0-stage → 0.3.0

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 (132) hide show
  1. package/LICENSE +202 -0
  2. package/README.en.md +322 -0
  3. package/README.md +321 -2
  4. package/THIRD_PARTY_NOTICES.md +30 -0
  5. package/bin/multiplane +98 -0
  6. package/examples/alpha.html +18 -0
  7. package/examples/animation.html +17 -0
  8. package/examples/assets/OFL.txt +93 -0
  9. package/examples/assets/Roboto-Variable.ttf +0 -0
  10. package/examples/assets/card.svg +6 -0
  11. package/examples/assets/readme-card-0.svg +1 -0
  12. package/examples/assets/readme-card-1.svg +1 -0
  13. package/examples/assets/readme-card-2.svg +1 -0
  14. package/examples/assets/readme-detail.svg +1 -0
  15. package/examples/readme-cards.html +23 -0
  16. package/examples/readme-cut.html +13 -0
  17. package/examples/readme-depth.html +15 -0
  18. package/examples/readme-wheel.html +14 -0
  19. package/examples/three-cards.html +17 -0
  20. package/examples/typography.html +17 -0
  21. package/licenses/GPL-3.0.txt +232 -0
  22. package/licenses/LGPL-3.0.txt +304 -0
  23. package/licenses/acorn-LICENSE +21 -0
  24. package/licenses/colour-LICENSE.md +82 -0
  25. package/licenses/detect-libc-LICENSE +201 -0
  26. package/licenses/entities-LICENSE +11 -0
  27. package/licenses/nanoid-LICENSE +20 -0
  28. package/licenses/native/README.ijg +258 -0
  29. package/licenses/native/aom-LICENSE +27 -0
  30. package/licenses/native/aom-PATENTS +108 -0
  31. package/licenses/native/archive-COPYING +65 -0
  32. package/licenses/native/cairo-COPYING +33 -0
  33. package/licenses/native/cairo-COPYING-MPL-1.1 +470 -0
  34. package/licenses/native/cgif-LICENSE +21 -0
  35. package/licenses/native/exif-COPYING +504 -0
  36. package/licenses/native/expat-COPYING +21 -0
  37. package/licenses/native/ffi-LICENSE +21 -0
  38. package/licenses/native/fontconfig-COPYING +200 -0
  39. package/licenses/native/freetype-FTL.TXT +169 -0
  40. package/licenses/native/fribidi-COPYING +501 -0
  41. package/licenses/native/glib-COPYING +175 -0
  42. package/licenses/native/harfbuzz-COPYING +42 -0
  43. package/licenses/native/heif-COPYING +871 -0
  44. package/licenses/native/highway-LICENSE +371 -0
  45. package/licenses/native/imagequant-COPYRIGHT +36 -0
  46. package/licenses/native/lcms-COPYING +22 -0
  47. package/licenses/native/mozjpeg-LICENSE.md +135 -0
  48. package/licenses/native/nsgif-COPYING +21 -0
  49. package/licenses/native/pango-COPYING +482 -0
  50. package/licenses/native/pixman-COPYING +42 -0
  51. package/licenses/native/png-LICENSE +134 -0
  52. package/licenses/native/proxy-libintl-COPYING +482 -0
  53. package/licenses/native/rsvg-COPYING.LIB +502 -0
  54. package/licenses/native/sources.json +240 -0
  55. package/licenses/native/tiff-LICENSE.md +49 -0
  56. package/licenses/native/uhdr-LICENSE +230 -0
  57. package/licenses/native/vips-COPYING +502 -0
  58. package/licenses/native/webp-COPYING +30 -0
  59. package/licenses/native/webp-PATENTS +23 -0
  60. package/licenses/native/xml2-Copyright +24 -0
  61. package/licenses/native/zlib-ng-LICENSE.md +19 -0
  62. package/licenses/parse5-LICENSE +19 -0
  63. package/licenses/picocolors-LICENSE +15 -0
  64. package/licenses/playwright-LICENSE +202 -0
  65. package/licenses/playwright-NOTICE +5 -0
  66. package/licenses/playwright-ThirdPartyNotices.txt +14 -0
  67. package/licenses/playwright-core-ThirdPartyNotices.txt +13 -0
  68. package/licenses/postcss-LICENSE +20 -0
  69. package/licenses/postcss-value-parser-LICENSE +22 -0
  70. package/licenses/semver-LICENSE +15 -0
  71. package/licenses/sharp-LICENSE +191 -0
  72. package/licenses/sharp-win32-x64-LICENSE +191 -0
  73. package/licenses/sharp-win32-x64-NOTICES.md +59 -0
  74. package/licenses/source-map-js-LICENSE +28 -0
  75. package/licenses/three-LICENSE +21 -0
  76. package/package.json +92 -6
  77. package/skills/multiplane/SKILL.md +213 -0
  78. package/src/alpha.mjs +69 -0
  79. package/src/artifacts.mjs +61 -0
  80. package/src/benchmark.mjs +140 -0
  81. package/src/browser-recovery.mjs +26 -0
  82. package/src/capture.mjs +224 -0
  83. package/src/config.mjs +79 -0
  84. package/src/doctor.mjs +56 -0
  85. package/src/draft.mjs +30 -0
  86. package/src/encoder.mjs +16 -0
  87. package/src/environment.mjs +56 -0
  88. package/src/errors.mjs +51 -0
  89. package/src/events.mjs +252 -0
  90. package/src/ffmpeg.mjs +32 -0
  91. package/src/format.mjs +27 -0
  92. package/src/frame-rate.mjs +28 -0
  93. package/src/home-migration.mjs +54 -0
  94. package/src/hosted.mjs +134 -0
  95. package/src/index.mjs +14 -0
  96. package/src/inspect.mjs +20 -0
  97. package/src/jobs.mjs +51 -0
  98. package/src/lint.mjs +248 -0
  99. package/src/output-alpha.mjs +36 -0
  100. package/src/output-check.mjs +110 -0
  101. package/src/pixel-comparison.mjs +43 -0
  102. package/src/platform.mjs +114 -0
  103. package/src/png-native.mjs +12 -0
  104. package/src/png.mjs +74 -0
  105. package/src/post.mjs +433 -0
  106. package/src/preview.mjs +23 -0
  107. package/src/process-identity.mjs +17 -0
  108. package/src/process.mjs +9 -0
  109. package/src/render-pool.mjs +93 -0
  110. package/src/render-worker.mjs +59 -0
  111. package/src/render.mjs +157 -0
  112. package/src/resource-path.mjs +29 -0
  113. package/src/resources.mjs +27 -0
  114. package/src/runtime/camera.js +37 -0
  115. package/src/runtime/shot.css +9 -0
  116. package/src/runtime/shot.js +71 -0
  117. package/src/serve.mjs +23 -0
  118. package/src/server.mjs +93 -0
  119. package/src/setup.mjs +51 -0
  120. package/src/skills.mjs +128 -0
  121. package/src/static-reuse.mjs +265 -0
  122. package/src/stills.mjs +81 -0
  123. package/src/three/index.js +348 -0
  124. package/src/user-home.mjs +105 -0
  125. package/src/verify.mjs +161 -0
  126. package/src/video-pipe.mjs +6 -0
  127. package/src/workers.mjs +62 -0
  128. package/tools/bench/shots/canvas-2d.html +1 -0
  129. package/tools/bench/shots/dom-light.html +1 -0
  130. package/tools/bench/shots/scene.js +45 -0
  131. package/tools/bench/shots/webgl.html +1 -0
  132. package/tools/install-browser.mjs +18 -0
package/src/hosted.mjs ADDED
@@ -0,0 +1,134 @@
1
+ import path from 'node:path';
2
+ import { mkdir, readFile, writeFile, rename, open, stat, unlink } from 'node:fs/promises';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { userHome } from './platform.mjs';
5
+ import { processCreatedAt } from './process-identity.mjs';
6
+ import { errorCodes, MultiplaneError } from './errors.mjs';
7
+
8
+ const schema = 'multiplane/1';
9
+ export const hostedBase = (jobId, home = userHome()) => path.join(home, 'hosted', jobId);
10
+ async function json(file) {
11
+ try { return JSON.parse(await readFile(file, 'utf8')); }
12
+ catch (error) { if (error.code === 'ENOENT' || error instanceof SyntaxError) return null; throw error; }
13
+ }
14
+ async function exists(file) { try { await stat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } }
15
+ async function move(from, to) {
16
+ try { await rename(from, to); return true; }
17
+ catch (error) { if (error.code === 'ENOENT') return false; throw error; }
18
+ }
19
+
20
+ export async function hostedAvailable(command, options, env = process.env) {
21
+ if (!['render', 'stills', 'verify'].includes(command) || !options._hostedCLI || options.hosted !== true
22
+ || options['no-hosted'] || options['hosted-executor'] || env.MULTIPLANE_HOSTED === '0') return false;
23
+ try {
24
+ const app = await json(path.join(userHome(env), 'app.json'));
25
+ return app?.schema === schema && app.acceptsHosted === true && typeof app.version === 'string'
26
+ && typeof app.createdAt === 'string' && app.createdAt === await processCreatedAt(app.pid);
27
+ } catch { return false; }
28
+ }
29
+
30
+ export function hostedEnvironment(env) {
31
+ const allowed = /^(PATH|MULTIPLANE_.*|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|TEMP|TMP|TMPDIR|HOME|USERPROFILE|APPDATA|LOCALAPPDATA|SystemRoot|ComSpec|PATHEXT|LANG|LC_.*)$/i;
32
+ const secret = /TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH|BEARER|COOKIE|SESSION|API_?KEY|PRIVATE_?KEY|ACCESS_?KEY/i;
33
+ return Object.fromEntries(Object.entries(env).filter(([key, value]) => {
34
+ if (value === undefined || !allowed.test(key) || secret.test(key)) return false;
35
+ // Do not persist proxy passwords or tokens carried in proxy URLs.
36
+ if (/PROXY$/i.test(key) && !/^NO_PROXY$/i.test(key)) {
37
+ try { const url = new URL(value); if (url.username || url.password || url.search || url.hash) return false; }
38
+ catch { return false; }
39
+ }
40
+ return true;
41
+ }));
42
+ }
43
+
44
+ export async function handoff(request, { home = userHome(), timeout = 5000, startTimeout = 15000,
45
+ interval = 100, announce = message => process.stderr.write(message + '\n') } = {}) {
46
+ const base = hostedBase(request.jobId, home);
47
+ try {
48
+ await mkdir(path.dirname(base), { recursive: true });
49
+ await writeFile(base + '.tmp', JSON.stringify(request) + '\n', { flag: 'wx', mode: 0o600 });
50
+ await rename(base + '.tmp', base + '.request.json');
51
+ } catch {
52
+ await unlink(base + '.tmp').catch(() => {});
53
+ return { reason: 'The hosted request could not be published.' };
54
+ }
55
+ const deadline = performance.now() + timeout;
56
+ let accepted = false, startDeadline = Infinity;
57
+ for (;;) {
58
+ if (await exists(base + '.rejected.json')) {
59
+ const rejected = await json(base + '.rejected.json');
60
+ return { reason: typeof rejected?.reason === 'string' ? rejected.reason : 'The app rejected the request.' };
61
+ }
62
+ const running = await exists(base + '.running.json');
63
+ const claimed = await exists(base + '.claimed.json');
64
+ const status = await json(path.join(request.jobDir, 'status.json'));
65
+ const queued = status?.state === 'queued';
66
+ if (!accepted && (claimed || running || status?.result)) {
67
+ accepted = true; startDeadline = performance.now() + startTimeout;
68
+ }
69
+ if (running || status?.result || (accepted && queued)) {
70
+ announce('Job ' + request.jobId + ' has been handed to Multiplane'
71
+ + (queued ? (Number.isSafeInteger(status.queuePosition) && status.queuePosition > 0 ? ' (queued; #' + status.queuePosition + ')' : ' (queued; position unavailable)') : '') + '. Closing this session will not interrupt it.');
72
+ return { hosted: true };
73
+ }
74
+ if (accepted && performance.now() >= startDeadline && status?.state !== 'queued') {
75
+ if (await move(base + '.claimed.json', base + '.withdrawn.json')) return { reason: 'The app did not start the job within 15 seconds.' };
76
+ }
77
+ if (!accepted && performance.now() >= deadline) {
78
+ if (await move(base + '.request.json', base + '.withdrawn.json')) return { reason: 'The app did not claim the job within 5 seconds.' };
79
+ }
80
+ await delay(interval);
81
+ }
82
+ }
83
+
84
+ // Claim before loading config, creating status, linting or opening output files.
85
+ export async function claimExecutor(jobDir, command) {
86
+ try {
87
+ if (!path.isAbsolute(jobDir) || !['render', 'stills', 'verify'].includes(command)) return false;
88
+ const jobId = path.basename(jobDir), base = hostedBase(jobId);
89
+ const request = await json(base + '.claimed.json');
90
+ if (request?.schema !== schema || request.jobId !== jobId || request.command !== command
91
+ || path.resolve(request.jobDir ?? '') !== path.resolve(jobDir)) return false;
92
+ return await move(base + '.claimed.json', base + '.running.json');
93
+ } catch { return false; }
94
+ }
95
+
96
+ export const resultExitCode = result => result.ok ? 0 : errorCodes[result.code]?.exitCode ?? 4;
97
+
98
+ // Keep bytes intact across partial writes and UTF-8 boundaries. Only complete lines
99
+ // are published, so timeout diagnostics cannot splice into a partial JSON record.
100
+ export async function followJob(directory, { timeout = Infinity, onBytes, onEvent, interval = 100 } = {}) {
101
+ const deadline = performance.now() + timeout * 1000;
102
+ let offset = 0, pending = Buffer.alloc(0);
103
+ for (;;) {
104
+ let handle;
105
+ try {
106
+ handle = await open(path.join(directory, 'events.jsonl'), 'r');
107
+ for (;;) {
108
+ const buffer = Buffer.alloc(64 * 1024);
109
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
110
+ if (!bytesRead) break;
111
+ offset += bytesRead;
112
+ pending = Buffer.concat([pending, buffer.subarray(0, bytesRead)]);
113
+ let end;
114
+ while ((end = pending.indexOf(10)) !== -1) {
115
+ const line = pending.subarray(0, end + 1); pending = pending.subarray(end + 1);
116
+ const event = JSON.parse(line.toString('utf8'));
117
+ await onBytes?.(line); onEvent?.(event);
118
+ if (event.type === 'result') return event;
119
+ }
120
+ }
121
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
122
+ finally { await handle?.close(); }
123
+ const status = await json(path.join(directory, 'status.json'));
124
+ if (status?.schema !== schema || !Number.isSafeInteger(status.pid) || status.pid <= 0
125
+ || typeof status.state !== 'string') throw new MultiplaneError('job_not_found');
126
+ // A result is committed to the event log before status; read the next poll.
127
+ if (!status.result && status.state === 'running') {
128
+ try { process.kill(status.pid, 0); }
129
+ catch (error) { if (error.code === 'ESRCH') throw new MultiplaneError('internal_error', 'Job process exited without a result.'); }
130
+ }
131
+ if (performance.now() >= deadline) throw new MultiplaneError('wait_timeout');
132
+ await delay(interval);
133
+ }
134
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,14 @@
1
+ export { renderShot } from './render.mjs';
2
+ export { stillsShot } from './stills.mjs';
3
+ export { verifyShot } from './verify.mjs';
4
+ export { openShot } from './capture.mjs';
5
+ export { inspectShot } from './inspect.mjs';
6
+ export { createPostCapture } from './post.mjs';
7
+ export { createRenderPool } from './render-pool.mjs';
8
+ export { MultiplaneError, errorCodes } from './errors.mjs';
9
+ export { doctor } from './doctor.mjs';
10
+ export { statusJob, cancelJob, waitJob } from './jobs.mjs';
11
+
12
+ import { runCommand } from './events.mjs';
13
+ import { checkLint } from './lint.mjs';
14
+ export const lintShot = (shot, options = {}) => runCommand('lint', shot, options, events => checkLint(shot, events.options, events), false);
@@ -0,0 +1,20 @@
1
+ import path from 'node:path';
2
+ import { openShot, number } from './capture.mjs';
3
+ import { runCommand } from './events.mjs';
4
+ import { frameRate } from './frame-rate.mjs';
5
+ import { invalidOption } from './errors.mjs';
6
+
7
+ export async function inspectShot(shot, options = {}) {
8
+ return runCommand('inspect', shot, options, async events => {
9
+ const capture = await openShot(shot, 1, events.options);
10
+ try {
11
+ const { duration, width, height, cuts, post, hasBlurAt, meta } = capture;
12
+ const fps = number(events.options.fps ?? capture.fps, '--fps', { positive: true });
13
+ frameRate(fps);
14
+ const frames = Math.ceil(duration * fps - 1e-9);
15
+ if (!frames) throw invalidOption('--from', 'Range contains no frames.');
16
+ return { shot: path.resolve(events.options.root, shot), duration, fps, frames,
17
+ stage: { width, height }, cuts, post, hasBlurAt, meta };
18
+ } finally { await capture.close(); }
19
+ }, false);
20
+ }
package/src/jobs.mjs ADDED
@@ -0,0 +1,51 @@
1
+ import path from 'node:path';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { setTimeout } from 'node:timers/promises';
4
+ import { jobDirectory, runCommand } from './events.mjs';
5
+ import { MultiplaneError, invalidOption } from './errors.mjs';
6
+ import { number } from './capture.mjs';
7
+ import { followJob } from './hosted.mjs';
8
+ import { loadUserConfig } from './user-home.mjs';
9
+
10
+ const resolveJob = (target, options) => path.isAbsolute(target) || /[\\/]/.test(target) ? path.resolve(target) : path.join(jobDirectory(options), target);
11
+ export async function readStatus(target, options = {}) {
12
+ let status;
13
+ try { status = JSON.parse(await readFile(path.join(resolveJob(target, options), 'status.json'), 'utf8')); }
14
+ catch (cause) { throw new MultiplaneError('job_not_found'); }
15
+ if (!status || status.schema !== 'multiplane/1' || !Number.isSafeInteger(status.pid) || status.pid <= 0
16
+ || typeof status.state !== 'string') throw new MultiplaneError('job_not_found');
17
+ if (status.state === 'running') {
18
+ try { process.kill(status.pid, 0); }
19
+ catch (error) {
20
+ if (error.code === 'ESRCH') {
21
+ // Report a vanished process as failed/internal_error without changing the status file.
22
+ status = { ...status, state: 'failed', result: { schema: 'multiplane/1', type: 'result', command: status.command,
23
+ ts: new Date().toISOString(), ok: false, code: 'internal_error', message: 'Job process exited without a result.',
24
+ artifacts: [], elapsedMs: Math.max(0, Date.now() - Date.parse(status.startedAt)), jobId: status.jobId, data: {} } };
25
+ }
26
+ }
27
+ }
28
+ return status;
29
+ }
30
+ export const statusJob = (target, options = {}) => runCommand('status', null, options, events => readStatus(target, events.options), false);
31
+ const active = status => !['succeeded', 'failed', 'cancelled'].includes(status.state);
32
+ export async function waitJob(target, options = {}) {
33
+ const timeout = options.timeout === undefined ? Infinity : Number(options.timeout);
34
+ if (options.timeout !== undefined && (!String(options.timeout).trim() || !Number.isFinite(timeout) || timeout < 0)) throw invalidOption('--timeout', 'Invalid value for --timeout.');
35
+ const configured = loadUserConfig('wait', options).options;
36
+ return followJob(resolveJob(target, configured), { timeout, onBytes: options.onBytes, onEvent: options.onEvent });
37
+ }
38
+ export const cancelJob = (target, options = {}) => runCommand('cancel', null, { wait: 30, ...options }, async events => {
39
+ options = events.options;
40
+ const wait = number(options.wait ?? 30, '--wait');
41
+ let status = await readStatus(target, options);
42
+ if (!active(status)) return status;
43
+ await writeFile(path.join(resolveJob(target, options), 'stop'), '');
44
+ const deadline = performance.now() + wait * 1000;
45
+ while (active(status) && performance.now() < deadline) {
46
+ await setTimeout(100);
47
+ status = await readStatus(target, options);
48
+ }
49
+ if (active(status)) throw new MultiplaneError('cancel_timeout', 'Timed out waiting for job cancellation.', { result: { data: status } });
50
+ return status;
51
+ }, false);
package/src/lint.mjs ADDED
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
@@ -0,0 +1,110 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile, rm } from 'node:fs/promises';
3
+ import { decodeNativePNG } from './png-native.mjs';
4
+ import { MultiplaneError } from './errors.mjs';
5
+ import { stopChild } from './process.mjs';
6
+
7
+ // Container timestamp precision (notably WebM/Matroska) makes decoded rates
8
+ // approximate; apply the same relative tolerance to every output format.
9
+ const FRAME_RATE_RELATIVE_TOLERANCE = 1e-4;
10
+
11
+ async function probePass(ffmpeg, args, events, spawnProcess, line) {
12
+ events?.check?.();
13
+ await new Promise((resolve, reject) => {
14
+ const child = spawnProcess(ffmpeg, ['-hide_banner', '-nostdin', '-nostats', ...args], { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] });
15
+ const unsubscribe = events?.onCancel?.(() => stopChild(child));
16
+ let buffer = '';
17
+ child.stderr.on('data', bytes => {
18
+ buffer += bytes.toString();
19
+ let end;
20
+ while ((end = buffer.indexOf('\n')) >= 0) { line(buffer.slice(0, end)); buffer = buffer.slice(end + 1); }
21
+ if (buffer.length > 65536) buffer = buffer.slice(-65536);
22
+ });
23
+ child.once('error', error => { unsubscribe?.(); reject(error); });
24
+ child.once('close', code => {
25
+ unsubscribe?.(); line(buffer);
26
+ if (code !== 0) return reject(new Error('FFmpeg probe exited with code ' + code + '.'));
27
+ resolve();
28
+ });
29
+ });
30
+ events?.check?.();
31
+ }
32
+
33
+ export async function probeVideo(ffmpeg, file, events, spawnProcess = spawn, options = {}) {
34
+ // Count copied video packets independently of decoding and audio scheduling.
35
+ let packetCount;
36
+ await probePass(ffmpeg, ['-i', file, '-map', '0:v:0', '-an', '-c', 'copy', '-loglevel', 'verbose', '-f', 'null', '-'], events, spawnProcess, text => {
37
+ const count = text.match(/Output stream #0:0 \(video\):\s*(\d+) packets muxed/);
38
+ if (count) packetCount = Number(count[1]);
39
+ });
40
+ if (!Number.isSafeInteger(packetCount)) throw new Error('FFmpeg packet count could not be parsed.');
41
+ let frames = 0, minTime = Infinity, maxTime = -Infinity, audioStart = Infinity, audioEnd = -Infinity;
42
+ let width, height, fps, codec, pixelFormat, timeBase;
43
+ let input = true, hasAudio = false;
44
+ const line = text => {
45
+ if (text.startsWith('Stream mapping:')) input = false;
46
+ if (input && !codec) codec = text.match(/Stream #0:\d+.*Video: (\w+)/)?.[1];
47
+ if (input && /Stream #0:\d+.*Audio:/.test(text)) hasAudio = true;
48
+ if (text.includes('Parsed_showinfo')) {
49
+ const rate = text.match(/config in time_base:\s*(\d+)\/(\d+), frame_rate:\s*(\d+)\/(\d+)/);
50
+ if (rate) { timeBase = Number(rate[1]) / Number(rate[2]); fps = Number(rate[3]) / Number(rate[4]); }
51
+ const frame = text.match(/\bn:\s*\d+\s+pts:\s*(-?\d+)\s+pts_time:.*?fmt:(\S+).*?\bs:(\d+)x(\d+)/);
52
+ if (frame) {
53
+ frames++;
54
+ const t = Number(frame[1]) * timeBase;
55
+ const duration = Number(text.match(/\bduration:\s*(\d+)/)?.[1]) * timeBase;
56
+ minTime = Math.min(minTime, t); maxTime = Math.max(maxTime, t + (duration > 0 ? duration : 1 / fps));
57
+ pixelFormat = frame[2]; width = Number(frame[3]); height = Number(frame[4]);
58
+ }
59
+ }
60
+ if (text.includes('Parsed_ashowinfo')) {
61
+ const audio = text.match(/pts_time:([\d.e+-]+).*?rate:(\d+).*?nb_samples:(\d+)/);
62
+ if (audio) { audioStart = Math.min(audioStart, Number(audio[1])); audioEnd = Math.max(audioEnd, Number(audio[1]) + Number(audio[3]) / Number(audio[2])); }
63
+ }
64
+ };
65
+
66
+ await probePass(ffmpeg, [...(options.alpha && options.format === 'webm' ? ['-c:v', 'libvpx-vp9'] : []), '-i', file,
67
+ '-map', '0:v:0', '-an', '-vf', 'showinfo=checksum=0', '-fps_mode', 'passthrough', '-f', 'null', '-'], events, spawnProcess, line);
68
+ if (packetCount !== frames) throw new MultiplaneError('output_invalid', 'Video packet count and decoded frame count disagree.', {
69
+ details: { reason: 'video_count_mismatch', packetCount, decodedFrames: frames },
70
+ });
71
+ if (!frames || !width || !height || !Number.isFinite(fps) || fps <= 0 || !Number.isFinite(maxTime - minTime) || maxTime <= minTime || !codec || !pixelFormat) throw new Error('FFmpeg probe output could not be parsed.');
72
+ if (hasAudio) {
73
+ await probePass(ffmpeg, ['-i', file, '-map', '0:a:0', '-vn', '-af', 'ashowinfo', '-f', 'null', '-'], events, spawnProcess, line);
74
+ if (!Number.isFinite(audioEnd - audioStart) || audioEnd <= audioStart) throw new Error('FFmpeg audio duration could not be parsed.');
75
+ }
76
+ return { frames, durationSeconds: maxTime - minTime, width, height, fps, codec, pixelFormat,
77
+ // TODO(design): Document the optional audioDurationSeconds field.
78
+ ...(Number.isFinite(audioEnd) ? { audioDurationSeconds: audioEnd - audioStart } : {}) };
79
+ }
80
+
81
+ export async function checkVideo(ffmpeg, file, expected, events, probe = probeVideo) {
82
+ let actual;
83
+ try { actual = await probe(ffmpeg, file, events, undefined, expected); }
84
+ catch (error) {
85
+ events.check();
86
+ if (error.code === 'output_invalid') throw error;
87
+ if (expected.alpha) throw new MultiplaneError('output_invalid', 'Transparent output could not be verified.', { cause: error });
88
+ events.warning({ code: 'output_unverified', message: 'Output could not be verified.', details: { reason: error.message } });
89
+ return { verified: false, frames: null, durationSeconds: null, width: null, height: null, fps: null, codec: null, pixelFormat: null };
90
+ }
91
+ events.check();
92
+ const durationPrecision = 8 * Number.EPSILON * Math.max(1, expected.durationSeconds, actual.durationSeconds);
93
+ const mismatch = ['frames', 'width', 'height'].some(key => actual[key] !== expected[key])
94
+ || Math.abs(actual.fps - expected.fps) / expected.fps > FRAME_RATE_RELATIVE_TOLERANCE
95
+ || Math.abs(actual.durationSeconds - expected.durationSeconds) - 1 / expected.fps > durationPrecision;
96
+ if (expected.alpha && !/^(yuva|gbrap|rgba|bgra|argb|abgr)/.test(actual.pixelFormat)) throw new MultiplaneError('output_invalid', 'Decoded output has no alpha channel.', { details: { actual } });
97
+ if (mismatch) throw new MultiplaneError('output_invalid', undefined, { details: { expected, actual } });
98
+ return { verified: true, ...actual };
99
+ }
100
+
101
+ export async function checkStill(file, expected) {
102
+ let actual = { width: null, height: null };
103
+ try {
104
+ const decoded = await decodeNativePNG(await readFile(file));
105
+ actual = { width: decoded.width, height: decoded.height };
106
+ if (actual.width === expected.width && actual.height === expected.height) return;
107
+ } catch {}
108
+ await rm(file, { force: true });
109
+ throw new MultiplaneError('output_invalid', 'Still image could not be decoded or has incorrect dimensions.', { details: { expected, actual } });
110
+ }