pixi-effects 0.28.2 → 0.30.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 (93) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +11 -10
  3. package/ai/chat-template.html +5 -5
  4. package/ai/tools/check.mjs +18 -3
  5. package/ai/tools/lookup.mjs +208 -0
  6. package/ai/tools/pixi-effects.mjs +16 -5
  7. package/ai/tools/render.mjs +14 -9
  8. package/ai/tools/view.mjs +6 -2
  9. package/dist/Base-CB1_FPZD.d.cts +60 -0
  10. package/dist/Base-Ne74ztNl.d.ts +60 -0
  11. package/dist/Composition-MUKR3FYD.cjs +16 -0
  12. package/dist/{Composition-K6B3APM5.cjs.map → Composition-MUKR3FYD.cjs.map} +1 -1
  13. package/dist/Composition-RJGA7KDU.js +7 -0
  14. package/dist/{Composition-H7UNR5F5.js.map → Composition-RJGA7KDU.js.map} +1 -1
  15. package/dist/Controller.cjs +73 -45
  16. package/dist/Controller.cjs.map +1 -1
  17. package/dist/Controller.d.cts +16 -22
  18. package/dist/Controller.d.ts +16 -22
  19. package/dist/Controller.js +74 -40
  20. package/dist/Controller.js.map +1 -1
  21. package/dist/{Movie-CHcM8HNB.d.ts → Movie-C2rZFkrO.d.ts} +227 -119
  22. package/dist/{Movie-Cx5ozUGb.d.cts → Movie-DdnpLpw1.d.cts} +227 -119
  23. package/dist/{blendModes-HDOP6R7T.cjs → blendModes-ETMYWATF.cjs} +5 -5
  24. package/dist/{blendModes-HDOP6R7T.cjs.map → blendModes-ETMYWATF.cjs.map} +1 -1
  25. package/dist/{blendModes-PAVTW3BO.js → blendModes-RIVZXL5A.js} +4 -4
  26. package/dist/{blendModes-PAVTW3BO.js.map → blendModes-RIVZXL5A.js.map} +1 -1
  27. package/dist/{chunk-P4GY3Z4G.cjs → chunk-2DXPTKQZ.cjs} +6 -6
  28. package/dist/chunk-2DXPTKQZ.cjs.map +1 -0
  29. package/dist/{chunk-TYWFKCPY.js → chunk-4KB3GUEK.js} +5 -5
  30. package/dist/chunk-4KB3GUEK.js.map +1 -0
  31. package/dist/{chunk-5MZWXVTT.js → chunk-A3Q3SDSH.js} +35 -28
  32. package/dist/chunk-A3Q3SDSH.js.map +1 -0
  33. package/dist/chunk-CRQFDY7E.js +4182 -0
  34. package/dist/chunk-CRQFDY7E.js.map +1 -0
  35. package/dist/chunk-HREBQAY4.cjs +4206 -0
  36. package/dist/chunk-HREBQAY4.cjs.map +1 -0
  37. package/dist/{chunk-ZMAD3KJU.cjs → chunk-LYOKH7KE.cjs} +22 -3
  38. package/dist/chunk-LYOKH7KE.cjs.map +1 -0
  39. package/dist/{chunk-45QKLD6M.js → chunk-RCVZX2JP.js} +22 -3
  40. package/dist/chunk-RCVZX2JP.js.map +1 -0
  41. package/dist/{chunk-KBH33IQX.cjs → chunk-WHNADXXG.cjs} +121 -114
  42. package/dist/chunk-WHNADXXG.cjs.map +1 -0
  43. package/dist/{chunk-7VN2KI3U.js → chunk-ZAXYPXKW.js} +14 -6
  44. package/dist/chunk-ZAXYPXKW.js.map +1 -0
  45. package/dist/{chunk-MJW7KILM.cjs → chunk-ZVUO325O.cjs} +32 -24
  46. package/dist/chunk-ZVUO325O.cjs.map +1 -0
  47. package/dist/index.cjs +106 -4482
  48. package/dist/index.cjs.map +1 -1
  49. package/dist/index.d.cts +15 -60
  50. package/dist/index.d.ts +15 -60
  51. package/dist/index.js +6 -4457
  52. package/dist/index.js.map +1 -1
  53. package/dist/loader.css +1 -1
  54. package/dist/{Presenter.cjs → present.cjs} +370 -31
  55. package/dist/present.cjs.map +1 -0
  56. package/dist/present.d.cts +278 -0
  57. package/dist/present.d.ts +278 -0
  58. package/dist/{Presenter.js → present.js} +358 -32
  59. package/dist/present.js.map +1 -0
  60. package/dist/three.cjs +6 -6
  61. package/dist/three.d.cts +2 -2
  62. package/dist/three.d.ts +2 -2
  63. package/dist/three.js +2 -2
  64. package/dist/{types-DpK1vwcl.d.ts → types-BzIB65Vk.d.cts} +11 -265
  65. package/dist/{types-DpK1vwcl.d.cts → types-BzIB65Vk.d.ts} +11 -265
  66. package/docs/api.md +44 -77
  67. package/docs/dsl.md +16 -14
  68. package/llms-full.txt +121 -117
  69. package/llms.txt +3 -2
  70. package/package.json +5 -5
  71. package/skills/pixi-effects/SKILL.md +7 -7
  72. package/skills/pixi-effects/reference/cheatsheet.md +9 -9
  73. package/skills/pixi-effects/reference/direction.md +2 -0
  74. package/skills/pixi-effects/reference/pitfalls.md +6 -6
  75. package/skills/pixi-effects/reference/quickstart.md +29 -0
  76. package/skills/pixi-effects/reference/recipes.md +4 -4
  77. package/skills/pixi-effects/template.html +7 -7
  78. package/dist/Base-C6f0fE8j.d.ts +0 -109
  79. package/dist/Base-DI1FCGcH.d.cts +0 -109
  80. package/dist/Composition-H7UNR5F5.js +0 -7
  81. package/dist/Composition-K6B3APM5.cjs +0 -16
  82. package/dist/Presenter.cjs.map +0 -1
  83. package/dist/Presenter.d.cts +0 -122
  84. package/dist/Presenter.d.ts +0 -122
  85. package/dist/Presenter.js.map +0 -1
  86. package/dist/chunk-45QKLD6M.js.map +0 -1
  87. package/dist/chunk-5MZWXVTT.js.map +0 -1
  88. package/dist/chunk-7VN2KI3U.js.map +0 -1
  89. package/dist/chunk-KBH33IQX.cjs.map +0 -1
  90. package/dist/chunk-MJW7KILM.cjs.map +0 -1
  91. package/dist/chunk-P4GY3Z4G.cjs.map +0 -1
  92. package/dist/chunk-TYWFKCPY.js.map +0 -1
  93. package/dist/chunk-ZMAD3KJU.cjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.30.0
4
+
5
+ **Added**
6
+
7
+ - **`pixi-effects new <name>`, `recipe [id]` and `help <word>`: read what you need, not 100 KB.** `new` writes `<name>.html` from the template, titled with the name and pinned to the installed version (it will not overwrite without `--force`). `recipe` lists the tested recipes (id and title); with an id (or a unique part of one) it prints that recipe's text and code; an id nobody has says the nearest one. `help <word>` prints the sections of the docs about a word (`docs/dsl.md`, the cheatsheet, the pitfalls, `docs/api.md`, `direction.md`), headings first, each cut to 30 lines with where the rest is, never more than 170 lines in all.
8
+ - **`skills/pixi-effects/reference/quickstart.md`** (under 2 KB, a test holds it there): the five commands, a title card that the recipe tests build and lint, and the rules that cause most failures. `SKILL.md`, `AGENTS.md`, `llms.txt` and the README start an agent there; the 35 KB cheatsheet and the 41 KB pitfalls are for looking things up.
9
+
10
+ ## 0.29.0
11
+
12
+ **Changed (breaking): presentation leaves the core**
13
+
14
+ The talk side of the library is now its own entry, `pixi-effects/present`, built on the core's public API only (a test enforces the import rule). The core keeps `composition.stops` (named times, `movie.stops`) and gains two general moves; everything else that was about a talk moved out.
15
+
16
+ | before | now |
17
+ |---|---|
18
+ | `movie.next()` `prev()` `goToStop(i)` `goToPage(n)` | `next(movie)` `prev(movie)` `goToStop(movie, i)` `goToPage(movie, n)` from `pixi-effects/present` |
19
+ | `movie.stopIndex` `pageIndex` `pageCount` `currentStop` | `stopIndex(movie)` `pageIndex(movie)` `pageCount(movie)` `currentStop(movie)` |
20
+ | `movie.on('stop', fn)` | `const off = onStop(movie, fn)` |
21
+ | `movie.stopImages(o)` `movie.inspectStops(o)` `movie.exportPDF(o)` | `stopImages(movie, o)` `inspectStops(movie, o)` `exportPDF(movie, o)` |
22
+ | `import { deck } from 'pixi-effects'` | `import { deck } from 'pixi-effects/present'` |
23
+ | `import { Presenter } from 'pixi-effects/presenter'` (`dist/Presenter.js`) | `import { Presenter } from 'pixi-effects/present'` (`dist/present.js`; the import map line is `"pixi-effects/present"`) |
24
+ | `normalizeStops`, `pictureStops` exported from `pixi-effects` | not exported (the core normalises `composition.stops` itself; `pictureStops` is inside `pixi-effects/present`) |
25
+ | the types `StopEvent`, `StopImage`, `StopImagesOptions` from `pixi-effects` | the same names from `pixi-effects/present` (`Stop` and `StopSpec` stay in `pixi-effects`) |
26
+
27
+ A page that only maps `"pixi-effects"` still gets its stops reviewed and its PDF exported by `check` / `render`: they load `present.js` from beside the library.
28
+
29
+ **Changed (breaking): the API list for 0.29** (`docs/api-surface.txt` is the whole public surface, read from the sources and frozen by a test; the decisions are in `docs/superpowers/specs/2026-10-11-api-list-0.29.md`)
30
+
31
+ | before | now |
32
+ |---|---|
33
+ | `maskInverted: true` next to `mask: { …a layer… }` | `mask: { layer: { …the mask layer… }, invert: true }` (the one inverted-mask form, the same as `{ layer: 'name', invert: true }` for a named matte; `channel: 'luma'` works on a layer written in place too). The old key warns and says this. Inside a list of masks only names are allowed |
34
+ | `Controller` options `enableKeyboardShortcuts`, `showExportButton`, `present` | `keyboard`, `exportButton`, `presentButton` (the Presenter's names). An unknown `Controller` option now warns, with did-you-mean |
35
+ | `pixi-effects/controller` also exported `CONTROLLER_CSS`, `formatTime`, `frameToPercent`, `pxToFrame`, `pxToFraction`, `extensionForMimeType` | `Controller`, `ControllerOptions`, `ControllerTheme` only |
36
+ | the bins `pixi-effects-check`, `pixi-effects-render`, `pixi-effects-view` | `pixi-effects check|render|view` (the one bin; `npx pixi-effects-check` never worked) |
37
+ | `pixi-effects render --fail-on-warn` | `--strict`, the word `check` already used (the old flag says so) |
38
+ | `Movie.emit`, `audioSource`, `gainNode`; `Sequence.bindTimeline`, `collectAudio`, `absoluteStart`, `keepHidden`, `maskSequence`, `filters`, `updateSpace`, `syncFrame` in the type declarations | marked `@internal` and stripped from the declarations (the library still uses them) |
39
+
40
+ **Added**
41
+
42
+ - `movie.playUntil(seconds?)`: play and pause exactly at a time (`true` when it landed; `false` when a pause, a seek or a newer `playUntil` ended it first; no time = to the end). It is what `next(movie)` is made of, and the way to "play the intro, then wait".
43
+ - `movie.peek(fn)`: run a function that looks around (seeks, pictures) and put the playhead or the poster back; the seeks inside are not reported as `seeking` / `seeked`.
44
+ - `movie.snapshot(frame, { quality })` (JPEG quality) and `{ as: 'imageData' }` (the pixels, to measure or compare).
45
+ - `warnUnknownOptions(where, options, validNames)` is exported: the did-you-mean warning for an unknown option, for presets and layer types written outside the library.
46
+ - `animateText({ out: { preset, at: T } })`: the exit starts at composition second T (the pieces leave in the same wave and stay gone until the layer ends). Without `at` it is the end of the layer run backwards, as before; a warning says `out.at ≤ …` when the exit does not fit, or when T is before the entrance has finished.
47
+ - `pixi-effects render --help` and `pixi-effects view --help` (usage, exit 0), like `check`.
48
+ - A warning when a picture key (`filters`, `filterArea`, `mask`, `blendMode`, `threeD`, `hideBehindCamera`, `lit`, `castsShadows`) is put on a layer that is never drawn: an audio layer, a camera, a light (a light keeps `castsShadows`, its own switch). The key did nothing there, silently.
49
+
50
+ **Fixed**
51
+
52
+ - `NamedFilterType` lacked `'warp'`: `{ type: 'warp' }` was a TypeScript error though it worked at run time. The compiler now checks the type and the runtime filter tables against each other, both ways.
53
+
54
+ **Checked, not reproduced**
55
+
56
+ - The two bugs the old roadmap listed (an inline shape mask under a transition; a 4000 x 2400 `threeD` layer black on WebGPU) do not reproduce on this code. Real-browser tests pin both, on both backends.
57
+
58
+ **Docs**
59
+
60
+ - **The film guide says who it is for.** `direction.md` opens with a note for an AI in a browser chat (no shell, no files): this guide is not yours, follow `ai/CHAT.md`; you can still put a three-line plan in your reply and ask. The `llms.txt` line for it says "for an AI with a shell and files; a browser chat skips it".
61
+
3
62
  ## 0.28.2
4
63
 
5
64
  **Added**
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.28.2`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
3
+ > **Status**: experimental — current release `0.30.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
 
@@ -21,14 +21,14 @@ Declarative composition and video rendering for the web. After Effects-style tim
21
21
  - **Draw-on strokes and changing text** — `trimEnd` 0 → 1 draws a line, border or SVG path on; a keyframe `set: { text }` swaps a string and `visibleChars` is a typewriter.
22
22
  - **Masks, transitions, chroma key** — inline masks; chroma key; eight scene transitions (`crossfade`, `wipe`, `iris`, `slide`, `dip`, `zoom`, `dissolve`, `luma`).
23
23
  - **Presets** — `kenBurns` for stills, `withFade` for fade-in/out; `wiggle`, `stagger`, `animateText`, `followPath`, `particles` and `react` return keyframes or layers (seeded, so every render is the same).
24
- - **Presentations** — `stops` in the composition pause the movie where you say; `Presenter` (`pixi-effects/presenter`) moves on with arrow keys, a click or a swipe, with a page overview (G), a presenter view with notes and a timer (P), and `deck()` to lay out pages and steps. Export a deck as PDF (`movie.exportPDF()`, `pixi-effects-render talk.html -o talk.pdf`). See the [Presenting guide](https://yjmtmtk.github.io/pixi-effects/guide/presenting.html).
24
+ - **Presentations** — `stops` in the composition pause the movie where you say; `Presenter` (`pixi-effects/present`) moves on with arrow keys, a click or a swipe, with a page overview (G), a presenter view with notes and a timer (P), and `deck()` to lay out pages and steps. Export a deck as PDF (`exportPDF(movie)`, `pixi-effects render talk.html -o talk.pdf`). See the [Presenting guide](https://yjmtmtk.github.io/pixi-effects/guide/presenting.html).
25
25
  - **Music written as text** — `{ type: 'audio', music: { bpm: 96, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4' }], drums: { kick: 'x...x...' } } }`: a tune with no audio file, played by a small built-in synthesiser (keys, pluck, pad, bass, lead, bell, music box, drums; chords by name, swing, fades, a ritardando). Deterministic, in the exported file at the same moment; an AI writes it as easily as a title.
26
26
  - **Audio-reactive visuals** — `audioEnvelope('music.mp3')` analyses loudness, bass / mid / treble and the beats per frame; `react()` turns them into keyframes, so the picture follows the music and still renders identically.
27
27
  - **2.5D layers & camera** — add `threeD: true`, `z`, `rotationX/Y` and a `{ type: 'camera' }` layer for parallax, flips and dolly zooms; no three.js needed.
28
28
  - **three.js layer** *(optional)* — drop a real three.js scene in as a layer via `pixi-effects/three`; keyframes drive its objects (`three.cube.rotation.y`).
29
29
  - **Built-in player UI — or your own** — a drop-in HTML5-`<video>`-style overlay controller (play, scrub, mute, volume, fullscreen, export-to-file) whose colours are one option (`theme: { accent: '#ff4d6d' }`); or build a player yourself on the movie's `<video>`-named events (`play pause ended seeking seeked volumechange error`).
30
- - **MP4 / WebM / MOV export** — pick container and quality from the controller, call `movie.render()` from code, or render headless from the command line (`pixi-effects-render`).
31
- - **A timeline you can read and scrub** — `movie.timelineChart()` draws every layer as a bar on a time axis; `pixi-effects-view` opens the page with that timeline under it, zoomable, with a playhead you can drag.
30
+ - **MP4 / WebM / MOV export** — pick container and quality from the controller, call `movie.render()` from code, or render headless from the command line (`pixi-effects render`).
31
+ - **A timeline you can read and scrub** — `movie.timelineChart()` draws every layer as a bar on a time axis; `pixi-effects view` opens the page with that timeline under it, zoomable, with a playhead you can drag.
32
32
  - **Tiny dependency surface** — only `mediabunny` (runtime) plus PixiJS (a peer dep). three.js is an *optional* peer, needed only if you import `pixi-effects/three`.
33
33
 
34
34
  ## Install
@@ -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.28.2/dist/index.js",
53
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.2/dist/Controller.js"
52
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/index.js",
53
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.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.28.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.
64
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.30.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.28.2/dist/three.js"
80
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/three.js"
81
81
  }
82
82
  }
83
83
  </script>
@@ -174,8 +174,9 @@ This library is designed to be written by AI: a video is plain data, and every m
174
174
  - [`skills/pixi-effects/SKILL.md`](./skills/pixi-effects/SKILL.md) — a [skill](https://docs.claude.com/en/docs/claude-code/skills) (workflow, rules, verification loop). Install it with `npx skills add yjmtmtk/pixi-effects` (or copy the `skills/pixi-effects/` folder to `~/.claude/skills/pixi-effects/` or your project's `.claude/skills/`) and your agent loads it by itself when you ask for a video.
175
175
  - [`skills/pixi-effects/reference/cheatsheet.md`](./skills/pixi-effects/reference/cheatsheet.md), [`recipes.md`](./skills/pixi-effects/reference/recipes.md) (tested), [`pitfalls.md`](./skills/pixi-effects/reference/pitfalls.md), and a starter [`skills/pixi-effects/template.html`](./skills/pixi-effects/template.html).
176
176
 
177
+ - **Start a page, and read only what you need:** `npx pixi-effects new my-video` writes `my-video.html` from the template; `npx pixi-effects recipe [id]` lists the tested recipes and prints one with its code; `npx pixi-effects help <word>` prints the sections of the docs about a word (`mask`, `threeD`, `keyframes`, `music` …). An AI starts from the 2 KB [quick start](./skills/pixi-effects/reference/quickstart.md), not from the cheatsheet.
177
178
  - **Look at a page with its timeline, and scrub one with the other:** `npx pixi-effects view my-video.html` (it also watches the folder: when the work changes it shows "Updated hh:mm:ss — reload (R)" and never switches by itself; reload comes back to the same frame) (or `node ai/tools/view.mjs …`) opens your browser on a viewer: the page on top, the timeline of every layer under it with a playhead that follows the movie. Click or drag the timeline to seek, click a layer's name to jump to where it starts, Space plays, ← / → step a frame, + / − (or Ctrl/⌘ + wheel) zoom, the names stay in place. For the human who wants to see what the AI made.
178
- - **Render a page to a video file, headless:** `npx pixi-effects render my-video.html -o my-video.mp4` (or `node ai/tools/render.mjs …`; Node ≥ 22 and Chrome installed, no dependencies). The container follows the extension (`mp4 webm mov mkv`); `--quality very-low…very-high`, `--query lang=ja` (added to the page URL), `--fail-on-warn` (exit 1 when the page logged a warning), `--quiet`. It waits for `window.__ready`, runs `movie.render()` and streams the file to disk; exit 0 = written, 1 = the page or the render failed (no file). For scripts, CI and batches; `pixi-effects-check` below is the review.
179
+ - **Render a page to a video file, headless:** `npx pixi-effects render my-video.html -o my-video.mp4` (or `node ai/tools/render.mjs …`; Node ≥ 22 and Chrome installed, no dependencies). The container follows the extension (`mp4 webm mov mkv`); `--quality very-low…very-high`, `--query lang=ja` (added to the page URL), `--strict` (exit 1 when the page logged a warning), `--quiet`. It waits for `window.__ready`, runs `movie.render()` and streams the file to disk; exit 0 = written, 1 = the page or the render failed (no file). For scripts, CI and batches; `pixi-effects check` below is the review.
179
180
  - **A talk as a PDF:** `npx pixi-effects render my-talk.html -o my-talk.pdf` (one page per page of a `deck()` / `stops` movie; `--all-stops` makes a page of every step). The review tool writes `stops.png` for such a page, one picture per stop.
180
181
  - **One-command review for the AI that wrote the page:** `npx pixi-effects check my-video.html` (or `node ai/tools/check.mjs my-video.html`; Node ≥ 22 and Chrome installed, no dependencies). It opens the page in its own headless Chrome and reports warnings, layout (`movie.inspect` over the whole timeline), the soundtrack (`movie.inspectAudio`) and a real export decoded again, and writes a contact sheet PNG and a `timeline.html` (every layer as a bar on a time axis) to look at. Exit code 0 / 1.
181
182
 
@@ -184,7 +185,7 @@ They ship in the npm package (`node_modules/pixi-effects/ai/`).
184
185
  ## Documentation
185
186
 
186
187
  - [**DSL reference**](./docs/dsl.md) — composition, sequences, 3D layers & camera, three.js layer, keyframes, expressions, filters
187
- - [**API reference**](./docs/api.md) — `Movie`, `Controller`, `pixi-effects/presenter`, `pixi-effects/three`, events, render options
188
+ - [**API reference**](./docs/api.md) — `Movie`, `Controller`, `pixi-effects/present`, `pixi-effects/three`, events, render options
188
189
  - [**Examples**](./examples/) — runnable HTML files (read in order):
189
190
  - `01-hello.html` — minimum viable composition
190
191
  - `02-keyframes.html` — keyframes, easings, expressions
@@ -59,17 +59,17 @@
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.28.2/dist/index.js",
63
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.2/dist/Controller.js",
64
- "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.28.2/dist/Presenter.js"
62
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/index.js",
63
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/Controller.js",
64
+ "pixi-effects/present": "https://cdn.jsdelivr.net/npm/pixi-effects@0.30.0/dist/present.js"
65
65
  }
66
66
  }
67
67
  </script>
68
68
 
69
69
  <script type="module">
70
- import { Movie, kenBurns, withFade, wiggle, stagger, animateText, followPath, particles, deck } from 'pixi-effects';
70
+ import { Movie, kenBurns, withFade, wiggle, stagger, animateText, followPath, particles } from 'pixi-effects';
71
71
  import { Controller } from 'pixi-effects/controller';
72
- // A talk instead of a video: import { Presenter } from 'pixi-effects/presenter'; use deck({ pages: [...] }) and new Presenter(movie, { canvas }) instead of the Controller.
72
+ // A talk instead of a video: import { deck, Presenter } from 'pixi-effects/present'; use deck({ pages: [...] }) and new Presenter(movie, { canvas }) instead of the Controller.
73
73
 
74
74
  // ===================== EDIT FROM HERE =====================
75
75
  const W = 1280, H = 720, FPS = 30, DURATION = 6;
@@ -286,6 +286,18 @@ const waveformScript = (audio, duration) => `(() => {
286
286
  // ───────────────────────────── the check ─────────────────────────────
287
287
 
288
288
  export const sleep = ms => new Promise(r => setTimeout(r, ms));
289
+
290
+ /**
291
+ * JavaScript, as text, for the page: it resolves to the page's own `pixi-effects/present` module (stops, pages, PDF). The page's import map names it
292
+ * (`pixi-effects/present`); a page that only maps `pixi-effects` gets the `present.js` that sits beside the library.
293
+ */
294
+ export const LOAD_PRESENT = `(async () => {
295
+ try { return await import('pixi-effects/present'); } catch (e) { /* not in the import map: look beside the library */ }
296
+ let lib = null;
297
+ try { lib = JSON.parse(document.querySelector('script[type="importmap"]').textContent).imports['pixi-effects']; } catch (e) { /* none */ }
298
+ if (!lib) throw new Error('the page has no "pixi-effects" in its import map');
299
+ return import(new URL('present.js', new URL(lib, document.baseURI)).href);
300
+ })()`;
289
301
  /** A path for display: relative to the cwd when it is inside it, absolute otherwise. */
290
302
  export const shown = f => { const r = path.relative(process.cwd(), f); return r.startsWith('..') || path.isAbsolute(r) ? f : r; };
291
303
  const kb = n => (n >= 1048576 ? `${(n / 1048576).toFixed(2)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
@@ -404,11 +416,13 @@ export async function runCheck(opts, log = console.log) {
404
416
  }
405
417
 
406
418
  // a presentation (composition.stops): one picture of every stop, in order, so the pages and steps can be read at a glance
407
- 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);
419
+ 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);
408
420
  if (stops) {
409
421
  report.stops = stops;
410
422
  // is the picture at each stop still changing? (the audience, the page overview and a PDF would see a half-finished animation)
411
- const settled = await cdp.eval('movie.inspectStops()').catch(() => null);
423
+ let skipped = '';
424
+ const settled = await cdp.eval(`${LOAD_PRESENT}.then(p => p.inspectStops(movie))`).catch(e => { skipped = String((e && e.message) || e); return null; });
425
+ if (!settled) report.stops.reviewSkipped = `${skipped || 'no answer'} (the page needs "pixi-effects/present": ".../dist/present.js" in its import map, from the same version as the library)`;
412
426
  if (settled) {
413
427
  report.stops.review = settled.issues;
414
428
  report.stops.moving = settled.stops.map(s => Math.round(s.moving * 10000) / 10000);
@@ -529,7 +543,8 @@ function finish(report, outDir, log) {
529
543
  }
530
544
  if (report.stops) {
531
545
  const s = report.stops;
532
- L.push(` stops ${s.count} stop(s) on ${s.pages} page(s) — stops.png shows each one in order; the picture at a stop is what the audience sees while it waits`);
546
+ L.push(` stops ${s.count} stop(s) on ${s.pages} page(s) — stops.png shows each one in order; the picture at a stop is what the audience sees while it waits`)
547
+ if (s.reviewSkipped) L.push(` not reviewed for stops that are still moving: ${s.reviewSkipped}`);
533
548
  if (s.review?.length) {
534
549
  L.push(` review ${s.review.length} stop(s) where the picture is still changing (may be on purpose, e.g. something always moving; use --strict to fail on them):`);
535
550
  for (const r of s.review.slice(0, 4)) L.push(` - ${r}`);
@@ -0,0 +1,208 @@
1
+ // pixi-effects recipe | help | new: let an agent read what it needs instead of a 35 KB cheatsheet and a 69 KB list of recipes.
2
+ //
3
+ // npx pixi-effects recipe the recipes (id and title)
4
+ // npx pixi-effects recipe <id> one recipe: its text and its code (a part of an id works when it is unique)
5
+ // npx pixi-effects help <word> the sections of the docs about a word (mask, threeD, keyframes, music …)
6
+ // npx pixi-effects new <name> <name>.html from the template, pinned to this package's version
7
+ //
8
+ // Zero dependencies. The texts are the ones the package ships (docs/dsl.md, docs/api.md, skills/pixi-effects/reference/*.md, skills/pixi-effects/template.html).
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
14
+ const RECIPES = 'skills/pixi-effects/reference/recipes.md';
15
+ /** What `help` reads, most useful first (the number is a small bonus in the ranking). */
16
+ const CORPUS = [
17
+ ['docs/dsl.md', 10], ['skills/pixi-effects/reference/cheatsheet.md', 8], ['skills/pixi-effects/reference/pitfalls.md', 6],
18
+ ['docs/api.md', 4], ['skills/pixi-effects/reference/direction.md', 2],
19
+ ];
20
+ const MAX_HITS = 5, MAX_LINES_PER_HIT = 30, MAX_OUTPUT_LINES = 170;
21
+
22
+ function distance(a, b) {
23
+ const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
24
+ for (let j = 1; j <= b.length; j++) m[0][j] = j;
25
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
26
+ m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
27
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) m[i][j] = Math.min(m[i][j], m[i - 2][j - 2] + 1);
28
+ }
29
+ return m[a.length][b.length];
30
+ }
31
+ const nearestOf = (word, list) => {
32
+ let best = null;
33
+ for (const c of list) { const d = distance(word.toLowerCase(), c.toLowerCase()); if (d <= Math.max(2, Math.floor(c.length / 3)) && (!best || d < best.d)) best = { c, d }; }
34
+ return best ? best.c : undefined;
35
+ };
36
+
37
+ /** The "## " sections of a markdown text (a heading inside a code fence is not one). Each: { title, text, line (1-based), ids (the names after // @recipe or // @docs-only) }. */
38
+ export function parseRecipes(md) {
39
+ const lines = md.split('\n');
40
+ const sections = [];
41
+ let fence = false, cur = null;
42
+ lines.forEach((line, i) => {
43
+ if (/^```/.test(line)) fence = !fence;
44
+ const h = !fence && /^## (.+)$/.exec(line);
45
+ if (h) { cur = { title: h[1].trim(), lines: [line], line: i + 1 }; sections.push(cur); }
46
+ else if (cur) cur.lines.push(line);
47
+ });
48
+ return sections.map(s => {
49
+ const body = [...s.lines];
50
+ while (body.length && /^(---|\s*)$/.test(body[body.length - 1])) body.pop(); // the separator belongs to nobody
51
+ const text = body.join('\n');
52
+ return { title: s.title, text, line: s.line, ids: [...text.matchAll(/\/\/ @(?:recipe|docs-only)[ \t]+([A-Za-z0-9_-]+)/g)].map(m => m[1]) };
53
+ });
54
+ }
55
+
56
+ /** { section, id } for an exact id or a unique part of one (or of a title); { many } when several match; { nearest } (maybe undefined) when none does. */
57
+ export function findRecipe(sections, query) {
58
+ const q = query.trim().toLowerCase();
59
+ const exact = sections.find(s => s.ids.some(id => id.toLowerCase() === q));
60
+ if (exact) return { section: exact, id: exact.ids.find(id => id.toLowerCase() === q) };
61
+ const part = sections.filter(s => s.ids.some(id => id.toLowerCase().includes(q)) || s.title.toLowerCase().includes(q));
62
+ if (part.length === 1) return { section: part[0] };
63
+ if (part.length > 1) return { many: part };
64
+ return { nearest: nearestOf(q, sections.flatMap(s => s.ids)) };
65
+ }
66
+
67
+ const readIf = (file) => { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } };
68
+
69
+ /** Sections of a doc: a "## / ### / ####" heading each; for the pitfalls, a numbered item each ("84. **Subtract …**"). */
70
+ function docSections(text, file) {
71
+ const out = [];
72
+ const lines = text.split('\n');
73
+ const pit = /pitfalls\.md$/.test(file);
74
+ let fence = false, cur = null;
75
+ lines.forEach((line, i) => {
76
+ if (/^```/.test(line)) fence = !fence;
77
+ let heading = null;
78
+ if (!fence) {
79
+ const h = /^#{2,4} (.+)$/.exec(line);
80
+ const p = pit && /^(\d+[a-z]?)\. \*\*(.+?)\*\*/.exec(line);
81
+ if (h) heading = h[1].trim();
82
+ else if (p) heading = `pitfall ${p[1]}: ${p[2].replace(/[`*]/g, '')}`.slice(0, 100);
83
+ }
84
+ if (heading) { cur = { file, heading, line: i + 1, lines: [line] }; out.push(cur); }
85
+ else if (cur) cur.lines.push(line);
86
+ });
87
+ return out;
88
+ }
89
+
90
+ /** The sections of the docs about `word` (all its words must appear), best first: [{ file, line, heading, lines, more }]. */
91
+ export function searchDocs(root, word) {
92
+ const tokens = String(word).toLowerCase().split(/\s+/).filter(Boolean);
93
+ if (!tokens.length) return [];
94
+ const scored = [];
95
+ for (const [file, bonus] of CORPUS) {
96
+ const text = readIf(path.join(root, file));
97
+ if (!text) continue;
98
+ for (const s of docSections(text, file)) {
99
+ const heading = s.heading.toLowerCase();
100
+ const body = s.lines.join('\n').toLowerCase();
101
+ if (!tokens.every(t => body.includes(t))) continue;
102
+ const inHeading = tokens.every(t => heading.includes(t));
103
+ const count = tokens.reduce((n, t) => n + body.split(t).length - 1, 0);
104
+ scored.push({ s, score: (inHeading ? 1000 : 0) + Math.min(count, 50) + bonus });
105
+ }
106
+ }
107
+ scored.sort((a, b) => b.score - a.score);
108
+ return scored.slice(0, MAX_HITS).map(({ s }) => {
109
+ const body = s.lines.filter((l, i) => i === 0 || l.trim() !== '' || true);
110
+ const shown = body.slice(0, MAX_LINES_PER_HIT);
111
+ return { file: s.file, line: s.line, heading: s.heading, lines: shown, more: Math.max(0, body.length - shown.length) };
112
+ });
113
+ }
114
+
115
+ /** The lines `help` prints for those hits (the whole answer stays under MAX_OUTPUT_LINES), with the next step at the end. */
116
+ export function formatHelp(hits, word, root = PACKAGE_ROOT) {
117
+ const out = [];
118
+ const share = Math.max(8, Math.floor((MAX_OUTPUT_LINES - 6 - hits.length * 3) / Math.max(1, hits.length)));
119
+ for (const h of hits) {
120
+ out.push(`── ${h.file}:${h.line} ${h.heading}`);
121
+ const lines = h.lines.slice(0, share);
122
+ out.push(...lines);
123
+ const more = h.more + (h.lines.length - lines.length);
124
+ if (more > 0) out.push(`… ${more} more lines: ${h.file}:${h.line}`);
125
+ out.push('');
126
+ }
127
+ const md = readIf(path.join(root, RECIPES));
128
+ const ids = md ? parseRecipes(md).filter(s => String(word).toLowerCase().split(/\s+/).every(t => s.title.toLowerCase().includes(t) || s.ids.some(id => id.includes(t)))).flatMap(s => s.ids).slice(0, 6) : [];
129
+ out.push(`next: pixi-effects help <another word>${ids.length ? ` · recipes about it: ${ids.map(i => `pixi-effects recipe ${i}`).join(', ')}` : ' · pixi-effects recipe (the tested pieces to copy)'}`);
130
+ return out.slice(0, MAX_OUTPUT_LINES);
131
+ }
132
+
133
+ /** <name>.html from the template, titled with the name, its CDN pins set to this package's version. Throws if the file exists (without `force`). */
134
+ export function newPage(name, { cwd = process.cwd(), root = PACKAGE_ROOT, force = false } = {}) {
135
+ if (!name || !String(name).trim()) throw new Error('give the page a name: pixi-effects new my-video');
136
+ const raw = String(name).trim();
137
+ const base = path.basename(raw).replace(/\.html?$/i, '');
138
+ const dir = path.dirname(raw);
139
+ const file = path.resolve(cwd, dir, base.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, '-') + '.html');
140
+ if (!fs.existsSync(path.dirname(file))) throw new Error(`the folder ${path.dirname(file)} does not exist`);
141
+ if (fs.existsSync(file) && !force) throw new Error(`${file} already exists (give another name, or --force to replace it)`);
142
+ const template = readIf(path.join(root, 'skills/pixi-effects/template.html'));
143
+ if (!template) throw new Error('the template is missing from this package (skills/pixi-effects/template.html)');
144
+ const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
145
+ const html = template
146
+ .replace(/pixi-effects@\d+\.\d+\.\d+/g, `pixi-effects@${version}`)
147
+ .replace('<title>pixi-effects video</title>', `<title>${base.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</title>`);
148
+ fs.writeFileSync(file, html);
149
+ return { file };
150
+ }
151
+
152
+ const shownPath = (file, cwd) => { const r = path.relative(cwd, file); return r.startsWith('..') || path.isAbsolute(r) ? file : r; };
153
+
154
+ export const LOOKUP_COMMANDS = ['recipe', 'help', 'new'];
155
+
156
+ /** Run one of the commands: { code, stdout, stderr } (nothing is printed here). */
157
+ export function runLookup(cmd, args, { cwd = process.cwd(), root = PACKAGE_ROOT } = {}) {
158
+ const ok = (stdout) => ({ code: 0, stdout: stdout.endsWith('\n') ? stdout : stdout + '\n', stderr: '' });
159
+ const fail = (stderr, code = 1) => ({ code, stdout: '', stderr: stderr.endsWith('\n') ? stderr : stderr + '\n' });
160
+ if (cmd === 'recipe') {
161
+ const md = readIf(path.join(root, RECIPES));
162
+ if (!md) return fail(`the recipes are missing from this package (${RECIPES})`);
163
+ const sections = parseRecipes(md);
164
+ const query = args.join(' ').trim();
165
+ if (!query) {
166
+ const lines = ['recipes (pixi-effects recipe <id> prints one with its code):'];
167
+ for (const s of sections) for (const id of s.ids) lines.push(` ${id.padEnd(28)} ${s.title}`);
168
+ return ok(lines.join('\n'));
169
+ }
170
+ const r = findRecipe(sections, query);
171
+ if (r.section) return ok(r.section.text);
172
+ if (r.many) return fail(`several recipes match "${query}": ${r.many.map(s => s.ids[0] ?? s.title).join(', ')}. Give one id (pixi-effects recipe lists them).`);
173
+ return fail(`no recipe "${query}"${r.nearest ? `; did you mean "${r.nearest}"?` : ''} (pixi-effects recipe lists them)`);
174
+ }
175
+ if (cmd === 'help') {
176
+ const word = args.join(' ').trim();
177
+ if (!word) {
178
+ return ok([
179
+ 'pixi-effects help <word>: the sections of the docs about a word. Try:',
180
+ ' layers text shape image video audio composition camera light shader null',
181
+ ' motion keyframes ease stagger wiggle spring speed time expression',
182
+ ' look mask filters blendMode gradient transition threeD depth of field',
183
+ ' sound sfx music audioEnvelope',
184
+ ' checking check inspect contactSheet render stops',
185
+ 'pixi-effects recipe lists the tested pieces to copy; pixi-effects check <page.html> reviews your work.',
186
+ ].join('\n'));
187
+ }
188
+ const hits = searchDocs(root, word);
189
+ if (!hits.length) return fail(`nothing in the docs about "${word}": try another word (pixi-effects help lists some), or pixi-effects recipe`);
190
+ return ok(formatHelp(hits, word, root).join('\n'));
191
+ }
192
+ if (cmd === 'new') {
193
+ const force = args.includes('--force');
194
+ const name = args.filter(a => a !== '--force')[0];
195
+ if (!name) return fail('give the page a name: pixi-effects new my-video [--force]', 2);
196
+ try {
197
+ const { file } = newPage(name, { cwd, root, force });
198
+ const f = shownPath(file, cwd);
199
+ return ok([
200
+ `created ${f} (the template: a 1280x720 page that collects every warning in window.__logs)`,
201
+ `next: npx pixi-effects view ${f} look at it as you edit (it says when the file changed)`,
202
+ ` npx pixi-effects check ${f} review it: warnings, layout, sound, a contact sheet, a real export`,
203
+ ' npx pixi-effects recipe tested pieces to copy · npx pixi-effects help <word>',
204
+ ].join('\n'));
205
+ } catch (e) { return fail(`pixi-effects new: ${e.message}`); }
206
+ }
207
+ return fail(`unknown command ${cmd}`, 2);
208
+ }
@@ -1,40 +1,45 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * pixi-effects — one command for the three tools, named like the package so `npx pixi-effects check my-video.html` works with nothing installed.
3
+ * pixi-effects — one command for the three tools (and three small lookups: new, recipe, help), named like the package so `npx pixi-effects check my-video.html` works with nothing installed.
4
4
  *
5
5
  * npx pixi-effects check my-video.html [--at 3,6] [--out dir] review it: warnings, layout, sound, a contact sheet, a real export
6
6
  * npx pixi-effects render my-video.html -o my-video.mp4 export the file
7
7
  * npx pixi-effects view my-video.html open it in a browser with a timeline
8
8
  *
9
9
  * Everything after the command goes to the tool unchanged (`check.mjs`, `render.mjs`, `view.mjs`; their own headers list every option), and so do the
10
- * exit code and the output. The three older bins (`pixi-effects-check|render|view`) still work.
10
+ * exit code and the output. It is the package's only bin (the older `pixi-effects-check|render|view` are gone).
11
11
  */
12
12
  import { spawn } from 'node:child_process';
13
13
  import { realpathSync } from 'node:fs';
14
14
  import { dirname, resolve } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
+ import { runLookup, LOOKUP_COMMANDS } from './lookup.mjs';
16
17
 
17
18
  const TOOLS = { check: 'check.mjs', render: 'render.mjs', view: 'view.mjs' };
18
19
  const USAGE = `usage:
19
20
  pixi-effects check <page.html> [--at 3,6] [--out dir] review a video page: warnings, layout, sound, contact sheet, real export
20
21
  pixi-effects render <page.html> -o <file.mp4> export the video
21
22
  pixi-effects view <page.html> look at it in a browser, with a timeline
23
+ pixi-effects new <name> a page to start from (<name>.html)
24
+ pixi-effects recipe [id] the tested recipes; with an id, one of them with its code
25
+ pixi-effects help <word> the docs about a word (mask, threeD, keyframes, music …)
22
26
  (every option of a command is listed in the header of its file: node_modules/pixi-effects/ai/tools/<command>.mjs)`;
23
27
 
24
28
  function nearest(word) {
25
29
  const d = (a, b) => { const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]); for (let j = 1; j <= b.length; j++) m[0][j] = j;
26
30
  for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); return m[a.length][b.length]; };
27
- const best = Object.keys(TOOLS).map(k => [k, d(word, k)]).sort((x, y) => x[1] - y[1])[0];
31
+ const best = [...Object.keys(TOOLS), ...LOOKUP_COMMANDS].map(k => [k, d(word, k)]).sort((x, y) => x[1] - y[1])[0];
28
32
  return best[1] <= 2 ? best[0] : null;
29
33
  }
30
34
 
31
35
  /** Which tool a command line asks for: `{ tool, args }`, or `{ usage }` (no command / --help), or `{ error }`. */
32
36
  export function route(argv) {
33
37
  const [cmd, ...args] = argv;
34
- if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') return { usage: USAGE };
38
+ if (!cmd || cmd === '--help' || cmd === '-h') return { usage: USAGE };
35
39
  if (TOOLS[cmd]) return { tool: TOOLS[cmd], args };
40
+ if (LOOKUP_COMMANDS.includes(cmd)) return { inline: cmd, args };
36
41
  const guess = /\.html?$/i.test(cmd) ? 'check' : nearest(cmd);
37
- return { error: `pixi-effects: unknown command "${cmd}"${guess ? `; did you mean "${guess}"?` : ''} The commands are: ${Object.keys(TOOLS).join(', ')}.\n${USAGE}` };
42
+ return { error: `pixi-effects: unknown command "${cmd}"${guess ? `; did you mean "${guess}"?` : ''} The commands are: ${[...Object.keys(TOOLS), ...LOOKUP_COMMANDS].join(', ')}.\n${USAGE}` };
38
43
  }
39
44
 
40
45
  const self = fileURLToPath(import.meta.url);
@@ -42,6 +47,12 @@ if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(s
42
47
  const r = route(process.argv.slice(2));
43
48
  if (r.usage) { console.log(r.usage); process.exit(process.argv.length > 2 ? 0 : 2); }
44
49
  if (r.error) { console.error(r.error); process.exit(2); }
50
+ if (r.inline) {
51
+ const out = runLookup(r.inline, r.args);
52
+ if (out.stdout) process.stdout.write(out.stdout);
53
+ if (out.stderr) process.stderr.write(out.stderr);
54
+ process.exit(out.code);
55
+ }
45
56
  const child = spawn(process.execPath, [resolve(dirname(self), r.tool), ...r.args], { stdio: 'inherit' });
46
57
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => child.kill(sig)); // a harness that stops this process stops the tool (and its Chrome) too
47
58
  child.on('exit', (code, signal) => process.exit(code ?? (signal ? 128 : 1)));
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * It starts a private static server and a private headless Chrome, opens the page, waits for `window.__ready === true`
8
8
  * (the contract of skills/pixi-effects/template.html), runs `movie.render()` in the page and streams the file to disk. Nothing is decoded or
9
- * reviewed: that is `pixi-effects-check`'s job. Use this one to produce the file (a script, CI, a batch).
9
+ * reviewed: that is `pixi-effects check`'s job. Use this one to produce the file (a script, CI, a batch).
10
10
  *
11
11
  * Needs: Node >= 22 (built-in WebSocket), Chrome / Chromium installed (or CHROME=PATH / --chrome PATH). No npm dependencies.
12
12
  * Exit code: 0 = the file was written, 1 = the page or the render failed (no file), 2 = bad usage.
@@ -17,7 +17,7 @@
17
17
  * --draft (for looking: half size, low quality, no motion blur; a --quality you give still wins) ·
18
18
  * --motion-blur SAMPLES (2-64: each frame is drawn that many times over the shutter and averaged; overrides the page's own motionBlur) ·
19
19
  * --shutter FRACTION (with --motion-blur: how long the shutter is open, 0-1 of a frame, default 0.5) ·
20
- * --video-codec C (avc hevc vp9 av1 vp8) · --video-bitrate B (a quality or bits a second: 8M, 800k) · --audio-codec C · --query "a=1&b=2" (added to the page URL) · --fail-on-warn (exit 1 when the page
20
+ * --video-codec C (avc hevc vp9 av1 vp8) · --video-bitrate B (a quality or bits a second: 8M, 800k) · --audio-codec C · --query "a=1&b=2" (added to the page URL) · --strict (exit 1 when the page
21
21
  * logged a warning; the file is still written) · --quiet (no progress line) · --timeout SECONDS (default 900) ·
22
22
  * --root DIR (static server root; default: the nearest folder above the page with dist/) · --chrome PATH
23
23
  */
@@ -25,7 +25,7 @@ import fs from 'node:fs';
25
25
  import os from 'node:os';
26
26
  import path from 'node:path';
27
27
  import { pathToFileURL } from 'node:url';
28
- import { findChrome, findRoot, serve, launchChrome, shown, sleep } from './check.mjs';
28
+ import { findChrome, findRoot, serve, launchChrome, shown, sleep, LOAD_PRESENT } from './check.mjs';
29
29
 
30
30
  const FORMATS = ['mp4', 'webm', 'mov', 'mkv', 'wav', 'ogg', 'pdf'];
31
31
  const SOUND_ONLY = ['wav', 'ogg'];
@@ -43,12 +43,15 @@ export function defaultOutput(page, format) {
43
43
  return path.resolve(path.basename(page).replace(/\.[^.]+$/, '') + '.' + format);
44
44
  }
45
45
 
46
+ 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] [--strict] [--quiet] [--timeout S] [--root DIR] [--chrome PATH]';
47
+
46
48
  export function parseRenderArgs(argv) {
47
- 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, failOnWarn: false, quiet: false, timeout: 900, root: null, chrome: null };
49
+ 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, strict: false, quiet: false, timeout: 900, root: null, chrome: null };
48
50
  const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
49
51
  for (let i = 0; i < argv.length; i++) {
50
52
  const a = argv[i];
51
- if (a === '-o' || a === '--out') o.out = need(i++, a);
53
+ if (a === '--help' || a === '-h') o.help = true;
54
+ else if (a === '-o' || a === '--out') o.out = need(i++, a);
52
55
  else if (a === '--format') {
53
56
  o.format = need(i++, a).toLowerCase();
54
57
  if (!FORMATS.includes(o.format)) throw new Error(`--format must be mp4, webm, mov, mkv, wav, ogg or pdf (got "${o.format}")`);
@@ -79,7 +82,8 @@ export function parseRenderArgs(argv) {
79
82
  } else if (a === '--draft') o.draft = true;
80
83
  else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
81
84
  else if (a === '--all-stops') o.allStops = true;
82
- else if (a === '--fail-on-warn') o.failOnWarn = true;
85
+ else if (a === '--strict') o.strict = true;
86
+ else if (a === '--fail-on-warn') throw new Error('--fail-on-warn is now --strict (the same word as in check: a warning, or anything it would only list, fails the run)');
83
87
  else if (a === '--quiet') o.quiet = true;
84
88
  else if (a === '--timeout') o.timeout = Math.max(10, Number(need(i++, a)) || 900);
85
89
  else if (a === '--root') o.root = need(i++, a);
@@ -109,7 +113,7 @@ const renderScript = (format, quality, videoCodec, audioCodec, motionBlur, shutt
109
113
  const video = { ${[videoBitrate ? `bitrate: ${JSON.stringify(/^\d+(\.\d+)?$/.test(videoBitrate) ? Number(videoBitrate) : videoBitrate)}` : part.draft && !part.qualityGiven ? null : `bitrate: ${JSON.stringify(quality)}`, videoCodec ? `codec: ${JSON.stringify(videoCodec)}` : null].filter(Boolean).join(', ')} };
110
114
  const audio = { bitrate: ${JSON.stringify(quality)}${audioCodec ? `, codec: ${JSON.stringify(audioCodec)}` : ''} };
111
115
  const blob = ${JSON.stringify(format)} === 'pdf'
112
- ? await movie.exportPDF({ which: ${allStops ? "'stops'" : "'pages'"}, title: document.title })
116
+ ? await (${LOAD_PRESENT}).catch(e => { throw new Error('a PDF needs pixi-effects/present in the page (add "pixi-effects/present": ".../dist/present.js" to its import map, from the same version as the library): ' + ((e && e.message) || e)); }).then(p => p.exportPDF(movie, { which: ${allStops ? "'stops'" : "'pages'"}, title: document.title }))
113
117
  : await movie.render({ format: ${JSON.stringify(format)}, ${SOUND_ONLY.includes(format) ? '' : 'video, '}audio${part.range ? `, range: [${part.range[0] ?? 0}, ${part.range[1] ?? 'movie.duration'}]` : ''}${part.scene ? `, range: ${JSON.stringify(part.scene)}` : ''}${part.scale ? `, scale: ${part.scale}` : ''}${part.draft ? ', draft: true' : ''}${motionBlur ? `, motionBlur: { samples: ${motionBlur}${shutter ? `, shutter: ${shutter}` : ''} }` : ''} });
114
118
  const res = await fetch(${JSON.stringify(UPLOAD)}, { method: 'PUT', body: blob });
115
119
  if (!res.ok) throw new Error('could not hand the file to the command (' + res.status + ')');
@@ -209,8 +213,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process
209
213
  try {
210
214
  if (typeof WebSocket === 'undefined') throw new Error('Node >= 22 is needed (built-in WebSocket)');
211
215
  const opts = parseRenderArgs(process.argv.slice(2));
216
+ if (opts.help) { console.log(USAGE); process.exit(0); }
212
217
  if (!opts.page) {
213
- console.error('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] [--fail-on-warn] [--quiet] [--timeout S] [--root DIR] [--chrome PATH]');
218
+ console.error(USAGE);
214
219
  process.exit(2);
215
220
  }
216
221
  let last = -1;
@@ -227,7 +232,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process
227
232
  process.exit(1);
228
233
  }
229
234
  console.log(`${shown(r.file)} ${kb(r.bytes)} · ${r.duration} s · ${SOUND_ONLY.includes(opts.format) ? 'sound only' : `${r.width}×${r.height} @ ${r.frameRate} fps`} · ${opts.format} · rendered in ${r.seconds} s`);
230
- process.exit(opts.failOnWarn && r.logs.length ? 1 : 0);
235
+ process.exit(opts.strict && r.logs.length ? 1 : 0);
231
236
  } catch (e) {
232
237
  console.error(`pixi-effects render failed: ${e.message}`);
233
238
  process.exit(2);
package/ai/tools/view.mjs CHANGED
@@ -26,12 +26,15 @@ import { findRoot, serve } from './check.mjs';
26
26
 
27
27
  export const VIEWER = '/__viewer';
28
28
 
29
+ export const USAGE = 'usage: node ai/tools/view.mjs <page.html> [--port N] [--no-open] [--query a=1] [--root DIR]';
30
+
29
31
  export function parseViewArgs(argv) {
30
32
  const o = { page: null, port: 0, open: true, query: null, root: null };
31
33
  const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
32
34
  for (let i = 0; i < argv.length; i++) {
33
35
  const a = argv[i];
34
- if (a === '--port') o.port = Math.max(0, Math.round(Number(need(i++, a))) || 0);
36
+ if (a === '--help' || a === '-h') o.help = true;
37
+ else if (a === '--port') o.port = Math.max(0, Math.round(Number(need(i++, a))) || 0);
35
38
  else if (a === '--no-open') o.open = false;
36
39
  else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
37
40
  else if (a === '--root') o.root = need(i++, a);
@@ -343,8 +346,9 @@ export async function startViewer(opts) {
343
346
  if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) { // the real path: npm starts a bin through a symlink
344
347
  try {
345
348
  const opts = parseViewArgs(process.argv.slice(2));
349
+ if (opts.help) { console.log(USAGE); process.exit(0); }
346
350
  if (!opts.page) {
347
- console.error('usage: node ai/tools/view.mjs <page.html> [--port N] [--no-open] [--query a=1] [--root DIR]');
351
+ console.error(USAGE);
348
352
  process.exit(2);
349
353
  }
350
354
  const { url } = await startViewer(opts);