pixi-effects 0.4.0 → 0.5.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 +30 -1
  2. package/README.md +9 -5
  3. package/ai/SKILL.md +7 -4
  4. package/ai/reference/cheatsheet.md +16 -5
  5. package/ai/reference/pitfalls.md +15 -11
  6. package/ai/reference/recipes.md +117 -0
  7. package/ai/template.html +6 -3
  8. package/ai/tools/check.mjs +385 -0
  9. package/dist/{Base-5R7C5pHR.d.cts → Base-CPSFBkPm.d.ts} +6 -1
  10. package/dist/{Base-DhY9oMxr.d.ts → Base-DnRAxroQ.d.cts} +6 -1
  11. package/dist/Composition-63EXHNJJ.cjs +13 -0
  12. package/dist/{Composition-D7RCAGEA.cjs.map → Composition-63EXHNJJ.cjs.map} +1 -1
  13. package/dist/Composition-TUCRSMJU.js +4 -0
  14. package/dist/{Composition-RGTRZ2JX.js.map → Composition-TUCRSMJU.js.map} +1 -1
  15. package/dist/Controller.cjs +37 -13
  16. package/dist/Controller.cjs.map +1 -1
  17. package/dist/Controller.d.cts +4 -2
  18. package/dist/Controller.d.ts +4 -2
  19. package/dist/Controller.js +37 -13
  20. package/dist/Controller.js.map +1 -1
  21. package/dist/{Movie-DRZtIGU7.d.ts → Movie-CuU_aaCm.d.cts} +56 -2
  22. package/dist/{Movie-CghFXprP.d.cts → Movie-VyDiUYOj.d.ts} +56 -2
  23. package/dist/{chunk-QGBOC5YL.cjs → chunk-23M5QTWW.cjs} +33 -21
  24. package/dist/chunk-23M5QTWW.cjs.map +1 -0
  25. package/dist/{chunk-6BQ6IEW2.cjs → chunk-2RR43OIN.cjs} +143 -30
  26. package/dist/chunk-2RR43OIN.cjs.map +1 -0
  27. package/dist/{chunk-A574IA4F.js → chunk-5Q5XZG7Z.js} +18 -6
  28. package/dist/chunk-5Q5XZG7Z.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-ZQX7WIYL.js → chunk-UBPZLGLU.js} +143 -31
  32. package/dist/chunk-UBPZLGLU.js.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 +291 -86
  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 +275 -72
  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-7VtTEKUW.d.cts} +96 -6
  50. package/dist/{types-CRE9WKd4.d.cts → types-7VtTEKUW.d.ts} +96 -6
  51. package/docs/api.md +36 -0
  52. package/docs/dsl.md +44 -3
  53. package/llms-full.txt +234 -22
  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.cjs';
2
+ import { S as SequenceSpec, C as CompositionShape, B as AudioDescriptor } from './types-7VtTEKUW.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
@@ -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-7VtTEKUW.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
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ var chunk23M5QTWW_cjs = require('./chunk-23M5QTWW.cjs');
4
+ require('./chunk-2RR43OIN.cjs');
5
+
6
+
7
+
8
+ Object.defineProperty(exports, "CompositionSequence", {
9
+ enumerable: true,
10
+ get: function () { return chunk23M5QTWW_cjs.CompositionSequence; }
11
+ });
12
+ //# sourceMappingURL=Composition-63EXHNJJ.cjs.map
13
+ //# sourceMappingURL=Composition-63EXHNJJ.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-D7RCAGEA.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-63EXHNJJ.cjs"}
@@ -0,0 +1,4 @@
1
+ export { CompositionSequence } from './chunk-5Q5XZG7Z.js';
2
+ import './chunk-UBPZLGLU.js';
3
+ //# sourceMappingURL=Composition-TUCRSMJU.js.map
4
+ //# sourceMappingURL=Composition-TUCRSMJU.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-RGTRZ2JX.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-TUCRSMJU.js"}
@@ -320,6 +320,8 @@ var Controller = class _Controller {
320
320
  isVolumeScrubbing = false;
321
321
  fullscreenChangeHandler = null;
322
322
  wheelHandler = null;
323
+ /** Ends a drag the pointerup of which never arrives (see bindScrubbing). */
324
+ windowBlurHandler = null;
323
325
  constructor(movie, options) {
324
326
  if (!options || !options.canvas) {
325
327
  throw new Error("Controller requires options.canvas (HTMLCanvasElement).");
@@ -516,23 +518,29 @@ var Controller = class _Controller {
516
518
  }
517
519
  setFromPointer(e.clientX);
518
520
  };
519
- const onMove = (e) => {
520
- if (!this.isVolumeScrubbing) return;
521
- setFromPointer(e.clientX);
522
- };
523
- const onUp = (e) => {
521
+ const finish = (pointerId) => {
524
522
  if (!this.isVolumeScrubbing) return;
525
523
  this.isVolumeScrubbing = false;
526
524
  slider.classList.remove("mc-scrubbing");
527
525
  try {
528
- slider.releasePointerCapture(e.pointerId);
526
+ if (pointerId !== void 0) slider.releasePointerCapture(pointerId);
529
527
  } catch {
530
528
  }
531
529
  };
530
+ const onMove = (e) => {
531
+ if (!this.isVolumeScrubbing) return;
532
+ if (e.buttons === 0) {
533
+ finish(e.pointerId);
534
+ return;
535
+ }
536
+ setFromPointer(e.clientX);
537
+ };
538
+ const onUp = (e) => finish(e.pointerId);
532
539
  slider.addEventListener("pointerdown", onDown);
533
540
  slider.addEventListener("pointermove", onMove);
534
541
  slider.addEventListener("pointerup", onUp);
535
542
  slider.addEventListener("pointercancel", onUp);
543
+ slider.addEventListener("lostpointercapture", () => finish());
536
544
  this.wheelHandler = (e) => {
537
545
  e.preventDefault();
538
546
  this.adjustVolume(e.deltaY < 0 ? 0.05 : -0.05);
@@ -595,18 +603,14 @@ var Controller = class _Controller {
595
603
  }
596
604
  this.seekFromPointer(e.clientX);
597
605
  };
598
- const onMove = (e) => {
599
- if (!this.isScrubbing) return;
600
- this.seekFromPointer(e.clientX);
601
- };
602
- const onUp = (e) => {
606
+ const finish = (clientX, pointerId) => {
603
607
  if (!this.isScrubbing) return;
604
- this.seekFromPointer(e.clientX);
608
+ if (clientX !== void 0) this.seekFromPointer(clientX);
605
609
  this.isScrubbing = false;
606
610
  this.activePointerId = null;
607
611
  this.progressEl.classList.remove("mc-scrubbing");
608
612
  try {
609
- this.progressEl.releasePointerCapture(e.pointerId);
613
+ if (pointerId !== void 0) this.progressEl.releasePointerCapture(pointerId);
610
614
  } catch {
611
615
  }
612
616
  if (this.wasPlayingBeforeScrub) {
@@ -614,10 +618,26 @@ var Controller = class _Controller {
614
618
  this.refreshPlayIcon();
615
619
  }
616
620
  };
621
+ const onMove = (e) => {
622
+ if (!this.isScrubbing) return;
623
+ if (e.buttons === 0) {
624
+ finish(void 0, e.pointerId);
625
+ return;
626
+ }
627
+ this.seekFromPointer(e.clientX);
628
+ };
629
+ const onUp = (e) => finish(e.clientX, e.pointerId);
617
630
  this.progressEl.addEventListener("pointerdown", onDown);
618
631
  this.progressEl.addEventListener("pointermove", onMove);
619
632
  this.progressEl.addEventListener("pointerup", onUp);
620
633
  this.progressEl.addEventListener("pointercancel", onUp);
634
+ this.progressEl.addEventListener("lostpointercapture", () => finish());
635
+ this.windowBlurHandler = () => {
636
+ finish();
637
+ this.isVolumeScrubbing = false;
638
+ this.volumeSliderEl?.classList.remove("mc-scrubbing");
639
+ };
640
+ window.addEventListener("blur", this.windowBlurHandler);
621
641
  }
622
642
  seekFromPointer(clientX) {
623
643
  const rect = this.progressEl.getBoundingClientRect();
@@ -913,6 +933,10 @@ var Controller = class _Controller {
913
933
  document.removeEventListener("fullscreenchange", this.fullscreenChangeHandler);
914
934
  this.fullscreenChangeHandler = null;
915
935
  }
936
+ if (this.windowBlurHandler) {
937
+ window.removeEventListener("blur", this.windowBlurHandler);
938
+ this.windowBlurHandler = null;
939
+ }
916
940
  if (this.wheelHandler && this.volumeContainerEl) {
917
941
  this.volumeContainerEl.removeEventListener("wheel", this.wheelHandler);
918
942
  this.wheelHandler = null;