pixi-effects 0.38.1 → 0.39.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 (44) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +5 -5
  3. package/ai/chat-template.html +3 -3
  4. package/ai/tools/check.mjs +102 -8
  5. package/ai/tools/media.mjs +121 -9
  6. package/ai/tools/render.mjs +34 -8
  7. package/dist/Composition-3MNLNT2E.cjs +16 -0
  8. package/dist/{Composition-23KGIJX4.cjs.map → Composition-3MNLNT2E.cjs.map} +1 -1
  9. package/dist/Composition-P37AD4GN.js +7 -0
  10. package/dist/{Composition-5SQXSZNA.js.map → Composition-P37AD4GN.js.map} +1 -1
  11. package/dist/{Spans-WJFPHMSQ.cjs → Spans-F6FBBCD3.cjs} +9 -9
  12. package/dist/{Spans-WJFPHMSQ.cjs.map → Spans-F6FBBCD3.cjs.map} +1 -1
  13. package/dist/{Spans-4IBQASGK.js → Spans-PLBQOSMV.js} +4 -4
  14. package/dist/{Spans-4IBQASGK.js.map → Spans-PLBQOSMV.js.map} +1 -1
  15. package/dist/{chunk-XOCRS5MW.js → chunk-F2ZHDUPL.js} +7 -6
  16. package/dist/chunk-F2ZHDUPL.js.map +1 -0
  17. package/dist/{chunk-KNMDDUA3.cjs → chunk-FV25Y5OG.cjs} +42 -41
  18. package/dist/{chunk-XOCRS5MW.js.map → chunk-FV25Y5OG.cjs.map} +1 -1
  19. package/dist/{chunk-7NXKBRT7.cjs → chunk-HX73S7FD.cjs} +27 -7
  20. package/dist/{chunk-7NXKBRT7.cjs.map → chunk-HX73S7FD.cjs.map} +1 -1
  21. package/dist/{chunk-UDTLYYGY.js → chunk-JCPZJJNW.js} +27 -7
  22. package/dist/{chunk-UDTLYYGY.js.map → chunk-JCPZJJNW.js.map} +1 -1
  23. package/dist/{chunk-FE42PKHP.cjs → chunk-SJFDSHBP.cjs} +62 -62
  24. package/dist/{chunk-FE42PKHP.cjs.map → chunk-SJFDSHBP.cjs.map} +1 -1
  25. package/dist/{chunk-4CSL5QSL.js → chunk-UHCVO5TH.js} +3 -3
  26. package/dist/{chunk-4CSL5QSL.js.map → chunk-UHCVO5TH.js.map} +1 -1
  27. package/dist/index.cjs +33 -33
  28. package/dist/index.js +3 -3
  29. package/dist/loader.css +1 -1
  30. package/dist/present.cjs +3 -3
  31. package/dist/present.js +3 -3
  32. package/dist/three.cjs +5 -5
  33. package/dist/three.js +1 -1
  34. package/docs/api.md +1 -1
  35. package/docs/dsl.md +1 -1
  36. package/docs/media.md +17 -2
  37. package/llms-full.txt +24 -6
  38. package/package.json +1 -1
  39. package/skills/pixi-effects/SKILL.md +4 -2
  40. package/skills/pixi-effects/reference/pitfalls.md +1 -0
  41. package/skills/pixi-effects/template.html +6 -6
  42. package/dist/Composition-23KGIJX4.cjs +0 -16
  43. package/dist/Composition-5SQXSZNA.js +0 -7
  44. package/dist/chunk-KNMDDUA3.cjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.39.0
4
+
5
+ **The tools tell the truth, and a file can be checked against what it must be**
6
+
7
+ - **`probe` names the colour tags, and `--expect` can ask for them (`matrix=bt709`, `transfer=…`).** The plain report says `colour bt709 (transfer iec61966-2-1, limited range)` and `--json` has `video[].color`. The files `render()` writes carry the Rec.709 primaries and matrix, limited range and the **sRGB transfer** (`iec61966-2-1`); it was listed as a defect to change to `bt709`, and it is left: the pixels are drawn as sRGB, so that is the true tag (rewriting it would mean patching the container and the H.264 bitstream to say something less exact; editors and players read it as Rec.709 in practice). A platform that asks for Rec.709 is answered by `probe file --expect matrix=bt709`.
8
+ - **New: `grain` has `loop: seconds`, so a clip made to loop has the same grain at its seam.** `{ type: 'grain', loop: 10 }` repeats the pattern every 10 s (the grain frame is taken modulo `loop × fps`): the frame after the last is the first again. `loop × fps` must be a whole number of grain frames (10 s at 24 fps is 240); anything else, and a `loop` that is not above 0, warns and says what to change. The library's grain had no period, so two stock-clip agents wrote their own grain inside a shader to close a loop. Tests: the grain frame at the seam, the warnings, and a living filter's `loop`.
9
+ - **New: `check --seam` tells whether a movie made to loop closes without a jump.** In the page it takes the last frame against the first (`seam`) and nine ordinary neighbouring pairs (`steps`, the two next to the seam among them) on pictures a quarter of the size, as the mean absolute difference of the colour channels. A seam up to 1.5 times the largest ordinary step (or under 0.001) prints `seam the loop closes: …`; more prints `seam JUMP: … 8.1× the largest ordinary step …`, is a problem and exits 1. `report.json` has `seam: { seam, median, max, ratio, ok, frames }`. The movie is its own measure (no stored picture). Found by stock-clip agents, who measured the seam by hand with ImageMagick (`compare -metric MAE`, the last frame against the first). New export: `seamVerdict` (check.mjs); test pages `examples/_checks/seam-loop.html` and `seam-jump.html`.
10
+ - **New: `probe file --expect "size=3840x2160 fps=30 frames=300 bitrate>=35M audio=none"` checks a file against what it must be.** One line each (`ok` / `FAIL`, the number the file has and the one wanted), `RESULT: …`, exit 1 when one fails, `--json` adds an `expect` list. The numbers are measured over every packet of the file (the new `frames`, and the bitrate of the whole file), not over the first 300 like the plain report. Words: `size width height fps frames duration bitrate video audio container`; operators `= != >= <= > <`; a misspelt word gets `did you mean`; a bad expectation is a usage error before the file is read. `docs/media.md`, the skill and `help media` carry it; the docs test now runs example commands with quoted arguments.
11
+ - **Docs: `bitrate` is a ceiling, not a target (pitfall 119), measured.** A constant bitrate mode (`bitrateMode: 'constant'`) reaches the encoder and still writes 0.1 Mbps for a black frame with a circle (H.264, HEVC and VP9: no encoder pads), so there is no option to add: read the real rate (`render` prints it, `probe --expect` checks it) and, when a minimum is needed, raise it with what the picture really has (seeded film grain).
12
+ - **Changed (breaking, small): an export is exactly `duration × frameRate` frames, not one more.** `render()` wrote the frames 0 … `totalFrames` inclusive, so a 10 s movie at 30 fps was 301 frames and 10.033 s while `check` said 300; the moment `duration` is now not a frame (it belongs to the next one), so the file is 300 frames and 10.000 s, a clip made to loop has no repeated frame at its seam, and the picture and the sound have the same length. A `range` is `[from, to)`: `1:2` is 30 frames at 30 fps (it was 31). The progress events count the frames, then one `100` when the file is finished; the stage is left on the last exported frame. What you may have done to get 300 frames (`duration: 10 - 1/30`) now gives 299: write the plain `duration: 10`. Seeking to the end moment (`gotoFrame(totalFrames)`) and the last-frame check are unchanged. Found by agents making stock clips, which all had to know the trick. Tests: `renderRange.test.ts` counts the packets of the real file; the Renderer unit tests and two browser tests that assumed the extra frame were rewritten (the stage's last frame, the progress events, a reversed loop's first instant).
13
+ - **Fixed: the guide's demo pages scrolled a little inside their frame.** Every generated demo (`examples/_guide/*.html`) had `body { padding: 16px }` and a picture that sits on a text line, so each was 20 px taller than the 16:9 frame the guide gives it and could be scrolled (measured: 20 px at 880 px and at 360 px wide, in all 17 pages). The pages now fill the frame exactly (no padding, `line-height: 0`, no scrolling), and `first-video.html` (which the reader also copies) has the same CSS. Test: `tests/tools/guideDemoFit.test.ts` loads every demo at both widths and fails if one scrolls or does not fill its frame.
14
+ - **`render` and `check` say more about the file they wrote.** `render --help` now lists every option (`--video-bitrate` and `--video-codec` were missing, so an agent could not find them). A command whose stderr is not a terminal (an agent, a script, CI) used to print no progress at all, so a render of many minutes looked hung: it now prints a line at once and one every 15 s (`rendering 40% (47 s)`; `--quiet` still prints none). The result line gives the real bitrate (`· 17.8 Mbps ·`), and when `--video-bitrate` asked for more than twice what the encoder spent (55M gave 17.8 Mbps on a mostly black picture) a note says so and to read the real rate with `probe`. `check`'s own export line gives its bitrate and says it is for looking at, not the file to deliver (`render` is). New exports: `makeProgress`, `bitrateLine` (render.mjs), `exportLine` (check.mjs).
15
+ - **Fixed: `frames --at f1` gave frame 0 (and `f4` gave 3, `f7` gave 6 at 30 fps).** A frame number was turned into seconds and rounded DOWN to four decimals (1/30 s became 0.0333, which is before frame 1), so a third of the frames of a 30 fps file came back as the one before. A time asked for a frame (`fN`, `start`, `end`) now rounds up, so it lands on that frame (`end` was also a frame early for some lengths, such as 4.2 s at 29.97 fps); a time asked in seconds is still the instant asked for. Found by an agent measuring a loop's seam with `f0` and `f1`, which were the same picture. Tests: every frame number of 8 frame rates, and the real command on `colors.mp4`.
16
+ - **A start of `check`, `render` and the media commands sweeps the headless Chromes that an earlier run left behind.** On 2026-10-14 264 of them (5.7 GB, up to 7 hours old) piled up in one working day from runs that were cut off (a timeout, a harness that ended) whose watchman never got to stop them; the cause was not proven, so the fix does not depend on it. Every private profile folder (`pixi-effects-*` in the temp folder) now holds `pixi-owner.pid`, the process that started the browser; the next `launchChrome` (new export `reapStaleChrome(tmp?)`) stops, by process id and without `pkill`, everything started with a profile whose owner is gone and removes the folder. A profile whose owner is alive (another tool running now), one with no owner file and any other folder are left alone; it never throws. Tests: `tests/tools/chromeReap.test.ts` (stand-in processes, mutation-checked, and a real-Chrome case).
17
+ - **The release gate is shorter when only documents changed.** `npm run release:check` compares the tree with the last `v*` tag (`scripts/release-scope.mjs`): when every changed file is a document (the changelog, README, AGENTS, SKILL.md, `docs/*.md`, the generated llms files) or a bump of this package's own version (the old number turned into the new one, and in the manifests only the `"version"` key), it runs the unit and document tests and leaves out the real-browser ones (about 4 of the 5 minutes) and says so; anything else, a file added or deleted outside the documents, no tag, or any doubt is a full run. `--scope` says the decision and why without running anything, `--full` forces everything; the stamp that `npm publish` checks records the mode. Tested on real git repositories and with the mutations that must fail (a site file counted as a document, a dependency bump taken for a version bump, a pure reordering taken for a bump, an empty change taken for docs-only).
18
+ - **CI had been red since 0.38.0, and it is fixed (tests only, nothing in the package).** A test of the media commands ran `frames` with a `PATH` of only node's folder: on Linux `google-chrome` is a shell script that needs `dirname`, so Chrome exited with code 127, and the test also ignored `SKIP_BROWSER_TESTS`, which CI sets. The browser parts of that test, and of the docs example runner, now run only where browser tests run, and the PATH keeps the basic system folders (the test first proves ffmpeg is not in them). The two pushes (0.38.0, 0.38.1) were made without looking at CI: the red mail came from the maintainer.
19
+
3
20
  ## 0.38.1
4
21
 
5
22
  **Docs, and 21 pieces leave the gallery (64 → 43)**
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.38.1`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
3
+ > **Status**: experimental — current release `0.39.0`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
4
4
 
5
5
  **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 52 portfolio pieces written as plain data by AI models · **[Examples →](https://yjmtmtk.github.io/pixi-effects/examples/)** · 15 numbered examples, a music lab + an in-browser playground.
6
6
 
@@ -49,8 +49,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
49
49
  "imports": {
50
50
  "pixi.js": "https://esm.sh/pixi.js@8.22.0?bundle-deps",
51
51
  "mediabunny": "https://esm.sh/mediabunny",
52
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.38.1/dist/index.js",
53
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.38.1/dist/Controller.js"
52
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.39.0/dist/index.js",
53
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.39.0/dist/Controller.js"
54
54
  }
55
55
  }
56
56
  </script>
@@ -61,7 +61,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
61
61
  </script>
62
62
  ```
63
63
 
64
- Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.38.1/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
64
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.39.0/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
65
65
 
66
66
  > **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
67
67
  >
@@ -77,7 +77,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
77
77
  "imports": {
78
78
  "...": "(everything from the importmap above)",
79
79
  "three": "https://esm.sh/three@0.178.0",
80
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.38.1/dist/three.js"
80
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.39.0/dist/three.js"
81
81
  }
82
82
  }
83
83
  </script>
@@ -59,9 +59,9 @@
59
59
  "imports": {
60
60
  "pixi.js": "https://cdn.jsdelivr.net/npm/pixi.js@8.22.0/dist/pixi.min.mjs",
61
61
  "mediabunny": "https://cdn.jsdelivr.net/npm/mediabunny@1.61.3/+esm",
62
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.38.1/dist/index.js",
63
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.38.1/dist/Controller.js",
64
- "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.38.1/dist/present.js"
62
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.39.0/dist/index.js",
63
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.39.0/dist/Controller.js",
64
+ "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.39.0/dist/present.js"
65
65
  }
66
66
  }
67
67
  </script>
@@ -17,6 +17,7 @@
17
17
  * --query "a=1&b=2" (added to the page address: for a page that reads it, such as one video of a batch) ·
18
18
  * --crop X,Y,W,H (with --at: each picture is that part of the canvas, in canvas pixels, enlarged up to 4x so a small figure can be judged) ·
19
19
  * --onion A:B (one picture of the movement between A and B seconds, frames overlaid: onion.png) ·
20
+ * --seam (a movie made to loop: the last frame to the first is compared with the steps between ordinary neighbouring frames; a jump is a problem) ·
20
21
  * --strict (text overlaps and stops where the picture is still changing 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 ·
21
22
  * --timeout SECONDS (default 240) · --root DIR (static server root; default: the nearest folder above the page with dist/) · --chrome PATH
22
23
  */
@@ -80,10 +81,10 @@ export function findChrome(env = process.env, exists = fs.existsSync, platform =
80
81
  return candidates.find(c => c && exists(c)) ?? null;
81
82
  }
82
83
 
83
- export const USAGE = 'usage: node ai/tools/check.mjs <page.html> [--out DIR] [--frames N] [--formats mp4,webm] [--no-export] [--draft] [--at 3.5,title@end] [--crop X,Y,W,H] [--onion 1:3] [--query a=1&b=2] [--var name=value] [--size 1920x1080,1080x1920] [--timeout S] [--root DIR] [--chrome PATH] [--strict] [--help]\n(the header of ai/tools/check.mjs explains each option)';
84
+ export const USAGE = 'usage: node ai/tools/check.mjs <page.html> [--out DIR] [--frames N] [--formats mp4,webm] [--no-export] [--draft] [--at 3.5,title@end] [--crop X,Y,W,H] [--onion 1:3] [--seam] [--query a=1&b=2] [--var name=value] [--size 1920x1080,1080x1920] [--timeout S] [--root DIR] [--chrome PATH] [--strict] [--help]\n(the header of ai/tools/check.mjs explains each option)';
84
85
 
85
86
  export function parseArgs(argv) {
86
- const o = { page: null, out: null, frames: 12, formats: ['mp4'], export: true, timeout: 240, root: null, chrome: null, strict: false, at: null, crop: null, draft: false, onion: null, query: null, vars: [], sizes: [], help: false };
87
+ const o = { page: null, out: null, frames: 12, formats: ['mp4'], export: true, timeout: 240, root: null, chrome: null, strict: false, at: null, crop: null, draft: false, onion: null, seam: false, query: null, vars: [], sizes: [], help: false };
87
88
  const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
88
89
  for (let i = 0; i < argv.length; i++) {
89
90
  const a = argv[i];
@@ -103,6 +104,7 @@ export function parseArgs(argv) {
103
104
  }
104
105
  else if (a === '--var') o.vars.push(parseVar(need(i++, a)));
105
106
  else if (a === '--size') o.sizes.push(...parseSizes(need(i++, a)));
107
+ else if (a === '--seam') o.seam = true;
106
108
  else if (a === '--onion') {
107
109
  const m = need(i++, a).match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
108
110
  if (!m || !(Number(m[1]) < Number(m[2]))) throw new Error(`--onion must look like 1:3 (seconds, from before to), got "${argv[i]}"`);
@@ -186,8 +188,50 @@ export class Cdp {
186
188
  }
187
189
  }
188
190
 
191
+ /** The file in a private profile folder that names the process that started the browser: the next tool start can tell a profile that is still in use from one whose owner is gone. */
192
+ export const OWNER_FILE = 'pixi-owner.pid';
193
+
194
+ /**
195
+ * Sweep what a dead owner left: every `pixi-effects-*` folder in `tmp` whose owner file names a process that no longer exists has everything that was
196
+ * started with it stopped (found with `ps`, stopped by process id: no pkill, which a sandbox may refuse) and is removed. A profile whose owner is alive
197
+ * (another tool running right now), one with no owner file (a tool that is just starting) and any folder that is not ours are left alone.
198
+ * Never throws: a launch must not fail because of a cleanup. Returns `{ removed: [folders], stopped: number }`. (POSIX only; nothing to do elsewhere.)
199
+ */
200
+ export function reapStaleChrome(tmp = os.tmpdir()) {
201
+ const result = { removed: [], stopped: 0 };
202
+ if (process.platform === 'win32') return result;
203
+ try {
204
+ const alive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
205
+ let stale = [];
206
+ for (const name of fs.readdirSync(tmp)) {
207
+ if (!name.startsWith('pixi-effects-')) continue;
208
+ const dir = path.join(tmp, name);
209
+ let owner;
210
+ try { owner = Number(fs.readFileSync(path.join(dir, OWNER_FILE), 'utf8').trim()); } catch { continue; } // no owner file: not ours to take yet
211
+ if (Number.isInteger(owner) && owner > 0 && !alive(owner)) stale.push(dir);
212
+ }
213
+ if (!stale.length) return result;
214
+ const ps = spawnSync('ps', ['-axo', 'pid=,command='], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
215
+ const lines = ps.status === 0 ? ps.stdout.split('\n') : [];
216
+ for (const dir of stale) {
217
+ const flag = `--user-data-dir=${dir}`;
218
+ for (const line of lines) {
219
+ const at = line.indexOf(flag);
220
+ if (at < 0 || (line[at + flag.length] !== undefined && line[at + flag.length] !== ' ')) continue; // the whole folder name, not one that only starts like it
221
+ const pid = Number(line.trim().split(/\s+/, 1)[0]);
222
+ if (!Number.isInteger(pid) || pid === process.pid) continue;
223
+ try { process.kill(pid, 'SIGKILL'); result.stopped++; } catch { /* already gone */ }
224
+ }
225
+ try { fs.rmSync(dir, { recursive: true, force: true }); result.removed.push(dir); } catch { /* held a moment longer: the next start takes it */ }
226
+ }
227
+ } catch { /* a cleanup never stops a launch */ }
228
+ return result;
229
+ }
230
+
189
231
  /** Start a private headless Chrome. `extraArgs`: more command-line flags (the tests use it for `--enable-features=WebMCP`). */
190
232
  export async function launchChrome(chrome, userDataDir, extraArgs = []) {
233
+ reapStaleChrome(); // what an earlier run left behind (its owner is gone) goes first
234
+ try { fs.writeFileSync(path.join(userDataDir, OWNER_FILE), String(process.pid)); } catch { /* a profile that cannot be marked is simply never swept */ }
191
235
  const args = [
192
236
  '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check',
193
237
  '--autoplay-policy=no-user-gesture-required', '--mute-audio', '--disable-audio-output', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader', '--window-size=1400,900', ...extraArgs, 'about:blank',
@@ -446,6 +490,16 @@ export async function runCheck(opts, log = console.log) {
446
490
  report.files.onion = shown(onionFile);
447
491
  }
448
492
 
493
+ // --seam: a movie made to loop must not jump where it starts again
494
+ if (opts.seam) {
495
+ const m = await cdp.eval(SEAM_SCRIPT);
496
+ if (m.error) report.problems.push(`--seam: ${m.error}`);
497
+ else {
498
+ report.seam = { ...seamVerdict(m), frames: m.frames };
499
+ if (!report.seam.ok) report.problems.push(`the loop jumps at its seam: the last frame to the first differs by ${report.seam.seam}, ${report.seam.ratio}× the largest ordinary step (${report.seam.max})`);
500
+ }
501
+ }
502
+
449
503
  // a presentation (composition.stops): one picture of every stop, in order, so the pages and steps can be read at a glance
450
504
  const stops = await cdp.eval(`(() => { const s = movie.stops || []; return s.length ? { count: s.length, pages: new Set(s.map(x => x.pageIndex)).size, items: s.map(x => ({ at: Math.round(x.at * 100) / 100, frame: x.frame, page: x.page, pageStart: x.pageStart, notes: !!x.notes, pdf: x.pdf })) } : null; })()`).catch(() => null);
451
505
  if (stops) {
@@ -564,6 +618,50 @@ export async function runCheck(opts, log = console.log) {
564
618
  }
565
619
  }
566
620
 
621
+ /**
622
+ * `check --seam`: does a movie made to loop close without a jump? `seam` is how much the last frame differs from the first (mean absolute difference of the colour
623
+ * channels, 0 to 1), `steps` are the same number for ordinary neighbouring frames of the movie, so the movie is its own measure. A seam up to 1.5 times the largest
624
+ * ordinary step (or under the noise floor of 0.001) closes; more is a jump.
625
+ */
626
+ export function seamVerdict({ seam, steps }) {
627
+ const sorted = [...steps].sort((a, b) => a - b);
628
+ const median = sorted.length ? sorted[sorted.length >> 1] : 0, max = sorted.length ? sorted[sorted.length - 1] : 0;
629
+ const ok = seam <= Math.max(max * 1.5, 0.001);
630
+ const ratio = max > 0 ? seam / max : (seam > 0 ? Infinity : 0);
631
+ const n = x => (x < 0.1 ? x.toFixed(4) : x.toFixed(3));
632
+ const line = ok
633
+ ? ` seam the loop closes: the last frame to the first differs by ${n(seam)}, an ordinary step by ${n(median)} (largest ${n(max)})`
634
+ : ` seam JUMP: the last frame to the first differs by ${n(seam)}, ${Number.isFinite(ratio) ? `${(Math.round(ratio * 10) / 10).toFixed(1)}×` : 'against nothing'} the largest ordinary step (${n(max)}): the picture jumps when the movie loops (make every motion periodic over the duration)`;
635
+ return { seam: Math.round(seam * 1e6) / 1e6, median: Math.round(median * 1e6) / 1e6, max: Math.round(max * 1e6) / 1e6, ratio: Number.isFinite(ratio) ? Math.round(ratio * 100) / 100 : null, ok, line };
636
+ }
637
+
638
+ /** In the page: the seam (last frame against the first) and nine ordinary steps (the first and the last of them next to the seam), on pictures a quarter of the size. */
639
+ const SEAM_SCRIPT = `(async () => {
640
+ const total = movie.totalFrames;
641
+ if (total < 4) return { error: 'a movie of fewer than 4 frames has no loop to measure' };
642
+ const cache = new Map();
643
+ const grab = async f => { if (!cache.has(f)) cache.set(f, (await movie.snapshot(f, { as: 'imageData', scale: 0.25, motionBlur: false })).data); return cache.get(f); };
644
+ const mae = (a, b) => { let s = 0; for (let i = 0; i < a.length; i += 4) s += Math.abs(a[i] - b[i]) + Math.abs(a[i + 1] - b[i + 1]) + Math.abs(a[i + 2] - b[i + 2]); return s / (a.length / 4 * 3) / 255; };
645
+ const picks = new Set([0, total - 2]);
646
+ for (let k = 1; k <= 7; k++) picks.add(Math.round((total - 2) * k / 8));
647
+ const steps = [];
648
+ for (const k of [...picks].sort((a, b) => a - b)) steps.push(mae(await grab(k), await grab(k + 1)));
649
+ return { seam: mae(await grab(total - 1), await grab(0)), steps, frames: total };
650
+ })()`;
651
+
652
+ /**
653
+ * The line `check` prints for its own export. That export is made to see that the movie can be written and read back, at a low bitrate and with no way to
654
+ * ask for another: it is not the file to deliver (`render` is: `--video-bitrate`), and the line says so, with the real bitrate so nobody mistakes it for one.
655
+ */
656
+ export function exportLine(ex) {
657
+ if (ex.error) return ` export ${ex.format}: FAILED — ${ex.error}`;
658
+ const v = ex.video?.error ? `video: ${ex.video.error}` : `video ${ex.video.duration.toFixed(2)} s ${ex.video.width}×${ex.video.height}`;
659
+ 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';
660
+ const seconds = ex.video && !ex.video.error ? ex.video.duration : 0;
661
+ const rate = seconds > 0 ? ` · ${Math.round(ex.bytes * 8 / seconds / 1e5) / 10} Mbps (for looking at; \`render\` writes the file to deliver)` : '';
662
+ return ` export ${ex.format}${ex.draft ? ' (draft)' : ''} ok · ${kb(ex.bytes)}${rate} · ${v} · ${au}`;
663
+ }
664
+
567
665
  function finish(report, outDir, log) {
568
666
  const reportFile = path.join(outDir, 'report.json');
569
667
  report.files.report = shown(reportFile);
@@ -626,12 +724,8 @@ function finish(report, outDir, log) {
626
724
  for (const l of formatMusic(a?.sources)) L.push(l);
627
725
  for (const n of a?.notes ?? []) L.push(` note ${n}`);
628
726
  }
629
- for (const ex of report.exports ?? []) {
630
- if (ex.error) { L.push(` export ${ex.format}: FAILED — ${ex.error}`); continue; }
631
- const v = ex.video?.error ? `video: ${ex.video.error}` : `video ${ex.video.duration.toFixed(2)} s ${ex.video.width}×${ex.video.height}`;
632
- 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';
633
- L.push(` export ${ex.format}${ex.draft ? ' (draft)' : ''} ok · ${kb(ex.bytes)} · ${v} · ${au}`);
634
- }
727
+ if (report.seam) L.push(report.seam.line);
728
+ for (const ex of report.exports ?? []) L.push(exportLine(ex));
635
729
  L.push(` files ${Object.values(report.files).join(' ')}`);
636
730
  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.');
637
731
  log(L.join('\n'));
@@ -38,7 +38,7 @@ export const USAGE = `usage:
38
38
 
39
39
  /** How each command reads its command line: the positional words, and each option with the kind of value it takes. */
40
40
  const COMMANDS = {
41
- probe: { positional: ['file'], flags: { '--keyframes': 'bool', '--json': 'bool' } },
41
+ probe: { positional: ['file'], flags: { '--keyframes': 'bool', '--expect': 'str', '--json': 'bool' } },
42
42
  trim: { positional: ['file', 'start', 'end'], flags: { '-o': 'str', '--out': 'str', '--exact': 'bool', '--force': 'bool', '--json': 'bool', '--chrome': 'str' } },
43
43
  audio: { positional: ['file'], flags: { '-o': 'str', '--out': 'str', '--force': 'bool', '--json': 'bool' } },
44
44
  concat: { positional: ['file', 'file'], variadic: true, flags: { '-o': 'str', '--out': 'str', '--force': 'bool', '--json': 'bool' } },
@@ -157,7 +157,7 @@ async function openInput(file) {
157
157
  }
158
158
 
159
159
  /** What is in a file: container, duration, size, one row per track, key packet times with `keyframes`, and notes about what an author would trip on. */
160
- export async function probeFile(file, { keyframes = false } = {}) {
160
+ export async function probeFile(file, { keyframes = false, exact = false } = {}) {
161
161
  const { mb, input, format } = await openInput(file);
162
162
  const notes = [];
163
163
  let duration;
@@ -184,6 +184,13 @@ export async function probeFile(file, { keyframes = false } = {}) {
184
184
  first: round(first, 4), end: round(end, 6), frameGap: round(median, 6),
185
185
  stored: { width: await track.getCodedWidth(), height: await track.getCodedHeight() },
186
186
  });
187
+ const cs = await track.getColorSpace().catch(() => null); // what the file says about its colours (a decoder reads this, not the encoder's intent)
188
+ if (cs && (cs.primaries || cs.transfer || cs.matrix)) video[video.length - 1].color = { primaries: cs.primaries ?? null, transfer: cs.transfer ?? null, matrix: cs.matrix ?? null, fullRange: !!cs.fullRange };
189
+ if (exact) { // every packet, not the first 300: the real frame count and the bitrate of the whole file
190
+ const all = await track.computePacketStats(Infinity);
191
+ video[video.length - 1].frames = all.packetCount;
192
+ video[video.length - 1].bitrateAll = Math.round(all.averageBitrate);
193
+ }
187
194
  if (first > 0.05) notes.push(`the video starts at ${round(first, 3)} s (its first frame is not at 0, so the sound can start before the picture; frames before ${round(first, 3)} s show the first frame)`);
188
195
  if (rotation) notes.push(`rotated ${rotation}° clockwise: stored ${await track.getCodedWidth()}×${await track.getCodedHeight()}, shown ${await track.getDisplayWidth()}×${await track.getDisplayHeight()} (frames and anything that places the picture use the shown size; whether the picture then reads upright depends on how the file was made, so look at a frame)`);
189
196
  if (fpsVaries) notes.push(`variable frame rate (the gaps between frames range from ${round(gaps[0] * 1000, 1)} to ${round(gaps[gaps.length - 1] * 1000, 1)} ms): a clip from a phone or a screen recording; cut and sample by time, not by frame number`);
@@ -209,17 +216,119 @@ const kb = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(2)} MB` : `${Math.max
209
216
 
210
217
  function probeText(r, keyframes) {
211
218
  const L = [`${r.file}`, ` ${r.container} · ${r.duration} s${r.bytes != null ? ` · ${kb(r.bytes)}` : ''}`];
212
- for (const v of r.video) L.push(` video ${v.codec} ${v.width}×${v.height}${v.rotation ? ` shown (stored ${v.stored.width}×${v.stored.height}, turned ${v.rotation}° clockwise)` : ''} · ${v.fps}${v.fpsVaries ? ' (varies)' : ''} fps${v.bitrate ? ` · ${Math.round(v.bitrate / 1000)} kb/s` : ''}`);
219
+ for (const v of r.video) L.push(` video ${v.codec} ${v.width}×${v.height}${v.rotation ? ` shown (stored ${v.stored.width}×${v.stored.height}, turned ${v.rotation}° clockwise)` : ''} · ${v.fps}${v.fpsVaries ? ' (varies)' : ''} fps${v.bitrate ? ` · ${Math.round(v.bitrate / 1000)} kb/s` : ''}${v.color ? ` · colour ${v.color.matrix ?? '?'} (transfer ${v.color.transfer ?? '?'}, ${v.color.fullRange ? 'full' : 'limited'} range)` : ''}`);
213
220
  for (const a of r.audio) L.push(` audio ${a.codec} ${a.sampleRate} Hz · ${a.channels} ch${a.bitrate ? ` · ${Math.round(a.bitrate / 1000)} kb/s` : ''}`);
214
221
  if (keyframes && r.video.length) L.push(` key packets at: ${r.keyframes.slice(0, 12).join(', ')}${r.keyframes.length > 12 ? `, … (${r.keyframes.length})` : ''} s`);
215
222
  for (const n of r.notes) L.push(` note ${n}`);
216
223
  return L.join('\n');
217
224
  }
218
225
 
226
+ // ───────────────────────────── probe --expect ─────────────────────────────
227
+
228
+ const EXPECT_KEYS = ['size', 'width', 'height', 'fps', 'frames', 'duration', 'bitrate', 'video', 'audio', 'container', 'matrix', 'transfer'];
229
+ const EXPECT_NUMERIC = ['width', 'height', 'fps', 'frames', 'duration', 'bitrate'];
230
+
231
+ /**
232
+ * `size=3840x2160 fps=30 frames=300 bitrate>=35M duration<=60s audio=none video=avc container=mp4` → [{ key, op, raw }].
233
+ * Words are separated by spaces or commas; the operators are = != >= <= > <; a bitrate may end in k or M, a duration in s.
234
+ */
235
+ export function parseExpect(text) {
236
+ const items = [];
237
+ for (const word of String(text ?? '').split(/[\s,]+/).filter(Boolean)) {
238
+ const m = word.match(/^([A-Za-z]+)(>=|<=|!=|=|>|<)(.*)$/);
239
+ if (!m) {
240
+ const name = word.match(/^[A-Za-z]+/)?.[0];
241
+ if (name && EXPECT_KEYS.includes(name.toLowerCase())) throw new UsageError(`probe --expect: ${name}: write a comparison such as ${name}=30 or ${name}>=30 (words: ${EXPECT_KEYS.join(' ')})`);
242
+ throw new UsageError(`probe --expect: cannot read "${word}": write name, operator and value, for example frames=300 or bitrate>=35M (words: ${EXPECT_KEYS.join(' ')})`);
243
+ }
244
+ const key = m[1].toLowerCase(), op = m[2], raw = m[3];
245
+ if (!EXPECT_KEYS.includes(key)) {
246
+ const guess = nearest(key, EXPECT_KEYS);
247
+ throw new UsageError(`probe --expect: unknown "${m[1]}"${guess ? `; did you mean "${guess}"?` : ''} (words: ${EXPECT_KEYS.join(' ')})`);
248
+ }
249
+ if (!raw) throw new UsageError(`probe --expect: ${key}${op} needs a value`);
250
+ if (!EXPECT_NUMERIC.includes(key) && op !== '=' && op !== '!=') throw new UsageError(`probe --expect: ${key} takes only = or != (got ${op})`);
251
+ if (EXPECT_NUMERIC.includes(key) && !Number.isFinite(expectNumber(key, raw))) throw new UsageError(`probe --expect: ${key} wants a number${key === 'bitrate' ? ' (35M, 800k or bits a second)' : key === 'duration' ? ' of seconds (10 or 10s)' : ''}, got "${raw}"`);
252
+ items.push({ key, op, raw });
253
+ }
254
+ if (!items.length) throw new UsageError(`probe --expect: nothing to check: write what the file should be, for example size=3840x2160 fps=30 frames=300 (words: ${EXPECT_KEYS.join(' ')})`);
255
+ return items;
256
+ }
257
+
258
+ /** The number of an expectation's value: bits a second for a bitrate (k, M), seconds for a duration (an s is allowed). */
259
+ function expectNumber(key, raw) {
260
+ const m = String(raw).trim().match(/^(\d+(?:\.\d+)?|\.\d+)\s*([kKmM]?)\s*(?:s)?$/);
261
+ if (!m) return NaN;
262
+ const unit = m[2].toLowerCase();
263
+ if (unit && key !== 'bitrate') return NaN;
264
+ return Number(m[1]) * (unit === 'k' ? 1e3 : unit === 'm' ? 1e6 : 1);
265
+ }
266
+
267
+ const compare = (got, op, want, tol = 0) => {
268
+ switch (op) {
269
+ case '=': return Math.abs(got - want) <= tol;
270
+ case '!=': return Math.abs(got - want) > tol;
271
+ case '>': return got > want + tol;
272
+ case '<': return got < want - tol;
273
+ case '>=': return got >= want - tol;
274
+ case '<=': return got <= want + tol;
275
+ }
276
+ return false;
277
+ };
278
+
279
+ /** Each expectation against the numbers of a `probeFile(file, { exact: true })` report: [{ key, op, want, got, ok, line }]. */
280
+ export function checkExpect(report, items) {
281
+ const v = report.video?.[0], a = report.audio?.[0];
282
+ const mbps = n => `${Math.round(n / 1e5) / 10} Mbps`;
283
+ return items.map(({ key, op, raw }) => {
284
+ let got, ok, shown;
285
+ if (['size', 'width', 'height', 'fps', 'frames', 'duration', 'bitrate', 'video', 'matrix', 'transfer'].includes(key) && !v) {
286
+ got = null; ok = false; shown = 'no video';
287
+ } else if (key === 'size') {
288
+ got = `${v.width}x${v.height}`; ok = (got === raw.toLowerCase().replace('×', 'x')) === (op === '='); shown = got;
289
+ } else if (key === 'width' || key === 'height' || key === 'frames') {
290
+ got = v[key]; if (got === undefined) { ok = false; shown = 'not measured'; } else { ok = compare(got, op, expectNumber(key, raw)); shown = String(got); }
291
+ } else if (key === 'fps') {
292
+ got = v.fps; ok = compare(got, op, expectNumber(key, raw), 0.01); shown = `${got}`;
293
+ } else if (key === 'duration') {
294
+ const frame = v.frameGap || 1 / (v.fps || 30);
295
+ got = v.end; ok = compare(got, op, expectNumber(key, raw), frame / 2); shown = `${got} s`;
296
+ } else if (key === 'bitrate') {
297
+ got = v.bitrateAll ?? v.bitrate; ok = compare(got, op, expectNumber(key, raw)); shown = mbps(got);
298
+ } else if (key === 'video') {
299
+ got = v.codec; ok = (String(got).toLowerCase() === raw.toLowerCase()) === (op === '='); shown = String(got);
300
+ } else if (key === 'matrix' || key === 'transfer') {
301
+ got = v.color?.[key] ?? null; shown = got ?? 'not tagged'; ok = got !== null && ((String(got).toLowerCase() === raw.toLowerCase()) === (op === '='));
302
+ } else if (key === 'audio') {
303
+ const r = raw.toLowerCase();
304
+ got = a ? a.codec : 'none';
305
+ ok = (r === 'none' ? !a : r === 'any' ? !!a : !!a && String(a.codec).toLowerCase() === r) === (op === '=');
306
+ shown = a ? String(a.codec) : 'none';
307
+ } else {
308
+ got = String(report.container ?? '').toLowerCase(); ok = (got === raw.toLowerCase()) === (op === '='); shown = got;
309
+ }
310
+ return { key, op, want: raw, got, ok, line: ` ${ok ? 'ok ' : 'FAIL'} ${key.padEnd(9)} ${shown} (wanted ${key === 'bitrate' ? bitrateWanted(op, raw) : `${op}${raw}`})` };
311
+ });
312
+ }
313
+
314
+ function bitrateWanted(op, raw) { const n = expectNumber('bitrate', raw); return `${op}${Math.round(n / 1e5) / 10} Mbps`; }
315
+
219
316
  commands.probe = async ({ file, opts }, io) => {
220
- const r = await probeFile(file, { keyframes: !!opts.keyframes });
221
- io.log(opts.json ? JSON.stringify(r, null, 2) : probeText(r, !!opts.keyframes));
222
- return 0;
317
+ const items = opts.expect !== undefined ? parseExpect(opts.expect) : null; // a bad expectation is a usage error before any file is read
318
+ const r = await probeFile(file, { keyframes: !!opts.keyframes, exact: !!items });
319
+ const results = items ? checkExpect(r, items) : null;
320
+ if (results) r.expect = results.map(({ line, ...rest }) => rest);
321
+ if (opts.json) io.log(JSON.stringify(r, null, 2));
322
+ else {
323
+ const L = [probeText(r, !!opts.keyframes)];
324
+ if (results) {
325
+ L.push('', ...results.map(x => x.line));
326
+ const bad = results.filter(x => !x.ok).length;
327
+ L.push(bad ? `RESULT: ${bad} of ${results.length} expectations do not hold` : `RESULT: all ${results.length} expectations hold`);
328
+ }
329
+ io.log(L.join('\n'));
330
+ }
331
+ return results && results.some(x => !x.ok) ? 1 : 0;
223
332
  };
224
333
 
225
334
  const OUTPUT_FORMATS = { '.mp4': 'Mp4OutputFormat', '.m4v': 'Mp4OutputFormat', '.m4a': 'Mp4OutputFormat', '.mov': 'MovOutputFormat', '.webm': 'WebMOutputFormat', '.mkv': 'MkvOutputFormat', '.mka': 'MkvOutputFormat' };
@@ -470,6 +579,9 @@ const AT_HELP = 'use seconds (3.5), a percentage (50%), a frame (f120), start, e
470
579
  */
471
580
  export function parseTimes(list, { duration, fps, first = 0, last: lastFrame }) {
472
581
  const out = [], seen = new Set(), last = Math.max(first, lastFrame ?? duration - 1 / fps);
582
+ // A frame is shown from its own time until the next frame's: a time asked for a FRAME (f12, start, end) rounds UP to four decimals, never down (1/30 s as 0.0333 is
583
+ // before frame 1 and gives frame 0). A time asked in seconds is the instant asked for, and keeps the plain rounding.
584
+ const onFrame = t => Math.ceil(t * 1e4 - 1e-7) / 1e4 + 0; // + 0: Math.ceil(-0.00000001) is -0
473
585
  const add = (t, label) => { if (!seen.has(label)) { seen.add(label); out.push({ t, label }); } };
474
586
  const inside = (t, raw) => { if (t > duration + 1e-6) throw new Error(`${raw} s is past the end of the file (${round(duration, 3)} s)`); return Math.min(t, last > t ? t : Math.max(t, 0)); };
475
587
  for (const raw of String(list).split(',').map(x => x.trim()).filter(Boolean)) {
@@ -480,12 +592,12 @@ export function parseTimes(list, { duration, fps, first = 0, last: lastFrame })
480
592
  const n = Math.ceil(duration / step);
481
593
  if (n > MAX_FRAMES) throw new Error(`--at "${raw}": that is ${n} frames of a ${round(duration, 1)} s file, too many (at most ${MAX_FRAMES}): a larger step, or a range of times`);
482
594
  for (let k = 0; k * step < duration - 1e-6; k++) add(round(k * step, 4), `${round(k * step, 2).toFixed(2)}s`);
483
- } else if (/^start$/i.test(raw)) add(round(first, 4), 'start');
484
- else if (/^end$/i.test(raw)) add(round(last, 4), 'end');
595
+ } else if (/^start$/i.test(raw)) add(onFrame(first), 'start');
596
+ else if (/^end$/i.test(raw)) add(onFrame(last), 'end');
485
597
  else if ((m = raw.match(/^(\d+(?:\.\d+)?)%$/))) {
486
598
  if (Number(m[1]) > 100) throw new Error(`--at "${raw}": a percentage is 0–100`);
487
599
  add(round(Math.min(last, duration * Number(m[1]) / 100), 4), `${m[1]}pct`);
488
- } else if ((m = raw.match(/^f(\d+)$/i))) add(round(inside(Number(m[1]) / fps, `frame ${m[1]} is ${round(Number(m[1]) / fps, 3)}`), 4), `f${m[1]}`);
600
+ } else if ((m = raw.match(/^f(\d+)$/i))) add(onFrame(inside(Number(m[1]) / fps, `frame ${m[1]} is ${round(Number(m[1]) / fps, 3)}`)), `f${m[1]}`);
489
601
  else if (/^\d+(\.\d+)?$/.test(raw)) add(round(inside(Number(raw), raw), 4), `${Number(raw).toFixed(2)}s`);
490
602
  else throw new Error(`--at: cannot read "${raw}": ${AT_HELP}`);
491
603
  }
@@ -44,7 +44,7 @@ export function defaultOutput(page, format) {
44
44
  return path.resolve(path.basename(page).replace(/\.[^.]+$/, '') + '.' + format);
45
45
  }
46
46
 
47
- export const USAGE = 'usage: node ai/tools/render.mjs <page.html> [-o out.mp4] [--format mp4|webm|mov|mkv|pdf] [--range A:B | --scene NAME] [--scale S] [--draft] [--quality very-low|low|medium|high|very-high] [--query a=1] [--var name=value] [--size 1080x1920] [--strict] [--quiet] [--timeout S] [--root DIR] [--chrome PATH]';
47
+ export const USAGE = 'usage: node ai/tools/render.mjs <page.html> [-o out.mp4] [--format mp4|webm|mov|mkv|wav|ogg|pdf] [--range A:B | --scene NAME] [--scale S] [--draft] [--quality very-low|low|medium|high|very-high] [--video-codec avc|hevc|vp9|av1|vp8] [--video-bitrate 8M|high] [--audio-codec C] [--motion-blur SAMPLES] [--shutter FRACTION] [--all-stops] [--query a=1] [--var name=value] [--size 1080x1920] [--strict] [--quiet] [--timeout S] [--root DIR] [--chrome PATH] [--help]\n(the header of ai/tools/render.mjs explains each option)';
48
48
 
49
49
  export function parseRenderArgs(argv) {
50
50
  const o = { range: null, scene: null, scale: null, draft: false, qualityGiven: false, page: null, out: null, format: null, quality: 'high', videoCodec: null, videoBitrate: null, audioCodec: null, allStops: false, motionBlur: null, shutter: null, query: null, vars: [], size: null, strict: false, quiet: false, timeout: 900, root: null, chrome: null };
@@ -146,6 +146,34 @@ export function fileSeconds(r, opts) {
146
146
  return r.duration;
147
147
  }
148
148
 
149
+ /**
150
+ * The progress of a render. A terminal gets one line that is rewritten; a command whose stderr is not a terminal (an agent, a script, CI) used to get NOTHING,
151
+ * so a render that took minutes (a heavy shader, a busy GPU) looked hung: it gets a line at once and then one every `every` ms. `--quiet` gets none.
152
+ */
153
+ export function makeProgress({ quiet = false, isTTY = false, write, now = Date.now, every = 15000 }) {
154
+ if (quiet) return () => {};
155
+ let last = -1, start = null, lastAt = 0;
156
+ return p => {
157
+ const pct = Math.min(100, Math.round(p));
158
+ if (isTTY) { if (pct !== last) { last = pct; write(`\rrendering ${pct}%`); } return; }
159
+ const t = now();
160
+ if (start === null) { start = t; lastAt = t; write(`rendering ${pct}%\n`); return; }
161
+ if (t - lastAt >= every) { lastAt = t; write(`rendering ${pct}% (${Math.round((t - start) / 1000)} s)\n`); }
162
+ };
163
+ }
164
+
165
+ /** The real bitrate of a written file, and a sentence when `--video-bitrate` asked for far more than the encoder spent (55M gave 17.8 Mbps on a mostly black picture: it is not a target). */
166
+ export function bitrateLine(bytes, seconds, asked) {
167
+ const mbps = seconds > 0 ? Math.round(bytes * 8 / seconds / 1e5) / 10 : null;
168
+ let note = '';
169
+ const m = typeof asked === 'string' ? asked.trim().match(/^(\d+(?:\.\d+)?)\s*([kKmM]?)$/) : null;
170
+ if (m && mbps !== null) {
171
+ const askedMbps = Number(m[1]) * ({ k: 1e3, m: 1e6 }[m[2].toLowerCase()] ?? 1) / 1e6;
172
+ if (askedMbps > 0 && mbps < askedMbps * 0.5) note = `asked ${asked}, got ${mbps} Mbps: the encoder used fewer bits than asked (a quiet or mostly black picture needs fewer; grain and fine detail need more). Read the real rate with probe, and ask for more if a platform needs it`;
173
+ }
174
+ return { mbps, note };
175
+ }
176
+
149
177
  export async function runRender(opts, log = console.log, progress = () => {}) {
150
178
  const pagePath = path.resolve(opts.page);
151
179
  if (!fs.existsSync(pagePath)) throw new Error(`page not found: ${opts.page}`);
@@ -248,12 +276,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process
248
276
  console.error(USAGE);
249
277
  process.exit(2);
250
278
  }
251
- let last = -1;
252
- const progress = p => {
253
- if (opts.quiet || !process.stderr.isTTY) return;
254
- const pct = Math.min(100, Math.round(p));
255
- if (pct !== last) { last = pct; process.stderr.write(`\rrendering ${pct}%`); }
256
- };
279
+ const progress = makeProgress({ quiet: opts.quiet, isTTY: !!process.stderr.isTTY, write: s => process.stderr.write(s) });
257
280
  const r = await runRender(opts, console.log, progress);
258
281
  if (!opts.quiet && process.stderr.isTTY) process.stderr.write('\r \r');
259
282
  for (const w of r.logs) console.error(`page: ${w.length > 300 ? w.slice(0, 297) + '…' : w}`);
@@ -261,7 +284,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process
261
284
  console.error(`pixi-effects render failed: ${r.problems.join('; ') || 'no file written'}`);
262
285
  process.exit(1);
263
286
  }
264
- console.log(`${shown(r.file)} ${kb(r.bytes)} · ${fileSeconds(r, opts)} s · ${SOUND_ONLY.includes(opts.format) ? 'sound only' : `${r.width}×${r.height} @ ${r.frameRate} fps`} · ${opts.format} · rendered in ${r.seconds} s`);
287
+ const picture = !SOUND_ONLY.includes(opts.format) && opts.format !== 'pdf';
288
+ const rate = picture ? bitrateLine(r.bytes, fileSeconds(r, opts), opts.videoBitrate) : { mbps: null, note: '' };
289
+ console.log(`${shown(r.file)} ${kb(r.bytes)} · ${fileSeconds(r, opts)} s${rate.mbps !== null ? ` · ${rate.mbps} Mbps` : ''} · ${SOUND_ONLY.includes(opts.format) ? 'sound only' : `${r.width}×${r.height} @ ${r.frameRate} fps`} · ${opts.format} · rendered in ${r.seconds} s`);
290
+ if (rate.note) console.error(`note: ${rate.note}`);
265
291
  process.exit(opts.strict && r.logs.length ? 1 : 0);
266
292
  } catch (e) {
267
293
  console.error(`pixi-effects render failed: ${e.message}`);
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ var chunkSJFDSHBP_cjs = require('./chunk-SJFDSHBP.cjs');
4
+ require('./chunk-HX73S7FD.cjs');
5
+ require('./chunk-JN7JHNZR.cjs');
6
+ require('./chunk-3SAS3ZE5.cjs');
7
+ require('./chunk-TWZNOUM7.cjs');
8
+
9
+
10
+
11
+ Object.defineProperty(exports, "CompositionSequence", {
12
+ enumerable: true,
13
+ get: function () { return chunkSJFDSHBP_cjs.CompositionSequence; }
14
+ });
15
+ //# sourceMappingURL=Composition-3MNLNT2E.cjs.map
16
+ //# sourceMappingURL=Composition-3MNLNT2E.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-23KGIJX4.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-3MNLNT2E.cjs"}
@@ -0,0 +1,7 @@
1
+ export { CompositionSequence } from './chunk-UHCVO5TH.js';
2
+ import './chunk-JCPZJJNW.js';
3
+ import './chunk-VNP7QCJE.js';
4
+ import './chunk-32MMNDDN.js';
5
+ import './chunk-BXOCEZ4U.js';
6
+ //# sourceMappingURL=Composition-P37AD4GN.js.map
7
+ //# sourceMappingURL=Composition-P37AD4GN.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-5SQXSZNA.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-P37AD4GN.js"}
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- var chunkFE42PKHP_cjs = require('./chunk-FE42PKHP.cjs');
4
- var chunk7NXKBRT7_cjs = require('./chunk-7NXKBRT7.cjs');
3
+ var chunkSJFDSHBP_cjs = require('./chunk-SJFDSHBP.cjs');
4
+ var chunkHX73S7FD_cjs = require('./chunk-HX73S7FD.cjs');
5
5
  require('./chunk-JN7JHNZR.cjs');
6
6
  require('./chunk-3SAS3ZE5.cjs');
7
7
  require('./chunk-TWZNOUM7.cjs');
@@ -12,12 +12,12 @@ var strip = (props) => {
12
12
  const { visibleChars: _gone, ...rest } = props;
13
13
  return rest;
14
14
  };
15
- var SpansSequence = class extends chunkFE42PKHP_cjs.CompositionSequence {
15
+ var SpansSequence = class extends chunkSJFDSHBP_cjs.CompositionSequence {
16
16
  _meta;
17
17
  _count = { value: 0, visibleChars: 0 };
18
18
  constructor(spec, parent, root) {
19
19
  super(spec, parent, root);
20
- this._meta = chunk7NXKBRT7_cjs.spansMeta.get(spec);
20
+ this._meta = chunkHX73S7FD_cjs.spansMeta.get(spec);
21
21
  }
22
22
  /** `visibleChars` is the group's own counter, not a property to write on the container. */
23
23
  displayProps() {
@@ -33,8 +33,8 @@ var SpansSequence = class extends chunkFE42PKHP_cjs.CompositionSequence {
33
33
  if (!this._meta?.typed) return;
34
34
  const scope = this.scope();
35
35
  const first = this.spec.initial?.visibleChars;
36
- this._count.visibleChars = first === void 0 ? 0 : chunk7NXKBRT7_cjs.resolveNumber(first, scope);
37
- chunk7NXKBRT7_cjs.bindCounterKeyframes(timeline, this._count, "visibleChars", () => this._distribute(), this.spec.keyframes ?? [], this.duration, offset + this.at, scope);
36
+ this._count.visibleChars = first === void 0 ? 0 : chunkHX73S7FD_cjs.resolveNumber(first, scope);
37
+ chunkHX73S7FD_cjs.bindCounterKeyframes(timeline, this._count, "visibleChars", () => this._distribute(), this.spec.keyframes ?? [], this.duration, offset + this.at, scope);
38
38
  this._distribute();
39
39
  }
40
40
  /** Every piece shows its part of the first N characters of the whole text. */
@@ -43,7 +43,7 @@ var SpansSequence = class extends chunkFE42PKHP_cjs.CompositionSequence {
43
43
  const total = Math.max(0, Math.floor(this._count.visibleChars));
44
44
  let i = 0;
45
45
  for (const child of this._children) {
46
- if (!(child instanceof chunk7NXKBRT7_cjs.TextSequence)) continue;
46
+ if (!(child instanceof chunkHX73S7FD_cjs.TextSequence)) continue;
47
47
  const p = pieces[i++];
48
48
  if (p) child.setVisibleChars(Math.min(p.length, Math.max(0, total - p.start)));
49
49
  }
@@ -51,5 +51,5 @@ var SpansSequence = class extends chunkFE42PKHP_cjs.CompositionSequence {
51
51
  };
52
52
 
53
53
  exports.SpansSequence = SpansSequence;
54
- //# sourceMappingURL=Spans-WJFPHMSQ.cjs.map
55
- //# sourceMappingURL=Spans-WJFPHMSQ.cjs.map
54
+ //# sourceMappingURL=Spans-F6FBBCD3.cjs.map
55
+ //# sourceMappingURL=Spans-F6FBBCD3.cjs.map