pixi-effects 0.16.2 → 0.17.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 (52) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +6 -6
  3. package/ai/CHAT.md +4 -0
  4. package/ai/SKILL.md +2 -1
  5. package/ai/chat-template.html +3 -3
  6. package/ai/reference/cheatsheet.md +4 -2
  7. package/ai/reference/pitfalls.md +3 -0
  8. package/ai/reference/recipes.md +67 -0
  9. package/ai/template.html +5 -5
  10. package/ai/tools/check.mjs +162 -18
  11. package/ai/tools/render.mjs +21 -7
  12. package/dist/Composition-HYAOYPAX.js +5 -0
  13. package/dist/{Composition-VJWY2NDL.js.map → Composition-HYAOYPAX.js.map} +1 -1
  14. package/dist/Composition-LMVEASHF.cjs +14 -0
  15. package/dist/{Composition-3WWNVRR5.cjs.map → Composition-LMVEASHF.cjs.map} +1 -1
  16. package/dist/Controller.cjs +9 -3
  17. package/dist/Controller.cjs.map +1 -1
  18. package/dist/Controller.d.cts +1 -1
  19. package/dist/Controller.d.ts +1 -1
  20. package/dist/Controller.js +9 -3
  21. package/dist/Controller.js.map +1 -1
  22. package/dist/{Movie-oA0SUAyb.d.cts → Movie-CoxF2Z7t.d.cts} +82 -2
  23. package/dist/{Movie-DilBC348.d.ts → Movie-DHJRrUfI.d.ts} +82 -2
  24. package/dist/Presenter.d.cts +1 -1
  25. package/dist/Presenter.d.ts +1 -1
  26. package/dist/{chunk-2ROVCPSL.cjs → chunk-ETAIVOHP.cjs} +32 -27
  27. package/dist/chunk-ETAIVOHP.cjs.map +1 -0
  28. package/dist/{chunk-FJA5VUOF.cjs → chunk-LBJRXU7N.cjs} +15 -3
  29. package/dist/chunk-LBJRXU7N.cjs.map +1 -0
  30. package/dist/{chunk-ZNKQH356.js → chunk-ORTYBA42.js} +15 -4
  31. package/dist/chunk-ORTYBA42.js.map +1 -0
  32. package/dist/{chunk-XTIHKODH.js → chunk-Q3IVBYT7.js} +10 -5
  33. package/dist/chunk-Q3IVBYT7.js.map +1 -0
  34. package/dist/index.cjs +351 -41
  35. package/dist/index.cjs.map +1 -1
  36. package/dist/index.d.cts +1 -1
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.js +329 -19
  39. package/dist/index.js.map +1 -1
  40. package/dist/loader.css +1 -1
  41. package/dist/three.cjs +5 -5
  42. package/dist/three.js +1 -1
  43. package/docs/dsl.md +2 -1
  44. package/llms-full.txt +78 -4
  45. package/llms.txt +1 -1
  46. package/package.json +6 -3
  47. package/dist/Composition-3WWNVRR5.cjs +0 -14
  48. package/dist/Composition-VJWY2NDL.js +0 -5
  49. package/dist/chunk-2ROVCPSL.cjs.map +0 -1
  50. package/dist/chunk-FJA5VUOF.cjs.map +0 -1
  51. package/dist/chunk-XTIHKODH.js.map +0 -1
  52. package/dist/chunk-ZNKQH356.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0
4
+
5
+ **Fixed**
6
+
7
+ - **A text layer used as a mask is the letters, not their bounding box.** `mask: { type: 'text', … }` cut out a plain rectangle (a stencil ignores the letters' alpha), although the docs say any layer works as a mask. It is now an alpha mask: drawn into a texture, so the letters (soft, antialiased edges) are what is kept.
8
+ - **`maskInverted` inside a masked group no longer shows the layer outside the outer mask.** PIXI's inverted stencil tests against the empty level, so a layer with an inverted mask inside a masked composition or `null` layer was drawn everywhere the outer mask hides. An inverted mask is now an alpha mask too (inverse = 1 − alpha). Both fixes cost one extra offscreen pass for the masked layer; ordinary shape masks are unchanged (still a stencil), and the two gallery pieces that use `maskInverted` (`ma`, `liquid-flow-title`) render the same before and after in the six frames compared of each (no pixel differs by more than 29 of 255, and almost all by 0).
9
+
10
+ **Added**
11
+
12
+ - **Part of the movie, and drafts.** `movie.render({ range: [10, 15] })` (seconds) or `range: 'title'` (a top-level layer's span) exports only that part, picture and sound (the file starts at 0; a cut edge of the sound fades over 10 ms). `scale` (above 0, at most 1) and `draft: true` (half size, low quality, no motion blur) make the picture smaller. `pixi-effects-render` takes `--range 10:15`, `--scene title`, `--scale 0.5` and `--draft`. Measured with the 48 s 1080p film on an M1 Pro (whole command, about 7 s of it the browser's start-up): the whole film 20.8 s, `--range 10:15` 10.4 s, `--draft` 20.6 s with a file 7 times smaller (12.0 MB to 1.7 MB), `--scale 0.5` 19.0 s. **A range saves time; a draft or a scale saves file size** (each frame is still drawn at full size, then copied smaller).
13
+ - **`pixi-effects-check` looks harder, and shows a moment or a movement.** It inspects the first and last frame of every scene (a named top-level composition of a second or more) and a frame every 0.25 s, up to 240 frames (a gallery piece went from 59 to 86 inspected frames in the same 3 s; measuring the sound adds time on sound-heavy pieces: `movie.inspectAudio()` takes 1.4 s on the 22 s `christmas-eve` and 3.3 s on the 48 s `ma`, and a whole `check --no-export` takes 3.2 s, 5.5 s and 10 s on `quiet-hours`, `christmas-eve` and `ma`). `--at 3.5,title@end,50%,f120` writes the pictures at those moments (`frames/*.png`, `at.png`); `--onion 1:3` writes `onion.png` (below); `--draft` makes the export a draft; `--query "name=Aiko"` adds to the page address (for a page that reads it, such as a batch of videos). A web font whose file did not load fails the check, and a text layer none of whose fonts is installed is listed for review (`movie.inspectFonts()`).
14
+ - **`movie.onionSkin({ from, to, count, scale, as })`**: frames laid over one another into one picture of a movement, the later the stronger. A thing that moves leaves a trail (its path and its easing); what stays still stays itself.
15
+ - **Loudness.** `movie.inspectAudio()` also returns `loudness` (`integratedLufs`, `truePeakDb`, `clippedSamples`, `silences`; ITU-R BS.1770, no dependency), `scenes` (the same per named top-level layer), `cues` (when each sound starts) and `notes` (advice that does not fail the check: quiet below −24 or loud above −9 LUFS, a true peak above −1 dBTP, silent stretches of a second or more; web video is typically −14 to −16 LUFS). Clipping, or a true peak above 0 dBTP, is an issue and fails the check. `pixi-effects-check` prints it and writes `waveform.png` (the mix as bars and a peak line, scene edges, numbered ticks where each sound starts). The default level of `music` measures about −19 to −20 LUFS (`christmas-eve` −18.8, a lone tune −19.9), inside that range.
16
+ - **Two recipes in the cookbook:** subtitles from an SRT file, and a batch of videos from a table (a page that reads its address plus a shell loop over `pixi-effects-render --query`); both are tested.
17
+
18
+ **Site**
19
+
20
+ - **One look for the whole site.** The landing page, Guide, Gallery, Examples and Playground share one set of colours, fonts, header and footer (`site/shared/`), with the landing page's palette as the reference and both the dark and the light theme everywhere. The navigation is the same five entrances on every page (Home, Guide, Gallery, Examples, Playground); on a phone the header is two rows so nothing falls off the screen. `site/README.md` says where everything is and how to change it.
21
+ - **The Gallery is rebuilt on those parts.** Same behaviour (filters, the theatre with next / previous, `#id` links); it no longer loads a web font, and its theatre stays a dark room in the light theme. The total running time in its intro no longer prints `47.700000000000045 s`.
22
+ - **The Examples entrance is back, as a list of cards** (`examples/examples.json`, including the music lab and the playground). When the landing page became the Pages root it replaced the old list of the numbered examples, and the README's "Live demos" link pointed at the landing page; the README and `llms.txt` now link to `examples/`. A test fails if a numbered example is missing from the list.
23
+ - **The landing page moved to `index.html` at the repository root** (the repository root is the site root), so it opens from any static server without symlinks; the preview script and `npm run landing` are gone.
24
+ - **The deployed layout is code** (`scripts/stage-site.mjs`, which the Pages workflow runs) and a checker (`scripts/site-links.mjs`) fails the tests when a page has an `href` / `src` / `srcset`, an import-map target or an inline-module `import` that does not resolve to a file of the site (it does not follow links inside separate script or style files). `npm run site` builds that layout and serves it locally. The header, footer and `<head>` links of every page are written from one place (`scripts/site-parts.mjs`, `npm run site:sync`).
25
+
26
+ **Changed**
27
+
28
+ - **A mistyped `ease` warns once, with a guess** (`power3.outt`: did you mean `power3.out`). GSAP used to run an unknown ease as its default (`power1.out`): the motion changed and nothing said so.
29
+ - **The browser tools no longer make a sound.** `pixi-effects-check`, `pixi-effects-render` and the test suite start their private Chrome with `--mute-audio`, so a piece with sound no longer plays through the speakers while it is checked or exported (the audio is still analysed and encoded as before).
30
+ - **The release check runs once.** `npm run release:check` builds, type-checks and runs every test, then stamps the exact committed tree. `npm publish` (`prepublishOnly`) sees the stamp and only rebuilds instead of running the whole suite a second time; with no stamp, or with uncommitted changes, it runs everything as before. `npm run test:fast` runs the suite without the real-browser tests (about 10 s).
31
+
32
+ ## 0.16.3
33
+
34
+ **Site**
35
+
36
+ - The landing page no longer undersells speed: it says what was measured (on the author's M1 Pro most gallery pieces export at 105–115 frames per second, 3.5 to 3.8 times real time, the 48 s 1080p film in about 14 s; one heavy particle piece at 21 fps), credits mediabunny's own benchmark against fframes (13.8 s against 17.7 s on one scene, an M4) with what it is and is not, and stops calling fframes "the fast one". The export guide has the same numbers.
37
+
38
+ **Fixed**
39
+
40
+ - **The export progress (the "Exporting video…" bar) disappeared when the mouse stood still.** It lives inside the player bar, which fades after 2.5 s of an idle mouse, so a long export looked like it had stopped unless the mouse kept moving. The bar now stays on screen for the whole export (a video or a PDF, also with the Shift+E shortcut), and fades again as usual when the export ends or fails.
41
+
3
42
  ## 0.16.2
4
43
 
5
44
  **Site**
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.16.2`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `0.16.2` adds a landing page, `AGENTS.md` and a one-sentence start for AI, camera flights (`cameraPath`, shake offsets), `trimEach`, the showpiece `ma` and a fix for 3D cards after a seek, `0.16.1` adds `musicBuffer()` and a music lab page with eight tunes, `0.16.0` adds music written as text (`{ type: 'audio', music }`, `musicEnvelope()`), `0.15.0` adds a way in for an AI in a browser chat (`ai/CHAT.md` and `ai/chat-template.html`), `0.14.0` adds `movie.inspectStops()` (and a check review) for stops that land mid-animation, `0.13.1` fixes decks starting past their first stop in the guide and gallery and draws the stop marks as dots, `0.13.0` makes the player bar play a deck stop by stop (`pauseAtStops`), marks the stops more clearly, adds PDF to the download panel and a `pdf` flag on stops, `0.12.0` adds presentations (`stops`, `Presenter`, `deck()`, page overview, presenter view, PDF export) and audio-reactive visuals (`audioEnvelope()`, `react()`), `0.11.0` adds particles, motion blur, a shared loader and a much faster build for pieces with many layers, `0.10.0` adds seeded randomness, `wiggle` and `stagger`, null layers with `parent`, `animateText`, `followPath` and path morphing, and click-to-play in the player, `0.9.0` adds a poster time (`movie.init({ poster })`) and a guide site, `0.8.0` adds events and a theme for building your own player, `0.7.0` adds filters by name, the `pixi-effects-render` and `pixi-effects-view` commands and the timeline chart, `0.6.0` adds draw-on strokes (`trimEnd`) and text that changes over time, `0.5.0` adds synthesised sound effects (`sfx`, no files) and the `check` tool, `0.4.0` makes a mask share its layer's lifetime, `0.3.0` made keyframe `at` relative to the layer).
3
+ > **Status**: experimental — current release `0.17.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `0.17.0` adds part-of-the-movie and draft exports (`range`, `scale`, `draft`), a stronger `check` (`--at`, `--onion`, fonts, loudness and a waveform), `movie.onionSkin()`, a warning for a mistyped `ease`, two cookbook recipes, and fixes text masks and inverted masks, `0.16.3` fixes the export progress bar disappearing while the mouse is still and updates the landing page's speed figures, `0.16.2` adds a landing page, `AGENTS.md` and a one-sentence start for AI, camera flights (`cameraPath`, shake offsets), `trimEach`, the showpiece `ma` and a fix for 3D cards after a seek, `0.16.1` adds `musicBuffer()` and a music lab page with eight tunes, `0.16.0` adds music written as text (`{ type: 'audio', music }`, `musicEnvelope()`), `0.15.0` adds a way in for an AI in a browser chat (`ai/CHAT.md` and `ai/chat-template.html`), `0.14.0` adds `movie.inspectStops()` (and a check review) for stops that land mid-animation, `0.13.1` fixes decks starting past their first stop in the guide and gallery and draws the stop marks as dots, `0.13.0` makes the player bar play a deck stop by stop (`pauseAtStops`), marks the stops more clearly, adds PDF to the download panel and a `pdf` flag on stops, `0.12.0` adds presentations (`stops`, `Presenter`, `deck()`, page overview, presenter view, PDF export) and audio-reactive visuals (`audioEnvelope()`, `react()`), `0.11.0` adds particles, motion blur, a shared loader and a much faster build for pieces with many layers, `0.10.0` adds seeded randomness, `wiggle` and `stagger`, null layers with `parent`, `animateText`, `followPath` and path morphing, and click-to-play in the player, `0.9.0` adds a poster time (`movie.init({ poster })`) and a guide site, `0.8.0` adds events and a theme for building your own player, `0.7.0` adds filters by name, the `pixi-effects-render` and `pixi-effects-view` commands and the timeline chart, `0.6.0` adds draw-on strokes (`trimEnd`) and text that changes over time, `0.5.0` adds synthesised sound effects (`sfx`, no files) and the `check` tool, `0.4.0` makes a mask share its layer's lifetime, `0.3.0` made keyframe `at` relative to the layer).
4
4
 
5
- **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 39 portfolio pieces written as plain data by AI models · **[Live demos →](https://yjmtmtk.github.io/pixi-effects/)** · 12 numbered examples + an in-browser playground.
5
+ **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 39 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
 
7
7
  ## Make a video by asking an AI
8
8
 
@@ -51,8 +51,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
51
51
  "gsap": "https://esm.sh/gsap@3.12.5",
52
52
  "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
53
53
  "mediabunny": "https://esm.sh/mediabunny",
54
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.16.2/dist/index.js",
55
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.16.2/dist/Controller.js"
54
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.17.0/dist/index.js",
55
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.17.0/dist/Controller.js"
56
56
  }
57
57
  }
58
58
  </script>
@@ -63,7 +63,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
63
63
  </script>
64
64
  ```
65
65
 
66
- Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.16.2/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.
66
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.17.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.
67
67
 
68
68
  > **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
69
69
  >
@@ -79,7 +79,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
79
79
  "imports": {
80
80
  "...": "(everything from the importmap above)",
81
81
  "three": "https://esm.sh/three@0.178.0",
82
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.16.2/dist/three.js"
82
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.17.0/dist/three.js"
83
83
  }
84
84
  }
85
85
  </script>
package/ai/CHAT.md CHANGED
@@ -96,6 +96,10 @@ Every token is a note (`c4`, `f#3`, `Bb2`) with its length in beats after a colo
96
96
 
97
97
  Read your file once for these: every layer has a `type`; every keyframe `at` is inside its layer's lifetime; text is anchored where you meant; layer `duration`s reach the end of their scene; colours have enough contrast; nothing important sits near the edges. A mistake in the data prints a warning in the red box; the person's paste is your test run.
98
98
 
99
+ ## Useful, if the person can open the browser console
100
+
101
+ `movie.inspectAudio()` has `notes` (is the mix quiet or loud for web video, silent stretches) and `loudness.integratedLufs` (typical web video: −14 to −16); `await movie.onionSkin({ from: 1, to: 3, as: 'dataURL' })` is one picture of a movement. A mistyped `ease` prints a warning in the red box.
102
+
99
103
  ## If the preview is blank
100
104
 
101
105
  A blank preview with no red box usually means the chat's preview window blocks external scripts or does not allow `eval`. Tell the person to **save the file as `video.html` and open it in a browser** (Chrome, Edge or Firefox on a computer); that always works.
package/ai/SKILL.md CHANGED
@@ -19,9 +19,10 @@ You describe a video as a tree of plain objects; the library plays it in a canva
19
19
  - `await movie.inspect(frame)` → every layer's canvas bounds and visibility, plus `issues` (text off the canvas, cut off by an edge, empty, or overlapping other text). Run it at several frames; fix every issue it names.
20
20
  - `await movie.snapshot(frame, { as: 'dataURL' })` → one frame, canvas only (no player bar).
21
21
  - `movie.inspectAudio()` → **hear with numbers**: for every sound its `start`, `sound.length`, `sound.peakDb`, `sound.loudestAt` (line this up with the picture) and `sound.brightnessHz` (< 500 dark, > 8000 airy), plus `issues` (limited mix, inaudible or cut-off sounds). Check it whenever the movie has audio; `render()` encodes this same mix.
22
+ - **A moment, a movement, the sound (`pixi-effects-check` options):** `--at 3.5,title@end,50%,f120` writes those pictures to `check-out/<name>/frames/*.png` (and `at.png`): the last frame of a layer is `name@end`, the middle `name@mid`. `--onion 1:3` writes `onion.png`, eight frames of seconds 1–3 overlaid (the later the stronger): a trail that shows a movement's path and easing (even steps = constant speed, bunched = slow). `report.audio` now has `loudness` (`integratedLufs`, `truePeakDb`, `clippedSamples`, `silences`), `scenes` (per named top-level composition), `cues` (when each sound starts) and `notes` (advice: quiet or loud for web video, little headroom, silent stretches; web video is typically −14 to −16 LUFS); `waveform.png` draws the mix with the scene edges and the cues. A web font that failed to load fails the check; a text layer none of whose fonts is installed is listed for review.
22
23
  - **A deck** (`composition.stops`, `deck()`): `await movie.contactSheet({ frames: movie.stops.map(s => s.frame), as: 'dataURL' })` is one picture of every stop (the check writes it as `stops.png`); what the audience reads while a page waits is that picture, so check every one. `await movie.inspectStops()` lists the stops where the picture is still changing (a stop landing before its animation ends); the check prints it as a review list: move the stop later or flag a settled one `pdf: true`. `await movie.stopImages({ which: 'pages' })` / `movie.exportPDF()` give the pages themselves.
23
24
  From a browser tool the result is a `data:` URL string: `agent-browser eval "movie.contactSheet({ count: 6, as: 'dataURL' })" | python3 ai/tools/save-image.py /absolute/sheet.png`, then open the PNG. Sheet tiles are small (~480 px): use `snapshot` for detail. `inspect` output is long on busy scenes: pass `{ layers: 'none' }` and read `issues` first. Give layers a `name` so inspect paths are readable. A moving ticker is not flagged as cut off.
24
- 6. **Iterate** on what you saw (clipped text, collisions, wrong timing). Fast motion looks choppy: add `motionBlur: true` to `movie.init` (render, snapshot and contact sheet then show a film-style blur; renders take ~8× longer). Then export: `const blob = await movie.render({ format: 'mp4' })` (about real time; `movie.on('progress', …)` reports 0–100).
25
+ 6. **Iterate** on what you saw (clipped text, collisions, wrong timing). Fast motion looks choppy: add `motionBlur: true` to `movie.init` (render, snapshot and contact sheet then show a film-style blur; renders take ~8× longer). Then export: `const blob = await movie.render({ format: 'mp4' })` (about real time; `movie.on('progress', …)` reports 0–100). **While you iterate, do not export the whole movie:** `check --no-export --at <the moment you changed>` takes seconds (3 to 10 s on the gallery pieces: most of it is loading the page and measuring the sound); to see motion, `render({ range: [from, to] })` / `pixi-effects-render --range 10:15` / `--scene name` exports only that part (picture and sound); `draft: true` / `--draft` is half size, low quality and no motion blur: a much smaller file (and a render with motion blur is several times faster without it), but the drawing itself is not faster. Export the whole movie once, at the end.
25
26
 
26
27
  ## The rules that cause most failures
27
28
 
@@ -61,9 +61,9 @@
61
61
  "gsap": "https://cdn.jsdelivr.net/npm/gsap@3.12.5/+esm",
62
62
  "gsap/PixiPlugin": "https://cdn.jsdelivr.net/npm/gsap@3.12.5/PixiPlugin/+esm",
63
63
  "mediabunny": "https://cdn.jsdelivr.net/npm/mediabunny@1.61.3/+esm",
64
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.16.2/dist/index.js",
65
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.16.2/dist/Controller.js",
66
- "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.16.2/dist/Presenter.js"
64
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.17.0/dist/index.js",
65
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.17.0/dist/Controller.js",
66
+ "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.17.0/dist/Presenter.js"
67
67
  }
68
68
  }
69
69
  </script>
@@ -163,12 +163,14 @@ const movie = new Movie();
163
163
  await movie.init({ canvas, width, height, duration, frameRate, background, assets, composition });
164
164
  movie.play(); movie.pause();
165
165
  await movie.gotoFrame(n, true); // seek (frames; 30 fps => t = n/30)
166
- const blob = await movie.render({ format: 'mp4' }); // 'mp4' | 'webm' | 'mov' | 'mkv'; ~real-time. Fast motion: `movie.init({ motionBlur: true })` (8 samples, 180° shutter; or a number / { samples: 2–64, shutter: 0–1 }) blurs render / snapshot / contactSheet, not live playback; a render takes `samples`× as long
166
+ const blob = await movie.render({ format: 'mp4' }); // 'mp4' | 'webm' | 'mov' | 'mkv'; ~real-time. Part of it: `range: [2, 5]` (seconds) or `range: 'title'` (a top-level layer's span; picture and sound); `scale: 0.5` / `draft: true` (half size, low quality, no motion blur: a smaller file, not a faster drawing). Fast motion: `movie.init({ motionBlur: true })` (8 samples, 180° shutter; or a number / { samples: 2–64, shutter: 0–1 }) blurs render / snapshot / contactSheet, not live playback; a render takes `samples`× as long
167
167
  movie.on('progress', e => e.progress /* 0–100 */);
168
168
  movie.audioBuffer // mixed audio (after init), if any audio layers
169
169
  await movie.contactSheet({ count: 6, as: 'dataURL' }) // ONE image of several labelled frames — look at it. Options: frames | times | count, columns, cellWidth, as
170
170
  await movie.snapshot(60, { as: 'dataURL' }) // one frame, canvas only (no player bar) — use it for detail; sheet tiles are small
171
171
  await movie.inspect(60, { layers: 'none' }) // issues only (default lists the visible layers); text off-canvas / cut off / empty / overlapping text. Name your layers so paths are readable
172
- movie.inspectAudio() // the soundtrack as numbers — you cannot listen: sources[i] = { layer, start, end, peakDb, sound: { length, peakDb, loudestAt, brightnessHz } }, windows (rms / peak / brightness per 0.1 s), issues (limited mix, inaudible / cut-off sounds)
172
+ await movie.onionSkin({ from: 1, to: 3, count: 8, as: 'dataURL' }) // frames overlaid into ONE picture of a movement (later = stronger): path and easing at a glance
173
+ movie.inspectFonts() // { missing: [{ layer, family }] (no font of the list is installed), failed: [web fonts whose file did not load] }
174
+ movie.inspectAudio() // the soundtrack as numbers; also loudness { integratedLufs, truePeakDb, clippedSamples, silences }, scenes, cues, notes (advice: -14 to -16 LUFS is typical for web video) — — you cannot listen: sources[i] = { layer, start, end, peakDb, sound: { length, peakDb, loudestAt, brightnessHz } }, windows (rms / peak / brightness per 0.1 s), issues (limited mix, inaudible / cut-off sounds)
173
175
  new Controller(movie, { canvas }) // optional player bar (overlays the canvas bottom ~60px; not in the export)
174
176
  ```
@@ -53,6 +53,9 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
53
53
  30c. **Look with the built-in tools, not OS screenshots**: `await movie.contactSheet({ count: 6, as: 'dataURL' })` returns one labelled image of the whole animation; `await movie.inspect(frame)` returns every layer's canvas bounds and `issues`. If you do use a browser tool, `screenshot` needs an **absolute path**, macOS `sed -i` needs `''`, and a static server may redirect `/x.html` → `/x` and drop `?query`. Keep the template's `try/catch` around `init` so failures land in `window.__logs`. Check ≥ 5 timestamps including mid-transition. *(fixed: tools built in)*
54
54
  31. **Check audio with `movie.inspectAudio()`**: every sound measured on its own (`sound.length`, `peakDb`, `loudestAt`, `brightnessHz`), when it plays, loudness and brightness per 0.1 s window, and `issues` (the mix was limited, a layer is inaudible, an sfx is cut off by the end). Decoding a render by hand is no longer needed: `render()` encodes the same mix, on time in mp4 / mov / webm / mkv. *(fixed)*
55
55
 
56
+ 31b. **A mistyped `ease` now warns** (`ease: 'power3.outt'` → did you mean `power3.out`). It used to run silently as GSAP's default (`power1.out`), so the motion changed and nothing said so.
57
+ 31c. **`draft` and `scale` make the file smaller, not the drawing faster** (measured: a 48 s 1080p film exports in about the same time as a draft; the file is 7× smaller). To save time, export a part (`range` / `--range` / `--scene`), look at single moments (`check --no-export --at …`), and keep motion blur off while you iterate.
58
+
56
59
  ## Round 2 — found by building 16 portfolio pieces (examples/gallery)
57
60
 
58
61
  32. **Seeking is safe now** *(fixed)*: a `set` keyframe (counter value, text fill, image tint, shape style) is undone when you seek back before it, and shapes are redrawn before they are culled, so a rect growing from `width: 0` at the left edge draws after any jump seek (contact sheet, snapshot, scrubbing, export). The old workarounds (`width: 0.01`, a one-frame `to` instead of `set`) are not needed.
@@ -760,3 +760,70 @@ three({
760
760
  keyframes: [{ at: 0, to: { 'three.knot.rotation.y': Math.PI * 2 }, duration: 6 }],
761
761
  })
762
762
  ```
763
+
764
+ ## Subtitles from an SRT file
765
+
766
+ Subtitles are just text layers. The function reads a `.srt` (numbers, `00:00:01,000 --> 00:00:03,500`, the words) and returns one layer per cue: it appears and disappears over 0.12 s, sits 90 px above the bottom edge, wraps at 1000 px (about 80 % of a 1280 px picture) and has a black outline so it reads on any picture. Two cues that overlap in time are drawn on top of each other: split a long line into two lines in the `.srt` rather than relying on wrapping. Check the result with `pixi-effects-check`; it reports text that is cut off by the edge.
767
+
768
+ ```js
769
+ // @recipe subtitles
770
+ // SRT text -> one text layer per cue. Paste your .srt into SRT; the function does the rest.
771
+ const SRT = `1
772
+ 00:00:00,500 --> 00:00:02,800
773
+ Hello, and welcome.
774
+
775
+ 2
776
+ 00:00:03,000 --> 00:00:05,500
777
+ Subtitles are just text layers
778
+ that appear and disappear.`;
779
+
780
+ function srt(text, { bottom = 90, size = 44, color = '#ffffff', width = 1000 } = {}) {
781
+ const sec = (h, m, s, ms) => +h * 3600 + +m * 60 + +s + +ms / 1000;
782
+ return text.replace(/\r/g, '').trim().split(/\n\s*\n/).flatMap((block) => {
783
+ const lines = block.split('\n');
784
+ const i = lines.findIndex((l) => l.includes('-->'));
785
+ const t = i < 0 ? null : lines[i].match(/(\d+):(\d+):(\d+)[,.](\d+)\s*-->\s*(\d+):(\d+):(\d+)[,.](\d+)/);
786
+ if (!t) return [];
787
+ const at = sec(t[1], t[2], t[3], t[4]), end = sec(t[5], t[6], t[7], t[8]);
788
+ return [{
789
+ type: 'text', name: `sub ${at.toFixed(2)}`, text: lines.slice(i + 1).join('\n'), at, duration: end - at,
790
+ style: { fontFamily: 'Arial, sans-serif', fontSize: size, fontWeight: 'bold', fill: color, align: 'center', wordWrap: true, wordWrapWidth: width, stroke: { color: '#000000', width: 6 } },
791
+ initial: { x: 'GW/2', y: `GH - ${bottom}`, anchorX: 0.5, anchorY: 1 },
792
+ keyframes: [{ at: 0, from: { alpha: 0 }, to: { alpha: 1 }, duration: 0.12 }, { at: -0.12, to: { alpha: 0 }, duration: 0.12 }],
793
+ }];
794
+ });
795
+ }
796
+
797
+ return [
798
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#1d2433' } },
799
+ ...srt(SRT),
800
+ ];
801
+ ```
802
+
803
+ ## A batch of videos from a table (one video per row)
804
+
805
+ A batch of videos is a loop outside the video. The page reads what changes from its address (`new URLSearchParams(location.search)`), and `pixi-effects-render --query "name=Aiko&score=92"` opens it with those values. Names with spaces or non-Latin letters must be URL-encoded (`jq -sRr @uri` as below, or `encodeURIComponent` in Node). Each video starts its own browser, so N videos cost N start-ups: try one with `--draft` (half size, fast), then render the real ones. To make the pieces of one row differ in more than text, use the same values to pick colours or an image name.
806
+
807
+ ```js
808
+ // @docs-only batch-page
809
+ // batch.html: the same video, with the words taken from the address (?name=Aiko&score=92)
810
+ const q = new URLSearchParams(location.search);
811
+ const NAME = q.get('name') ?? 'World';
812
+ const SCORE = q.get('score') ?? '0';
813
+ // ...then use NAME and SCORE in the text layers of your composition:
814
+ const sequences = [
815
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#1d2433' } },
816
+ { type: 'text', text: `Well done, ${NAME}!`, at: 0.3, duration: 3.7, style: { fontSize: 96, fontWeight: 'bold', fill: '#ffffff' },
817
+ initial: { x: 'GW/2', y: 300, anchorX: 0.5, anchorY: 0.5 }, keyframes: [{ at: 0, from: { alpha: 0, y: 340 }, to: { alpha: 1, y: 300 }, duration: 0.6 }] },
818
+ { type: 'text', text: `${SCORE} points`, at: 0.9, duration: 3.1, style: { fontSize: 64, fill: '#ffd166' },
819
+ initial: { x: 'GW/2', y: 420, anchorX: 0.5, anchorY: 0.5 }, keyframes: [{ at: 0, from: { alpha: 0 }, to: { alpha: 1 }, duration: 0.6 }] },
820
+ ];
821
+ ```
822
+
823
+ ```sh
824
+ # batch.sh: one video per row of table.csv (name,score). --draft first, to look; remove it for the real files.
825
+ mkdir -p out
826
+ while IFS=, read -r name score; do
827
+ npx pixi-effects-render batch.html --query "name=$(printf %s "$name" | jq -sRr @uri)&score=$score" -o "out/$name.mp4" --quiet
828
+ done < table.csv
829
+ ```
package/ai/template.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>pixi-effects video</title>
7
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.16.2/dist/loader.css" /> <!-- the loader: HTML + CSS only, so it is on screen at once and keeps moving while the page builds the video -->
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.17.0/dist/loader.css" /> <!-- the loader: HTML + CSS only, so it is on screen at once and keeps moving while the page builds the video -->
8
8
  <style>
9
9
  body { background: #0d1220; margin: 0; padding: 16px; }
10
10
  .stage { position: relative; width: min(960px, 100%); } /* the player and the loader both sit inside this box */
@@ -39,10 +39,10 @@
39
39
  "gsap": "https://esm.sh/gsap@3.12.5",
40
40
  "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
41
41
  "mediabunny": "https://esm.sh/mediabunny",
42
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.16.2/dist/index.js",
43
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.16.2/dist/Controller.js",
44
- "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.16.2/dist/Presenter.js",
45
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.16.2/dist/three.js",
42
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.17.0/dist/index.js",
43
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.17.0/dist/Controller.js",
44
+ "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.17.0/dist/Presenter.js",
45
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.17.0/dist/three.js",
46
46
  "pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js",
47
47
  "three": "https://esm.sh/three@0.178.0"
48
48
  }
@@ -13,7 +13,10 @@
13
13
  * Needs: Node >= 22 (built-in WebSocket), Chrome / Chromium installed (or --chrome PATH / CHROME=PATH). No npm dependencies.
14
14
  * The page must follow ai/template.html: it exposes `window.movie` and sets `window.__ready = true` (and `window.__logs`).
15
15
  *
16
- * Options: --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 ·
16
+ * Options: --draft (the export is a draft: half size, low quality, no motion blur: a much smaller file; the drawing itself is not faster) · --at LIST (pictures at moments you name: 3.5, 50%, f120, title@end → frames/*.png and at.png) ·
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
+ * --onion A:B (one picture of the movement between A and B seconds, frames overlaid: onion.png) ·
19
+ * --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 ·
17
20
  * --timeout SECONDS (default 240) · --root DIR (static server root; default: the nearest folder above the page with dist/) · --chrome PATH
18
21
  */
19
22
  import { spawn } from 'node:child_process';
@@ -37,7 +40,7 @@ export function findChrome(env = process.env, exists = fs.existsSync, platform =
37
40
  }
38
41
 
39
42
  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 };
43
+ const o = { page: null, out: null, frames: 12, formats: ['mp4'], export: true, timeout: 240, root: null, chrome: null, strict: false, at: null, draft: false, onion: null, query: null };
41
44
  const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
42
45
  for (let i = 0; i < argv.length; i++) {
43
46
  const a = argv[i];
@@ -46,6 +49,14 @@ export function parseArgs(argv) {
46
49
  else if (a === '--formats') o.formats = need(i++, a).split(',').map(s => s.trim()).filter(Boolean);
47
50
  else if (a === '--no-export') o.export = false;
48
51
  else if (a === '--strict') o.strict = true;
52
+ else if (a === '--draft') o.draft = true;
53
+ else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
54
+ else if (a === '--at') o.at = need(i++, a);
55
+ else if (a === '--onion') {
56
+ const m = need(i++, a).match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
57
+ if (!m || !(Number(m[1]) < Number(m[2]))) throw new Error(`--onion must look like 1:3 (seconds, from before to), got "${argv[i]}"`);
58
+ o.onion = [Number(m[1]), Number(m[2])];
59
+ }
49
60
  else if (a === '--timeout') o.timeout = Math.max(10, Number(need(i++, a)) || 240);
50
61
  else if (a === '--root') o.root = need(i++, a);
51
62
  else if (a === '--chrome') o.chrome = need(i++, a);
@@ -56,6 +67,55 @@ export function parseArgs(argv) {
56
67
  return o;
57
68
  }
58
69
 
70
+ /**
71
+ * The frames movie.inspect looks at: the first and last frame, every scene's first and last frame (where a title is cut off or a
72
+ * wipe leaves an overlap), and one every `step` seconds. At most `cap`: past that the even grid is widened, the scene edges stay.
73
+ */
74
+ export function sampleFrames({ totalFrames, frameRate, scenes = [], step = 0.25, cap = 240 }) {
75
+ const last = totalFrames;
76
+ const clamp = f => Math.max(0, Math.min(last, Math.round(f)));
77
+ const edges = new Set([0, last]);
78
+ for (const s of scenes) { edges.add(clamp(s.start * frameRate)); edges.add(clamp(s.end * frameRate - 1)); }
79
+ const room = Math.max(0, cap - edges.size);
80
+ const seconds = totalFrames / frameRate;
81
+ const wanted = Math.floor(seconds / step) + 1;
82
+ const stride = wanted <= room ? step : seconds / Math.max(1, room);
83
+ const frames = new Set(edges);
84
+ for (let t = stride; frames.size < cap && t < seconds; t += stride) frames.add(clamp(t * frameRate));
85
+ return [...frames].sort((a, b) => a - b);
86
+ }
87
+
88
+ const AT_HELP = "use seconds (3.5), a percentage (50%), a frame (f120) or a layer's start / mid / end (name@end)";
89
+
90
+ /** The movie's scenes: top-level compositions with a name you gave (not `text#3`) of a second or more. Same rule as `namedScenes` in src/core/scenes.ts (a test keeps them equal). */
91
+ export function scenesOf(rows) {
92
+ return rows.filter(r => r.depth === 0 && r.type === 'composition' && !/[#×]/.test(r.name) && r.end - r.start >= 1).map(r => ({ name: r.name, start: r.start, end: r.end }));
93
+ }
94
+
95
+ /** `--at` list → frames, each with a label for its file name. */
96
+ export function resolveAtList(list, ctx) {
97
+ const { frameRate: fps, totalFrames, duration, rows } = ctx;
98
+ const out = [], used = new Set();
99
+ for (const raw of String(list).split(',').map(s => s.trim()).filter(Boolean)) {
100
+ let frame, label, m;
101
+ if ((m = raw.match(/^(.+)@(start|mid|end)$/))) {
102
+ const row = rows.find(r => r.name === m[1]);
103
+ if (!row) throw new Error(`--at "${raw}": no layer named "${m[1]}" (names: ${rows.map(r => r.name).join(', ')})`);
104
+ const t = m[2] === 'start' ? row.start : m[2] === 'mid' ? (row.start + row.end) / 2 : row.end - 1 / fps;
105
+ frame = Math.round(t * fps); label = `${m[1]}-${m[2]}`;
106
+ } else if ((m = raw.match(/^(\d+(?:\.\d+)?)%$/))) { frame = Math.round(Number(m[1]) / 100 * totalFrames); label = `${m[1]}pct`; }
107
+ else if ((m = raw.match(/^f(\d+)$/i))) { frame = Number(m[1]); label = `f${m[1]}`; }
108
+ else if (/^\d+(\.\d+)?$/.test(raw)) { frame = Math.round(Number(raw) * fps); label = `${Number(raw).toFixed(2)}s`; }
109
+ else throw new Error(`--at: cannot read "${raw}": ${AT_HELP}`);
110
+ if (frame > totalFrames || frame < 0) throw new Error(`--at "${raw}" is past the end: the movie is ${duration} s (${totalFrames} frames)`);
111
+ let unique = label, n = 2;
112
+ while (used.has(unique)) unique = `${label}-${n++}`;
113
+ used.add(unique);
114
+ out.push({ label: unique, frame });
115
+ }
116
+ return out;
117
+ }
118
+
59
119
  /** [{ frame, issues[] }] → [{ message, count, firstFrame, lastFrame }]: issues that differ only by numbers are one. */
60
120
  export function groupIssues(perFrame) {
61
121
  const groups = new Map();
@@ -140,7 +200,7 @@ export class Cdp {
140
200
  export async function launchChrome(chrome, userDataDir) {
141
201
  const proc = spawn(chrome, [
142
202
  '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check',
143
- '--autoplay-policy=no-user-gesture-required', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader', '--window-size=1400,900', 'about:blank',
203
+ '--autoplay-policy=no-user-gesture-required', '--mute-audio', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader', '--window-size=1400,900', 'about:blank',
144
204
  ], { stdio: ['ignore', 'ignore', 'pipe'] });
145
205
  const endpoint = await new Promise((resolve, reject) => {
146
206
  let buf = '';
@@ -161,11 +221,11 @@ export async function launchChrome(chrome, userDataDir) {
161
221
  const INFO = `(() => { const m = window.movie; if (!m) return { hasMovie: false, logs: window.__logs || [] };
162
222
  const c = document.querySelector('canvas');
163
223
  return { hasMovie: true, logs: window.__logs || [], totalFrames: m.totalFrames, frameRate: m.frameRate, duration: m.duration,
164
- width: m.width ?? (c && c.width), height: m.height ?? (c && c.height), hasAudio: !!m.audioBuffer }; })()`;
224
+ width: m.width ?? (c && c.width), height: m.height ?? (c && c.height), hasAudio: !!m.audioBuffer,
225
+ rows: (() => { try { return m.timelineData().rows.map(r => ({ name: r.name, type: r.type, start: r.start, end: r.end, depth: r.depth })); } catch { return []; } })() }; })()`;
165
226
 
166
- const sweepScript = stride => `(async () => {
167
- const total = movie.totalFrames, frames = new Set([0, total]);
168
- for (let f = 0; f <= total; f += ${stride}) frames.add(Math.min(f, total));
227
+ const sweepScript = list => `(async () => {
228
+ const frames = new Set(${JSON.stringify(list)});
169
229
  const perFrame = [];
170
230
  for (const f of [...frames].sort((a, b) => a - b)) {
171
231
  const r = await movie.inspect(f, { layers: 'none' });
@@ -174,11 +234,11 @@ const sweepScript = stride => `(async () => {
174
234
  return { checked: frames.size, perFrame };
175
235
  })()`;
176
236
 
177
- const exportScript = format => `(() => {
237
+ const exportScript = (format, draft = false) => `(() => {
178
238
  window.__x = { state: 'running' };
179
239
  (async () => {
180
240
  try {
181
- const blob = await movie.render({ format: ${JSON.stringify(format)} });
241
+ const blob = await movie.render({ format: ${JSON.stringify(format)}${draft ? ', draft: true' : ''} });
182
242
  const info = { bytes: blob.size, type: blob.type };
183
243
  const url = URL.createObjectURL(blob);
184
244
  const v = document.createElement('video'); v.muted = true; v.preload = 'metadata'; v.src = url;
@@ -206,6 +266,43 @@ const exportScript = format => `(() => {
206
266
  return 0;
207
267
  })()`;
208
268
 
269
+ /** The mix as a picture (in the page, no dependency): loudness per window as bars, the peak as a line, scene edges and the start of every sound. */
270
+ const waveformScript = (audio, duration) => `(() => {
271
+ const a = ${JSON.stringify({ windows: audio.windows, scenes: audio.scenes, cues: audio.cues })}, D = ${duration};
272
+ const W = 1200, H = 300, L = 44, R = 12, T = 22, B = 58;
273
+ const c = document.createElement('canvas'); c.width = W; c.height = H;
274
+ const g = c.getContext('2d');
275
+ g.fillStyle = '#10131c'; g.fillRect(0, 0, W, H);
276
+ const x = t => L + (t / D) * (W - L - R);
277
+ const y = db => T + (1 - (Math.max(-60, Math.min(0, db)) + 60) / 60) * (H - T - B);
278
+ g.font = '11px system-ui, sans-serif'; g.textBaseline = 'middle';
279
+ for (const db of [0, -12, -24, -36, -48, -60]) {
280
+ g.strokeStyle = 'rgba(255,255,255,.12)'; g.beginPath(); g.moveTo(L, y(db)); g.lineTo(W - R, y(db)); g.stroke();
281
+ g.fillStyle = '#9aa5c4'; g.fillText(String(db), 8, y(db));
282
+ }
283
+ const bw = Math.max(1, (W - L - R) / Math.max(1, a.windows.length));
284
+ g.fillStyle = '#7fb4ff';
285
+ for (const w of a.windows) { const top = y(w.rmsDb); g.fillRect(x(w.t), top, Math.max(1, bw - 0.5), (H - B) - top); }
286
+ g.strokeStyle = '#f2c14e'; g.lineWidth = 1; g.beginPath();
287
+ a.windows.forEach((w, i) => { const px = x(w.t) + bw / 2, py = y(w.peakDb); if (i) g.lineTo(px, py); else g.moveTo(px, py); });
288
+ g.stroke();
289
+ if (a.scenes.length > 1 && a.scenes.length <= 16) {
290
+ let lastLabel = -1e9;
291
+ a.scenes.forEach(s => {
292
+ g.strokeStyle = 'rgba(255,255,255,.35)'; g.beginPath(); g.moveTo(x(s.from), T - 8); g.lineTo(x(s.from), H - B); g.stroke();
293
+ if (x(s.from) - lastLabel < 70) return; // scenes that start together share one label
294
+ lastLabel = x(s.from);
295
+ g.fillStyle = '#e8ecf8'; g.fillText(s.name.slice(0, 18), x(s.from) + 3, T - 10, Math.max(20, x(s.to) - x(s.from) - 6));
296
+ });
297
+ }
298
+ a.cues.forEach((q, i) => { g.fillStyle = '#ff5a3c'; g.fillRect(x(q.t) - 1, H - B + 2, 2, 8); if (a.cues.length <= 40) g.fillText(String(i + 1), x(q.t) - 3, H - B + 20); });
299
+ g.fillStyle = '#9aa5c4';
300
+ const step = D > 120 ? 30 : D > 40 ? 10 : D > 12 ? 5 : 1;
301
+ for (let t = 0; t <= D + 1e-9; t += step) { g.fillText(t + ' s', x(t) - 8, H - 22); }
302
+ g.fillText('mix level (dBFS): bars = RMS, line = peak; red ticks = when a sound starts', L, H - 7);
303
+ return c.toDataURL('image/png');
304
+ })()`;
305
+
209
306
  // ───────────────────────────── the check ─────────────────────────────
210
307
 
211
308
  export const sleep = ms => new Promise(r => setTimeout(r, ms));
@@ -240,7 +337,7 @@ export async function runCheck(opts, log = console.log) {
240
337
  await cdp.send('Runtime.enable');
241
338
  await cdp.send('Page.enable');
242
339
  const t0 = Date.now();
243
- await cdp.send('Page.navigate', { url: `http://127.0.0.1:${port}/${path.relative(root, pagePath).split(path.sep).join('/')}` });
340
+ await cdp.send('Page.navigate', { url: `http://127.0.0.1:${port}/${path.relative(root, pagePath).split(path.sep).join('/')}${opts.query ? `?${opts.query}` : ''}` });
244
341
 
245
342
  // wait for the movie to be ready (or the page to say it failed)
246
343
  let info = null;
@@ -267,11 +364,22 @@ export async function runCheck(opts, log = console.log) {
267
364
  if (report.logs.length) report.problems.push(`${report.logs.length} console warning(s) / error(s) — each says what to change`);
268
365
 
269
366
  // layout: inspect over the whole timeline
270
- const stride = Math.max(1, Math.round(info.totalFrames / 60));
271
- const sweep = await cdp.eval(sweepScript(stride));
367
+ const scenes = scenesOf(info.rows ?? []);
368
+ const sweep = await cdp.eval(sweepScript(sampleFrames({ totalFrames: info.totalFrames, frameRate: info.frameRate, scenes })));
272
369
  const grouped = splitIssues(groupIssues(sweep.perFrame), opts.strict);
273
370
  report.inspect = { checkedFrames: sweep.checked, issues: grouped.problems, review: grouped.review };
274
371
  if (grouped.problems.length) report.problems.push(`${grouped.problems.length} kind(s) of layout issue from movie.inspect`);
372
+
373
+ // fonts: a web font whose file did not load is a problem; a layer none of whose fonts is available is for review (a fallback is drawn)
374
+ const fonts = await cdp.eval('movie.inspectFonts()').catch(() => null);
375
+ if (fonts) {
376
+ report.fonts = fonts;
377
+ if (fonts.failed.length) report.problems.push(`${fonts.failed.length} web font(s) failed to load: ${fonts.failed.join(', ')} (the layers that use them are drawn in a fallback)`);
378
+ for (const m of fonts.missing) {
379
+ (opts.strict ? report.inspect.issues : report.inspect.review).push({ message: `layer "${m.layer}": none of the fonts "${m.family}" is available here (it is drawn in a fallback font)`, count: 1, firstFrame: 0, lastFrame: 0 });
380
+ if (opts.strict) report.problems.push(`layer "${m.layer}": none of its fonts is available (${m.family})`);
381
+ }
382
+ }
275
383
  left();
276
384
 
277
385
  // contact sheet
@@ -280,6 +388,33 @@ export async function runCheck(opts, log = console.log) {
280
388
  fs.writeFileSync(sheetFile, Buffer.from(sheet.slice(sheet.indexOf(',') + 1), 'base64'));
281
389
  report.files.sheet = shown(sheetFile);
282
390
 
391
+ // --at: the pictures at moments you name (seconds, 50%, f120, title@end), one file each and one sheet
392
+ if (opts.at) {
393
+ const wanted = resolveAtList(opts.at, { frameRate: info.frameRate, totalFrames: info.totalFrames, duration: info.duration, rows: info.rows ?? [] });
394
+ const framesDir = path.join(outDir, 'frames');
395
+ fs.mkdirSync(framesDir, { recursive: true });
396
+ for (const w of wanted) {
397
+ const png = await cdp.eval(`movie.snapshot(${w.frame}, { as: 'dataURL' })`);
398
+ fs.writeFileSync(path.join(framesDir, `${w.label}.png`), Buffer.from(png.slice(png.indexOf(',') + 1), 'base64'));
399
+ }
400
+ const atSheet = await cdp.eval(`movie.contactSheet({ frames: ${JSON.stringify(wanted.map(w => w.frame))}, as: 'dataURL' })`);
401
+ const atFile = path.join(outDir, 'at.png');
402
+ fs.writeFileSync(atFile, Buffer.from(atSheet.slice(atSheet.indexOf(',') + 1), 'base64'));
403
+ report.files.at = shown(atFile);
404
+ report.files.frames = shown(framesDir);
405
+ report.at = wanted;
406
+ }
407
+
408
+ // --onion A:B: one picture of the movement between A and B seconds (8 frames overlaid, the later the stronger)
409
+ if (opts.onion) {
410
+ const [from, to] = opts.onion;
411
+ if (to > info.duration + 1e-9) throw new Error(`--onion ${from}:${to} goes past the end: the movie is ${info.duration} s`);
412
+ const onion = await cdp.eval(`movie.onionSkin({ from: ${from}, to: ${to}, count: 8, as: 'dataURL' })`);
413
+ const onionFile = path.join(outDir, 'onion.png');
414
+ fs.writeFileSync(onionFile, Buffer.from(onion.slice(onion.indexOf(',') + 1), 'base64'));
415
+ report.files.onion = shown(onionFile);
416
+ }
417
+
283
418
  // a presentation (composition.stops): one picture of every stop, in order, so the pages and steps can be read at a glance
284
419
  const stops = await cdp.eval(`(() => { const s = movie.stops || []; return s.length ? { count: s.length, pages: movie.pageCount, 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);
285
420
  if (stops) {
@@ -317,6 +452,14 @@ export async function runCheck(opts, log = console.log) {
317
452
 
318
453
  // audio
319
454
  report.audio = info.hasAudio ? await cdp.eval('movie.inspectAudio()') : null;
455
+ if (report.audio) {
456
+ const wave = await cdp.eval(waveformScript(report.audio, info.duration)).catch(() => null);
457
+ if (wave) {
458
+ const waveFile = path.join(outDir, 'waveform.png');
459
+ fs.writeFileSync(waveFile, Buffer.from(wave.slice(wave.indexOf(',') + 1), 'base64'));
460
+ report.files.waveform = shown(waveFile);
461
+ }
462
+ }
320
463
  if (report.audio?.issues?.length) report.problems.push(`${report.audio.issues.length} audio issue(s) from movie.inspectAudio`);
321
464
 
322
465
  // export, decoded again
@@ -324,7 +467,7 @@ export async function runCheck(opts, log = console.log) {
324
467
  if (opts.export) {
325
468
  for (const format of opts.formats) {
326
469
  left();
327
- await cdp.eval(exportScript(format));
470
+ await cdp.eval(exportScript(format, opts.draft));
328
471
  let state;
329
472
  for (;;) {
330
473
  left();
@@ -332,7 +475,7 @@ export async function runCheck(opts, log = console.log) {
332
475
  state = await cdp.eval('window.__x && window.__x.state');
333
476
  if (state && state !== 'running') break;
334
477
  }
335
- const ex = { format };
478
+ const ex = { format, draft: !!opts.draft };
336
479
  if (state === 'error') {
337
480
  ex.error = await cdp.eval('window.__x.message');
338
481
  report.problems.push(`export ${format} failed: ${ex.error}`);
@@ -382,7 +525,7 @@ function finish(report, outDir, log) {
382
525
  if (g.length > 8) L.push(` … ${g.length - 8} more (report.json)`);
383
526
  const rv = report.inspect.review;
384
527
  if (rv.length) {
385
- 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:`);
528
+ L.push(` review ${rv.length} to look at — text overlaps are often intentional (ghost / glow copies, per-letter boxes, a wipe), and a font that is not available is drawn in a fallback; check them on the contact sheet, or use --strict to fail on them:`);
386
529
  for (const i of rv.slice(0, 4)) L.push(line(i));
387
530
  if (rv.length > 4) L.push(` … ${rv.length - 4} more (report.json)`);
388
531
  }
@@ -398,13 +541,14 @@ function finish(report, outDir, log) {
398
541
  }
399
542
  if (report.audio !== undefined) {
400
543
  const a = report.audio;
401
- 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');
544
+ L.push(a ? ` audio ${a.sources.length} source(s) · mix peak ${a.peakDb} dBFS · ${a.loudness?.integratedLufs ?? 'n/a'} LUFS · true peak ${a.loudness?.truePeakDb ?? 'n/a'} dBTP · ${a.issues.length ? a.issues.join(' | ') : 'no issues (movie.inspectAudio)'}` : ' audio none');
545
+ for (const n of a?.notes ?? []) L.push(` note ${n}`);
402
546
  }
403
547
  for (const ex of report.exports ?? []) {
404
548
  if (ex.error) { L.push(` export ${ex.format}: FAILED — ${ex.error}`); continue; }
405
549
  const v = ex.video?.error ? `video: ${ex.video.error}` : `video ${ex.video.duration.toFixed(2)} s ${ex.video.width}×${ex.video.height}`;
406
550
  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';
407
- L.push(` export ${ex.format} ok · ${kb(ex.bytes)} · ${v} · ${au}`);
551
+ L.push(` export ${ex.format}${ex.draft ? ' (draft)' : ''} ok · ${kb(ex.bytes)} · ${v} · ${au}`);
408
552
  }
409
553
  L.push(` files ${Object.values(report.files).join(' ')}`);
410
554
  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.');
@@ -419,7 +563,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
419
563
  if (typeof WebSocket === 'undefined') throw new Error('Node >= 22 is needed (built-in WebSocket)');
420
564
  const opts = parseArgs(process.argv.slice(2));
421
565
  if (!opts.page) {
422
- 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]');
566
+ console.error('usage: node ai/tools/check.mjs <page.html> [--out DIR] [--frames N] [--formats mp4,webm] [--no-export] [--draft] [--at 3.5,title@end] [--onion 1:3] [--query a=1&b=2] [--timeout S] [--root DIR] [--chrome PATH]');
423
567
  process.exit(2);
424
568
  }
425
569
  const report = await runCheck(opts);