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.
- package/CHANGELOG.md +45 -1
- package/README.md +11 -6
- package/ai/SKILL.md +7 -4
- package/ai/reference/cheatsheet.md +18 -5
- package/ai/reference/pitfalls.md +16 -11
- package/ai/reference/recipes.md +153 -1
- package/ai/template.html +6 -3
- package/ai/tools/check.mjs +385 -0
- package/dist/{Base-DhY9oMxr.d.ts → Base-4vNLl4tH.d.cts} +6 -1
- package/dist/{Base-5R7C5pHR.d.cts → Base-CD4AoT5-.d.ts} +6 -1
- package/dist/Composition-7SO5WYHU.js +4 -0
- package/dist/{Composition-RGTRZ2JX.js.map → Composition-7SO5WYHU.js.map} +1 -1
- package/dist/Composition-Y5VZSDDP.cjs +13 -0
- package/dist/{Composition-D7RCAGEA.cjs.map → Composition-Y5VZSDDP.cjs.map} +1 -1
- package/dist/Controller.cjs +47 -15
- package/dist/Controller.cjs.map +1 -1
- package/dist/Controller.d.cts +5 -2
- package/dist/Controller.d.ts +5 -2
- package/dist/Controller.js +47 -15
- package/dist/Controller.js.map +1 -1
- package/dist/{Movie-DRZtIGU7.d.ts → Movie-CADDBfpl.d.cts} +58 -2
- package/dist/{Movie-CghFXprP.d.cts → Movie-Ch2SUI1h.d.ts} +58 -2
- package/dist/{chunk-QGBOC5YL.cjs → chunk-4AGJF5RK.cjs} +33 -21
- package/dist/chunk-4AGJF5RK.cjs.map +1 -0
- package/dist/{chunk-ZQX7WIYL.js → chunk-7IJHJXDI.js} +380 -64
- package/dist/chunk-7IJHJXDI.js.map +1 -0
- package/dist/{chunk-A574IA4F.js → chunk-B2FTJLNB.js} +18 -6
- package/dist/chunk-B2FTJLNB.js.map +1 -0
- package/dist/chunk-DKGG7WD4.js +61 -0
- package/dist/chunk-DKGG7WD4.js.map +1 -0
- package/dist/{chunk-6BQ6IEW2.cjs → chunk-KQM2ZZMK.cjs} +380 -63
- package/dist/chunk-KQM2ZZMK.cjs.map +1 -0
- package/dist/chunk-UQBGW5DO.cjs +64 -0
- package/dist/chunk-UQBGW5DO.cjs.map +1 -0
- package/dist/index.cjs +333 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -5
- package/dist/index.d.ts +54 -5
- package/dist/index.js +317 -75
- package/dist/index.js.map +1 -1
- package/dist/sfx-MZJZXYY3.cjs +446 -0
- package/dist/sfx-MZJZXYY3.cjs.map +1 -0
- package/dist/sfx-S37DDC66.js +434 -0
- package/dist/sfx-S37DDC66.js.map +1 -0
- package/dist/three.cjs +5 -5
- package/dist/three.d.cts +2 -2
- package/dist/three.d.ts +2 -2
- package/dist/three.js +1 -1
- package/dist/{types-CRE9WKd4.d.ts → types-DrW9QeKU.d.cts} +118 -12
- package/dist/{types-CRE9WKd4.d.cts → types-DrW9QeKU.d.ts} +118 -12
- package/docs/api.md +37 -0
- package/docs/dsl.md +70 -10
- package/llms-full.txt +300 -30
- package/llms.txt +1 -1
- package/package.json +2 -1
- package/dist/Composition-D7RCAGEA.cjs +0 -13
- package/dist/Composition-RGTRZ2JX.js +0 -4
- package/dist/chunk-6BQ6IEW2.cjs.map +0 -1
- package/dist/chunk-A574IA4F.js.map +0 -1
- package/dist/chunk-QGBOC5YL.cjs.map +0 -1
- 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,
|
|
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,
|
|
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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-
|
|
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-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-Y5VZSDDP.cjs"}
|
package/dist/Controller.cjs
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
610
|
+
const finish = (clientX, pointerId) => {
|
|
599
611
|
if (!this.isScrubbing) return;
|
|
600
|
-
this.seekFromPointer(
|
|
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(
|
|
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;
|