pixi-effects 0.4.0 → 0.6.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 (61) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +11 -6
  3. package/ai/SKILL.md +7 -4
  4. package/ai/reference/cheatsheet.md +18 -5
  5. package/ai/reference/pitfalls.md +16 -11
  6. package/ai/reference/recipes.md +153 -1
  7. package/ai/template.html +6 -3
  8. package/ai/tools/check.mjs +385 -0
  9. package/dist/{Base-DhY9oMxr.d.ts → Base-4vNLl4tH.d.cts} +6 -1
  10. package/dist/{Base-5R7C5pHR.d.cts → Base-CD4AoT5-.d.ts} +6 -1
  11. package/dist/Composition-7SO5WYHU.js +4 -0
  12. package/dist/{Composition-RGTRZ2JX.js.map → Composition-7SO5WYHU.js.map} +1 -1
  13. package/dist/Composition-Y5VZSDDP.cjs +13 -0
  14. package/dist/{Composition-D7RCAGEA.cjs.map → Composition-Y5VZSDDP.cjs.map} +1 -1
  15. package/dist/Controller.cjs +47 -15
  16. package/dist/Controller.cjs.map +1 -1
  17. package/dist/Controller.d.cts +5 -2
  18. package/dist/Controller.d.ts +5 -2
  19. package/dist/Controller.js +47 -15
  20. package/dist/Controller.js.map +1 -1
  21. package/dist/{Movie-DRZtIGU7.d.ts → Movie-CADDBfpl.d.cts} +58 -2
  22. package/dist/{Movie-CghFXprP.d.cts → Movie-Ch2SUI1h.d.ts} +58 -2
  23. package/dist/{chunk-QGBOC5YL.cjs → chunk-4AGJF5RK.cjs} +33 -21
  24. package/dist/chunk-4AGJF5RK.cjs.map +1 -0
  25. package/dist/{chunk-ZQX7WIYL.js → chunk-7IJHJXDI.js} +380 -64
  26. package/dist/chunk-7IJHJXDI.js.map +1 -0
  27. package/dist/{chunk-A574IA4F.js → chunk-B2FTJLNB.js} +18 -6
  28. package/dist/chunk-B2FTJLNB.js.map +1 -0
  29. package/dist/chunk-DKGG7WD4.js +61 -0
  30. package/dist/chunk-DKGG7WD4.js.map +1 -0
  31. package/dist/{chunk-6BQ6IEW2.cjs → chunk-KQM2ZZMK.cjs} +380 -63
  32. package/dist/chunk-KQM2ZZMK.cjs.map +1 -0
  33. package/dist/chunk-UQBGW5DO.cjs +64 -0
  34. package/dist/chunk-UQBGW5DO.cjs.map +1 -0
  35. package/dist/index.cjs +333 -89
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +54 -5
  38. package/dist/index.d.ts +54 -5
  39. package/dist/index.js +317 -75
  40. package/dist/index.js.map +1 -1
  41. package/dist/sfx-MZJZXYY3.cjs +446 -0
  42. package/dist/sfx-MZJZXYY3.cjs.map +1 -0
  43. package/dist/sfx-S37DDC66.js +434 -0
  44. package/dist/sfx-S37DDC66.js.map +1 -0
  45. package/dist/three.cjs +5 -5
  46. package/dist/three.d.cts +2 -2
  47. package/dist/three.d.ts +2 -2
  48. package/dist/three.js +1 -1
  49. package/dist/{types-CRE9WKd4.d.ts → types-DrW9QeKU.d.cts} +118 -12
  50. package/dist/{types-CRE9WKd4.d.cts → types-DrW9QeKU.d.ts} +118 -12
  51. package/docs/api.md +37 -0
  52. package/docs/dsl.md +70 -10
  53. package/llms-full.txt +300 -30
  54. package/llms.txt +1 -1
  55. package/package.json +2 -1
  56. package/dist/Composition-D7RCAGEA.cjs +0 -13
  57. package/dist/Composition-RGTRZ2JX.js +0 -4
  58. package/dist/chunk-6BQ6IEW2.cjs.map +0 -1
  59. package/dist/chunk-A574IA4F.js.map +0 -1
  60. package/dist/chunk-QGBOC5YL.cjs.map +0 -1
  61. package/dist/chunk-ZQX7WIYL.js.map +0 -1
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pixi-effects check — review a composition page in ONE command, without driving a browser by hand.
4
+ *
5
+ * node ai/tools/check.mjs my-video.html (or: npx pixi-effects-check my-video.html)
6
+ *
7
+ * It starts a private static server and a private headless Chrome (nothing shared with other sessions), opens the page,
8
+ * waits for `window.__ready === true`, then checks: console warnings / errors, `movie.inspect` over the whole timeline
9
+ * (grouped), `movie.inspectAudio()`, a contact sheet (a PNG you can look at), and a real export (`movie.render`) that it
10
+ * decodes again: video size / length, audio length, loudness per second, silence. Output goes to `check-out/<name>/`
11
+ * (`sheet.png`, `<name>.<format>`, `report.json`) and a short summary is printed. Exit code 0 = nothing to fix, 1 = problems.
12
+ *
13
+ * Needs: Node >= 22 (built-in WebSocket), Chrome / Chromium installed (or --chrome PATH / CHROME=PATH). No npm dependencies.
14
+ * The page must follow ai/template.html: it exposes `window.movie` and sets `window.__ready = true` (and `window.__logs`).
15
+ *
16
+ * Options: --strict (text overlaps fail the check; by default they are only listed for review) · --out DIR · --frames N (contact sheet tiles, default 12) · --formats mp4,webm (default mp4) · --no-export ·
17
+ * --timeout SECONDS (default 240) · --root DIR (static server root; default: the nearest folder above the page with dist/) · --chrome PATH
18
+ */
19
+ import { spawn } from 'node:child_process';
20
+ import http from 'node:http';
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
+
26
+ // ───────────────────────────── pure helpers (unit-tested) ─────────────────────────────
27
+
28
+ /** Chrome / Chromium executable: $CHROME, then the usual install paths. Null if none is found. */
29
+ export function findChrome(env = process.env, exists = fs.existsSync, platform = process.platform) {
30
+ if (env.CHROME && exists(env.CHROME)) return env.CHROME;
31
+ const candidates = platform === 'darwin'
32
+ ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge']
33
+ : platform === 'win32'
34
+ ? [`${env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`, `${env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`, `${env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`]
35
+ : ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'];
36
+ return candidates.find(c => c && exists(c)) ?? null;
37
+ }
38
+
39
+ export function parseArgs(argv) {
40
+ const o = { page: null, out: null, frames: 12, formats: ['mp4'], export: true, timeout: 240, root: null, chrome: null, strict: false };
41
+ const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ if (a === '--out') o.out = need(i++, a);
45
+ else if (a === '--frames') o.frames = Math.max(1, Math.round(Number(need(i++, a))) || 12);
46
+ else if (a === '--formats') o.formats = need(i++, a).split(',').map(s => s.trim()).filter(Boolean);
47
+ else if (a === '--no-export') o.export = false;
48
+ else if (a === '--strict') o.strict = true;
49
+ else if (a === '--timeout') o.timeout = Math.max(10, Number(need(i++, a)) || 240);
50
+ else if (a === '--root') o.root = need(i++, a);
51
+ else if (a === '--chrome') o.chrome = need(i++, a);
52
+ else if (a.startsWith('--')) throw new Error(`unknown option ${a} (see the header of check.mjs)`);
53
+ else if (!o.page) o.page = a;
54
+ else throw new Error(`unexpected argument ${a}`);
55
+ }
56
+ return o;
57
+ }
58
+
59
+ /** [{ frame, issues[] }] → [{ message, count, firstFrame, lastFrame }]: issues that differ only by numbers are one. */
60
+ export function groupIssues(perFrame) {
61
+ const groups = new Map();
62
+ for (const { frame, issues } of perFrame) {
63
+ for (const message of issues) {
64
+ const key = message.replace(/-?\d+(\.\d+)?/g, '#');
65
+ const g = groups.get(key);
66
+ if (g) { g.count++; g.lastFrame = frame; }
67
+ else groups.set(key, { message, count: 1, firstFrame: frame, lastFrame: frame });
68
+ }
69
+ }
70
+ return [...groups.values()];
71
+ }
72
+
73
+ /**
74
+ * Layout issues → { problems, review }. Cut off by an edge, outside the canvas and "no size" are exact: problems. Text that
75
+ * overlaps text is judged on layout boxes, not ink, so intentional designs trip it (ghost layers, a glow copy under a title,
76
+ * per-letter boxes, a wipe between two scenes: 7 of 30 gallery pieces): those are `review` items to look at on the contact
77
+ * sheet. `strict` makes every issue a problem.
78
+ */
79
+ export function splitIssues(groups, strict = false) {
80
+ if (strict) return { problems: groups, review: [] };
81
+ const overlap = g => /\boverlap\b/.test(g.message);
82
+ return { problems: groups.filter(g => !overlap(g)), review: groups.filter(overlap) };
83
+ }
84
+
85
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.mp4': 'video/mp4', '.webm': 'video/webm', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.wasm': 'application/wasm', '.map': 'application/json', '.md': 'text/plain; charset=utf-8', '.txt': 'text/plain; charset=utf-8' };
86
+
87
+ /** A plain static server for `root` (no redirects, so `?query` survives). Resolves { server, port }. */
88
+ export function serve(root) {
89
+ const server = http.createServer((req, res) => {
90
+ try {
91
+ let rel = decodeURIComponent(new URL(req.url, 'http://x').pathname);
92
+ let file = path.join(root, rel);
93
+ if (!file.startsWith(root)) { res.writeHead(403).end(); return; }
94
+ if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
95
+ if (!fs.existsSync(file)) { res.writeHead(404).end('not found'); return; }
96
+ res.writeHead(200, { 'content-type': MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream', 'cache-control': 'no-store' });
97
+ fs.createReadStream(file).pipe(res);
98
+ } catch { res.writeHead(500).end(); }
99
+ });
100
+ return new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port })));
101
+ }
102
+
103
+ /** The nearest folder at or above `dir` that contains dist/index.js (the library build), else `dir`. */
104
+ export function findRoot(dir, exists = fs.existsSync) {
105
+ for (let d = path.resolve(dir); ; d = path.dirname(d)) {
106
+ if (exists(path.join(d, 'dist', 'index.js'))) return d;
107
+ if (path.dirname(d) === d) return path.resolve(dir);
108
+ }
109
+ }
110
+
111
+ // ───────────────────────────── Chrome DevTools Protocol, minimal ─────────────────────────────
112
+
113
+ class Cdp {
114
+ constructor(ws) {
115
+ this.ws = ws; this.id = 0; this.pending = new Map(); this.listeners = [];
116
+ ws.onmessage = ev => {
117
+ const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : Buffer.from(ev.data).toString());
118
+ if (msg.id && this.pending.has(msg.id)) {
119
+ const { resolve, reject } = this.pending.get(msg.id);
120
+ this.pending.delete(msg.id);
121
+ msg.error ? reject(new Error(`${msg.error.message}`)) : resolve(msg.result);
122
+ } else if (msg.method) for (const fn of this.listeners) fn(msg);
123
+ };
124
+ }
125
+ send(method, params = {}) {
126
+ const id = ++this.id;
127
+ this.ws.send(JSON.stringify({ id, method, params }));
128
+ return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
129
+ }
130
+ on(fn) { this.listeners.push(fn); }
131
+ /** Run an expression in the page and return its value (JSON-able). Awaits promises. */
132
+ async eval(expression) {
133
+ const r = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
134
+ if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text || 'page evaluation failed');
135
+ return r.result.value;
136
+ }
137
+ }
138
+
139
+ async function launchChrome(chrome, userDataDir) {
140
+ const proc = spawn(chrome, [
141
+ '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check',
142
+ '--autoplay-policy=no-user-gesture-required', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader', '--window-size=1400,900', 'about:blank',
143
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
144
+ const endpoint = await new Promise((resolve, reject) => {
145
+ let buf = '';
146
+ const t = setTimeout(() => reject(new Error('Chrome did not start (no DevTools endpoint within 20 s)')), 20000);
147
+ proc.stderr.on('data', d => { buf += d; const m = buf.match(/DevTools listening on (ws:\/\/[^\s]+)/); if (m) { clearTimeout(t); resolve(m[1]); } });
148
+ proc.on('exit', code => reject(new Error(`Chrome exited early (code ${code})`)));
149
+ });
150
+ const port = new URL(endpoint).port;
151
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
152
+ const page = targets.find(t => t.type === 'page');
153
+ const ws = new WebSocket(page.webSocketDebuggerUrl);
154
+ await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error('could not connect to Chrome')); });
155
+ return { proc, cdp: new Cdp(ws) };
156
+ }
157
+
158
+ // ───────────────────────────── in-page scripts ─────────────────────────────
159
+
160
+ const INFO = `(() => { const m = window.movie; if (!m) return { hasMovie: false, logs: window.__logs || [] };
161
+ const c = document.querySelector('canvas');
162
+ return { hasMovie: true, logs: window.__logs || [], totalFrames: m.totalFrames, frameRate: m.frameRate, duration: m.duration,
163
+ width: m.width ?? (c && c.width), height: m.height ?? (c && c.height), hasAudio: !!m.audioBuffer }; })()`;
164
+
165
+ const sweepScript = stride => `(async () => {
166
+ const total = movie.totalFrames, frames = new Set([0, total]);
167
+ for (let f = 0; f <= total; f += ${stride}) frames.add(Math.min(f, total));
168
+ const perFrame = [];
169
+ for (const f of [...frames].sort((a, b) => a - b)) {
170
+ const r = await movie.inspect(f, { layers: 'none' });
171
+ if (r.issues.length) perFrame.push({ frame: f, issues: r.issues });
172
+ }
173
+ return { checked: frames.size, perFrame };
174
+ })()`;
175
+
176
+ const exportScript = format => `(() => {
177
+ window.__x = { state: 'running' };
178
+ (async () => {
179
+ try {
180
+ const blob = await movie.render({ format: ${JSON.stringify(format)} });
181
+ const info = { bytes: blob.size, type: blob.type };
182
+ const url = URL.createObjectURL(blob);
183
+ const v = document.createElement('video'); v.muted = true; v.preload = 'metadata'; v.src = url;
184
+ try {
185
+ await new Promise((res, rej) => { v.onloadedmetadata = res; v.onerror = () => rej(new Error('the exported file has no readable video')); setTimeout(() => rej(new Error('video metadata timed out')), 15000); });
186
+ info.video = { duration: v.duration, width: v.videoWidth, height: v.videoHeight };
187
+ } catch (e) { info.video = { error: String(e.message || e) }; }
188
+ try {
189
+ const buf = await new OfflineAudioContext(2, 1, 48000).decodeAudioData(await blob.arrayBuffer());
190
+ const L = buf.getChannelData(0), R = buf.getChannelData(buf.numberOfChannels > 1 ? 1 : 0), sr = buf.sampleRate;
191
+ const rmsDb = [];
192
+ let peak = 0;
193
+ for (let s = 0; s < Math.ceil(buf.duration); s++) {
194
+ let sum = 0, n = 0;
195
+ for (let i = Math.floor(s * sr); i < Math.min(L.length, Math.floor((s + 1) * sr)); i++) { sum += L[i] * L[i] + R[i] * R[i]; n += 2; peak = Math.max(peak, Math.abs(L[i]), Math.abs(R[i])); }
196
+ rmsDb.push(Math.round(20 * Math.log10(Math.max(Math.sqrt(sum / Math.max(1, n)), 1e-6)) * 10) / 10);
197
+ }
198
+ info.audio = { duration: Math.round(buf.duration * 1000) / 1000, sampleRate: sr, peakDb: Math.round(20 * Math.log10(Math.max(peak, 1e-6)) * 10) / 10, rmsDbPerSecond: rmsDb };
199
+ } catch (e) { info.audio = { error: String(e.message || e) }; }
200
+ info.dataUrl = await new Promise(r => { const f = new FileReader(); f.onload = () => r(f.result); f.readAsDataURL(blob); });
201
+ window.__x = { state: 'done', info };
202
+ } catch (e) { window.__x = { state: 'error', message: String((e && e.message) || e) }; }
203
+ })();
204
+ return 0;
205
+ })()`;
206
+
207
+ // ───────────────────────────── the check ─────────────────────────────
208
+
209
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
210
+ /** A path for display: relative to the cwd when it is inside it, absolute otherwise. */
211
+ export const shown = f => { const r = path.relative(process.cwd(), f); return r.startsWith('..') || path.isAbsolute(r) ? f : r; };
212
+ const kb = n => (n >= 1048576 ? `${(n / 1048576).toFixed(2)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
213
+
214
+ export async function runCheck(opts, log = console.log) {
215
+ const pagePath = path.resolve(opts.page);
216
+ if (!fs.existsSync(pagePath)) throw new Error(`page not found: ${opts.page}`);
217
+ const name = path.basename(pagePath).replace(/\.[^.]+$/, '');
218
+ const outDir = path.resolve(opts.out ?? path.join('check-out', name));
219
+ fs.mkdirSync(outDir, { recursive: true });
220
+ const chrome = opts.chrome ?? findChrome();
221
+ if (!chrome) throw new Error('Chrome / Chromium not found: install it, or set CHROME=/path/to/chrome (or --chrome PATH)');
222
+ const root = path.resolve(opts.root ?? findRoot(path.dirname(pagePath)));
223
+ if (!pagePath.startsWith(root)) throw new Error(`the page must be inside the server root (${root}); use --root`);
224
+ const deadline = Date.now() + opts.timeout * 1000;
225
+ const left = () => { if (Date.now() > deadline) throw new Error(`timed out after ${opts.timeout} s`); };
226
+
227
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pixi-effects-check-'));
228
+ const { server, port } = await serve(root);
229
+ let chromeProc = null;
230
+ const report = { page: shown(pagePath), problems: [], files: {} };
231
+ try {
232
+ const { proc, cdp } = await launchChrome(chrome, userDataDir);
233
+ chromeProc = proc;
234
+ const consoleLines = [];
235
+ cdp.on(m => {
236
+ if (m.method === 'Runtime.exceptionThrown') consoleLines.push(`uncaught: ${m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text}`);
237
+ });
238
+ await cdp.send('Runtime.enable');
239
+ await cdp.send('Page.enable');
240
+ const t0 = Date.now();
241
+ await cdp.send('Page.navigate', { url: `http://127.0.0.1:${port}/${path.relative(root, pagePath).split(path.sep).join('/')}` });
242
+
243
+ // wait for the movie to be ready (or the page to say it failed)
244
+ let info = null;
245
+ for (;;) {
246
+ left();
247
+ await sleep(400);
248
+ try {
249
+ const ready = await cdp.eval('window.__ready === true');
250
+ info = await cdp.eval(INFO);
251
+ const failed = (info.logs || []).some(l => /^(init:|uncaught)/.test(l));
252
+ if (ready || failed || consoleLines.length) break;
253
+ } catch { /* the page is still loading */ }
254
+ if (Date.now() - t0 > 60000) break;
255
+ }
256
+ info = await cdp.eval(INFO).catch(() => info) ?? info;
257
+ report.ready = !!info?.hasMovie && (await cdp.eval('window.__ready === true').catch(() => false));
258
+ report.readySeconds = Math.round((Date.now() - t0) / 100) / 10;
259
+ report.logs = [...new Set([...(info?.logs ?? []), ...consoleLines])];
260
+ if (!report.ready) {
261
+ report.problems.push(`the page did not become ready (window.__ready !== true within 60 s${info?.hasMovie ? '' : '; window.movie is missing'})`);
262
+ return finish(report, outDir, log);
263
+ }
264
+ Object.assign(report, { width: info.width, height: info.height, frameRate: info.frameRate, totalFrames: info.totalFrames, duration: info.duration, hasAudio: info.hasAudio });
265
+ if (report.logs.length) report.problems.push(`${report.logs.length} console warning(s) / error(s) — each says what to change`);
266
+
267
+ // layout: inspect over the whole timeline
268
+ const stride = Math.max(1, Math.round(info.totalFrames / 60));
269
+ const sweep = await cdp.eval(sweepScript(stride));
270
+ const grouped = splitIssues(groupIssues(sweep.perFrame), opts.strict);
271
+ report.inspect = { checkedFrames: sweep.checked, issues: grouped.problems, review: grouped.review };
272
+ if (grouped.problems.length) report.problems.push(`${grouped.problems.length} kind(s) of layout issue from movie.inspect`);
273
+ left();
274
+
275
+ // contact sheet
276
+ const sheet = await cdp.eval(`movie.contactSheet({ count: ${opts.frames}, as: 'dataURL' })`);
277
+ const sheetFile = path.join(outDir, 'sheet.png');
278
+ fs.writeFileSync(sheetFile, Buffer.from(sheet.slice(sheet.indexOf(',') + 1), 'base64'));
279
+ report.files.sheet = shown(sheetFile);
280
+
281
+ // audio
282
+ report.audio = info.hasAudio ? await cdp.eval('movie.inspectAudio()') : null;
283
+ if (report.audio?.issues?.length) report.problems.push(`${report.audio.issues.length} audio issue(s) from movie.inspectAudio`);
284
+
285
+ // export, decoded again
286
+ report.exports = [];
287
+ if (opts.export) {
288
+ for (const format of opts.formats) {
289
+ left();
290
+ await cdp.eval(exportScript(format));
291
+ let state;
292
+ for (;;) {
293
+ left();
294
+ await sleep(500);
295
+ state = await cdp.eval('window.__x && window.__x.state');
296
+ if (state && state !== 'running') break;
297
+ }
298
+ const ex = { format };
299
+ if (state === 'error') {
300
+ ex.error = await cdp.eval('window.__x.message');
301
+ report.problems.push(`export ${format} failed: ${ex.error}`);
302
+ } else {
303
+ const meta = await cdp.eval('(() => { const i = window.__x.info; return { bytes: i.bytes, type: i.type, video: i.video, audio: i.audio, length: i.dataUrl.length }; })()');
304
+ let data = '';
305
+ for (let a = 0; a < meta.length; a += 8_000_000) data += await cdp.eval(`window.__x.info.dataUrl.slice(${a}, ${a + 8_000_000})`);
306
+ const file = path.join(outDir, `${name}.${format}`);
307
+ fs.writeFileSync(file, Buffer.from(data.slice(data.indexOf(',') + 1), 'base64'));
308
+ report.files[format] = shown(file);
309
+ Object.assign(ex, { bytes: meta.bytes, video: meta.video, audio: meta.audio });
310
+ if (meta.video?.error) report.problems.push(`export ${format}: ${meta.video.error}`);
311
+ else if (Math.abs(meta.video.duration - info.duration) > 0.15) report.problems.push(`export ${format}: the video is ${meta.video.duration.toFixed(2)} s, the movie ${info.duration} s`);
312
+ if (info.hasAudio) {
313
+ if (meta.audio?.error) report.problems.push(`export ${format}: the movie has audio but the file's audio could not be decoded (${meta.audio.error})`);
314
+ else if (meta.audio && Math.abs(meta.audio.duration - info.duration) > 0.15) report.problems.push(`export ${format}: the audio is ${meta.audio.duration} s, the movie ${info.duration} s`);
315
+ else if (meta.audio && meta.audio.rmsDbPerSecond.every(db => db < -60)) report.problems.push(`export ${format}: the exported audio is silent`);
316
+ }
317
+ }
318
+ report.exports.push(ex);
319
+ }
320
+ }
321
+ return finish(report, outDir, log);
322
+ } finally {
323
+ try { chromeProc?.kill(); } catch { /* already gone */ }
324
+ server.close();
325
+ await sleep(200);
326
+ try { fs.rmSync(userDataDir, { recursive: true, force: true }); } catch { /* chrome may still hold files */ }
327
+ }
328
+ }
329
+
330
+ function finish(report, outDir, log) {
331
+ const reportFile = path.join(outDir, 'report.json');
332
+ report.files.report = shown(reportFile);
333
+ report.ok = report.problems.length === 0;
334
+ fs.writeFileSync(reportFile, JSON.stringify(report, null, 2));
335
+ const L = [];
336
+ L.push(`pixi-effects check — ${report.page}`);
337
+ if (report.ready) L.push(` page ready in ${report.readySeconds} s · ${report.width}×${report.height} · ${report.frameRate} fps · ${report.totalFrames} frames (${report.duration} s)${report.hasAudio ? ' · audio' : ''}`);
338
+ else L.push(' page NOT READY');
339
+ L.push(` warnings ${report.logs?.length ? report.logs.length + ' — ' + report.logs.slice(0, 5).map(l => l.length > 220 ? l.slice(0, 217) + '…' : l).join('\n ') + (report.logs.length > 5 ? `\n … ${report.logs.length - 5} more (report.json)` : '') : 'none'}`);
340
+ if (report.inspect) {
341
+ const g = report.inspect.issues;
342
+ const line = i => ` - ${i.message}${i.count > 1 ? ` [${i.count} frames, ${i.firstFrame}–${i.lastFrame}]` : ` [frame ${i.firstFrame}]`}`;
343
+ L.push(` layout ${g.length ? g.length + ' kind(s) of issue over ' + report.inspect.checkedFrames + ' frames:' : 'no cut-off / off-canvas text over ' + report.inspect.checkedFrames + ' frames (movie.inspect)'}`);
344
+ for (const i of g.slice(0, 8)) L.push(line(i));
345
+ if (g.length > 8) L.push(` … ${g.length - 8} more (report.json)`);
346
+ const rv = report.inspect.review;
347
+ if (rv.length) {
348
+ L.push(` review ${rv.length} text overlap(s) — often intentional (ghost / glow copies, per-letter boxes, a wipe); check them on the contact sheet, or use --strict to fail on them:`);
349
+ for (const i of rv.slice(0, 4)) L.push(line(i));
350
+ if (rv.length > 4) L.push(` … ${rv.length - 4} more (report.json)`);
351
+ }
352
+ }
353
+ if (report.audio !== undefined) {
354
+ const a = report.audio;
355
+ L.push(a ? ` audio ${a.sources.length} source(s) · mix peak ${a.peakDb} dBFS · ${a.issues.length ? a.issues.join(' | ') : 'no issues (movie.inspectAudio)'}` : ' audio none');
356
+ }
357
+ for (const ex of report.exports ?? []) {
358
+ if (ex.error) { L.push(` export ${ex.format}: FAILED — ${ex.error}`); continue; }
359
+ const v = ex.video?.error ? `video: ${ex.video.error}` : `video ${ex.video.duration.toFixed(2)} s ${ex.video.width}×${ex.video.height}`;
360
+ const au = ex.audio ? (ex.audio.error ? `audio: ${ex.audio.error}` : `audio ${ex.audio.duration} s, peak ${ex.audio.peakDb} dBFS, rms/s [${ex.audio.rmsDbPerSecond.join(' ')}]`) : 'no audio track';
361
+ L.push(` export ${ex.format} ok · ${kb(ex.bytes)} · ${v} · ${au}`);
362
+ }
363
+ L.push(` files ${Object.values(report.files).join(' ')}`);
364
+ L.push(report.problems.length ? `RESULT: ${report.problems.length} PROBLEM(S)\n - ${report.problems.join('\n - ')}` : 'RESULT: OK — nothing to fix. Look at the contact sheet before you call it done.');
365
+ log(L.join('\n'));
366
+ return report;
367
+ }
368
+
369
+ // ───────────────────────────── CLI ─────────────────────────────
370
+
371
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
372
+ try {
373
+ if (typeof WebSocket === 'undefined') throw new Error('Node >= 22 is needed (built-in WebSocket)');
374
+ const opts = parseArgs(process.argv.slice(2));
375
+ if (!opts.page) {
376
+ console.error('usage: node ai/tools/check.mjs <page.html> [--out DIR] [--frames N] [--formats mp4,webm] [--no-export] [--timeout S] [--root DIR] [--chrome PATH]');
377
+ process.exit(2);
378
+ }
379
+ const report = await runCheck(opts);
380
+ process.exit(report.ok ? 0 : 1);
381
+ } catch (e) {
382
+ console.error(`pixi-effects check failed: ${e.message}`);
383
+ process.exit(2);
384
+ }
385
+ }
@@ -1,5 +1,5 @@
1
1
  import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape, t as AudioDescriptor } from './types-CRE9WKd4.js';
2
+ import { S as SequenceSpec, C as CompositionShape, B as AudioDescriptor } from './types-DrW9QeKU.cjs';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -62,6 +62,11 @@ declare abstract class Sequence {
62
62
  * (not a normal child) by PIXI when `target.mask = maskSequence.target`.
63
63
  */
64
64
  maskSequence: Sequence | null;
65
+ /**
66
+ * Set by the parent composition on a sprite-based (image / video) mask. PIXI hides such a mask with
67
+ * `renderable = false`; the lifespan toggles must not switch it back on, or the mask is drawn as a picture.
68
+ */
69
+ keepHidden: boolean;
65
70
  /**
66
71
  * Absolute start time on the global timeline (offset + at), recorded by
67
72
  * bindTimeline. Movie._awaitVideoFrames uses it to determine the correct
@@ -1,5 +1,5 @@
1
1
  import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape, t as AudioDescriptor } from './types-CRE9WKd4.cjs';
2
+ import { S as SequenceSpec, C as CompositionShape, B as AudioDescriptor } from './types-DrW9QeKU.js';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -62,6 +62,11 @@ declare abstract class Sequence {
62
62
  * (not a normal child) by PIXI when `target.mask = maskSequence.target`.
63
63
  */
64
64
  maskSequence: Sequence | null;
65
+ /**
66
+ * Set by the parent composition on a sprite-based (image / video) mask. PIXI hides such a mask with
67
+ * `renderable = false`; the lifespan toggles must not switch it back on, or the mask is drawn as a picture.
68
+ */
69
+ keepHidden: boolean;
65
70
  /**
66
71
  * Absolute start time on the global timeline (offset + at), recorded by
67
72
  * bindTimeline. Movie._awaitVideoFrames uses it to determine the correct
@@ -0,0 +1,4 @@
1
+ export { CompositionSequence } from './chunk-B2FTJLNB.js';
2
+ import './chunk-7IJHJXDI.js';
3
+ //# sourceMappingURL=Composition-7SO5WYHU.js.map
4
+ //# sourceMappingURL=Composition-7SO5WYHU.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-RGTRZ2JX.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-7SO5WYHU.js"}
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ var chunk4AGJF5RK_cjs = require('./chunk-4AGJF5RK.cjs');
4
+ require('./chunk-KQM2ZZMK.cjs');
5
+
6
+
7
+
8
+ Object.defineProperty(exports, "CompositionSequence", {
9
+ enumerable: true,
10
+ get: function () { return chunk4AGJF5RK_cjs.CompositionSequence; }
11
+ });
12
+ //# sourceMappingURL=Composition-Y5VZSDDP.cjs.map
13
+ //# sourceMappingURL=Composition-Y5VZSDDP.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-D7RCAGEA.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-Y5VZSDDP.cjs"}
@@ -309,6 +309,7 @@ var Controller = class _Controller {
309
309
  settingsOutsideHandler = null;
310
310
  onReady = null;
311
311
  onFrame = null;
312
+ onPlay = null;
312
313
  onPause = null;
313
314
  onProgress = null;
314
315
  onWrapPointerMove = null;
@@ -320,6 +321,8 @@ var Controller = class _Controller {
320
321
  isVolumeScrubbing = false;
321
322
  fullscreenChangeHandler = null;
322
323
  wheelHandler = null;
324
+ /** Ends a drag the pointerup of which never arrives (see bindScrubbing). */
325
+ windowBlurHandler = null;
323
326
  constructor(movie, options) {
324
327
  if (!options || !options.canvas) {
325
328
  throw new Error("Controller requires options.canvas (HTMLCanvasElement).");
@@ -449,6 +452,10 @@ var Controller = class _Controller {
449
452
  }
450
453
  this.refreshTime(frame);
451
454
  };
455
+ this.onPlay = () => {
456
+ this.refreshPlayIcon();
457
+ this.kickIdleTimer();
458
+ };
452
459
  this.onPause = () => {
453
460
  this.refreshPlayIcon();
454
461
  this.kickIdleTimer();
@@ -460,6 +467,7 @@ var Controller = class _Controller {
460
467
  };
461
468
  this.movie.on("ready", this.onReady);
462
469
  this.movie.on("frame", this.onFrame);
470
+ this.movie.on("play", this.onPlay);
463
471
  this.movie.on("pause", this.onPause);
464
472
  this.movie.on("progress", this.onProgress);
465
473
  }
@@ -469,8 +477,6 @@ var Controller = class _Controller {
469
477
  this.movie.pause();
470
478
  } else {
471
479
  this.movie.play();
472
- this.refreshPlayIcon();
473
- this.kickIdleTimer();
474
480
  }
475
481
  });
476
482
  }
@@ -516,23 +522,29 @@ var Controller = class _Controller {
516
522
  }
517
523
  setFromPointer(e.clientX);
518
524
  };
519
- const onMove = (e) => {
520
- if (!this.isVolumeScrubbing) return;
521
- setFromPointer(e.clientX);
522
- };
523
- const onUp = (e) => {
525
+ const finish = (pointerId) => {
524
526
  if (!this.isVolumeScrubbing) return;
525
527
  this.isVolumeScrubbing = false;
526
528
  slider.classList.remove("mc-scrubbing");
527
529
  try {
528
- slider.releasePointerCapture(e.pointerId);
530
+ if (pointerId !== void 0) slider.releasePointerCapture(pointerId);
529
531
  } catch {
530
532
  }
531
533
  };
534
+ const onMove = (e) => {
535
+ if (!this.isVolumeScrubbing) return;
536
+ if (e.buttons === 0) {
537
+ finish(e.pointerId);
538
+ return;
539
+ }
540
+ setFromPointer(e.clientX);
541
+ };
542
+ const onUp = (e) => finish(e.pointerId);
532
543
  slider.addEventListener("pointerdown", onDown);
533
544
  slider.addEventListener("pointermove", onMove);
534
545
  slider.addEventListener("pointerup", onUp);
535
546
  slider.addEventListener("pointercancel", onUp);
547
+ slider.addEventListener("lostpointercapture", () => finish());
536
548
  this.wheelHandler = (e) => {
537
549
  e.preventDefault();
538
550
  this.adjustVolume(e.deltaY < 0 ? 0.05 : -0.05);
@@ -595,18 +607,14 @@ var Controller = class _Controller {
595
607
  }
596
608
  this.seekFromPointer(e.clientX);
597
609
  };
598
- const onMove = (e) => {
610
+ const finish = (clientX, pointerId) => {
599
611
  if (!this.isScrubbing) return;
600
- this.seekFromPointer(e.clientX);
601
- };
602
- const onUp = (e) => {
603
- if (!this.isScrubbing) return;
604
- this.seekFromPointer(e.clientX);
612
+ if (clientX !== void 0) this.seekFromPointer(clientX);
605
613
  this.isScrubbing = false;
606
614
  this.activePointerId = null;
607
615
  this.progressEl.classList.remove("mc-scrubbing");
608
616
  try {
609
- this.progressEl.releasePointerCapture(e.pointerId);
617
+ if (pointerId !== void 0) this.progressEl.releasePointerCapture(pointerId);
610
618
  } catch {
611
619
  }
612
620
  if (this.wasPlayingBeforeScrub) {
@@ -614,10 +622,26 @@ var Controller = class _Controller {
614
622
  this.refreshPlayIcon();
615
623
  }
616
624
  };
625
+ const onMove = (e) => {
626
+ if (!this.isScrubbing) return;
627
+ if (e.buttons === 0) {
628
+ finish(void 0, e.pointerId);
629
+ return;
630
+ }
631
+ this.seekFromPointer(e.clientX);
632
+ };
633
+ const onUp = (e) => finish(e.clientX, e.pointerId);
617
634
  this.progressEl.addEventListener("pointerdown", onDown);
618
635
  this.progressEl.addEventListener("pointermove", onMove);
619
636
  this.progressEl.addEventListener("pointerup", onUp);
620
637
  this.progressEl.addEventListener("pointercancel", onUp);
638
+ this.progressEl.addEventListener("lostpointercapture", () => finish());
639
+ this.windowBlurHandler = () => {
640
+ finish();
641
+ this.isVolumeScrubbing = false;
642
+ this.volumeSliderEl?.classList.remove("mc-scrubbing");
643
+ };
644
+ window.addEventListener("blur", this.windowBlurHandler);
621
645
  }
622
646
  seekFromPointer(clientX) {
623
647
  const rect = this.progressEl.getBoundingClientRect();
@@ -866,6 +890,10 @@ var Controller = class _Controller {
866
890
  this.movie.off("frame", this.onFrame);
867
891
  this.onFrame = null;
868
892
  }
893
+ if (this.onPlay) {
894
+ this.movie.off("play", this.onPlay);
895
+ this.onPlay = null;
896
+ }
869
897
  if (this.onPause) {
870
898
  this.movie.off("pause", this.onPause);
871
899
  this.onPause = null;
@@ -913,6 +941,10 @@ var Controller = class _Controller {
913
941
  document.removeEventListener("fullscreenchange", this.fullscreenChangeHandler);
914
942
  this.fullscreenChangeHandler = null;
915
943
  }
944
+ if (this.windowBlurHandler) {
945
+ window.removeEventListener("blur", this.windowBlurHandler);
946
+ this.windowBlurHandler = null;
947
+ }
916
948
  if (this.wheelHandler && this.volumeContainerEl) {
917
949
  this.volumeContainerEl.removeEventListener("wheel", this.wheelHandler);
918
950
  this.wheelHandler = null;