pixi-effects 0.5.0 → 0.7.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 (54) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +17 -10
  3. package/ai/SKILL.md +1 -1
  4. package/ai/reference/cheatsheet.md +8 -2
  5. package/ai/reference/pitfalls.md +1 -0
  6. package/ai/reference/recipes.md +36 -1
  7. package/ai/template.html +4 -3
  8. package/ai/tools/check.mjs +14 -5
  9. package/ai/tools/render.mjs +202 -0
  10. package/ai/tools/view.mjs +269 -0
  11. package/dist/{Base-DnRAxroQ.d.cts → Base-CSIulyIq.d.cts} +1 -1
  12. package/dist/{Base-CPSFBkPm.d.ts → Base-DVgwyIUj.d.ts} +1 -1
  13. package/dist/Composition-3KHQPRBA.cjs +14 -0
  14. package/dist/{Composition-63EXHNJJ.cjs.map → Composition-3KHQPRBA.cjs.map} +1 -1
  15. package/dist/Composition-DT657FO5.js +5 -0
  16. package/dist/{Composition-TUCRSMJU.js.map → Composition-DT657FO5.js.map} +1 -1
  17. package/dist/Controller.cjs +77 -2
  18. package/dist/Controller.cjs.map +1 -1
  19. package/dist/Controller.d.cts +16 -2
  20. package/dist/Controller.d.ts +16 -2
  21. package/dist/Controller.js +77 -2
  22. package/dist/Controller.js.map +1 -1
  23. package/dist/{Movie-CuU_aaCm.d.cts → Movie-B0OUKlZI.d.ts} +54 -2
  24. package/dist/{Movie-VyDiUYOj.d.ts → Movie-BK9daiTt.d.cts} +54 -2
  25. package/dist/{chunk-5Q5XZG7Z.js → chunk-36Z4RIRK.js} +3 -3
  26. package/dist/{chunk-5Q5XZG7Z.js.map → chunk-36Z4RIRK.js.map} +1 -1
  27. package/dist/{chunk-UBPZLGLU.js → chunk-4O2NYDJG.js} +365 -44
  28. package/dist/chunk-4O2NYDJG.js.map +1 -0
  29. package/dist/{chunk-23M5QTWW.cjs → chunk-4VSIJLU3.cjs} +21 -21
  30. package/dist/{chunk-23M5QTWW.cjs.map → chunk-4VSIJLU3.cjs.map} +1 -1
  31. package/dist/{chunk-2RR43OIN.cjs → chunk-6I55T4OH.cjs} +403 -62
  32. package/dist/chunk-6I55T4OH.cjs.map +1 -0
  33. package/dist/index.cjs +234 -24
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +4 -4
  36. package/dist/index.d.ts +4 -4
  37. package/dist/index.js +226 -16
  38. package/dist/index.js.map +1 -1
  39. package/dist/three.cjs +6 -5
  40. package/dist/three.cjs.map +1 -1
  41. package/dist/three.d.cts +2 -2
  42. package/dist/three.d.ts +2 -2
  43. package/dist/three.js +2 -1
  44. package/dist/three.js.map +1 -1
  45. package/dist/{types-7VtTEKUW.d.ts → types-oJijbn-K.d.cts} +36 -7
  46. package/dist/{types-7VtTEKUW.d.cts → types-oJijbn-K.d.ts} +36 -7
  47. package/docs/api.md +7 -0
  48. package/docs/dsl.md +74 -8
  49. package/llms-full.txt +127 -12
  50. package/package.json +7 -4
  51. package/dist/Composition-63EXHNJJ.cjs +0 -13
  52. package/dist/Composition-TUCRSMJU.js +0 -4
  53. package/dist/chunk-2RR43OIN.cjs.map +0 -1
  54. package/dist/chunk-UBPZLGLU.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ **Fixed**
6
+
7
+ - The player (`Controller`) wrapped a canvas sized in percent (`canvas { width: min(960px, 100%) }`, the usual page CSS) in a wrapper as wide as the canvas's width ATTRIBUTE (1280): in a window wider than that the picture sat at the left edge with an empty strip beside it, and the bar was wider than the picture. The wrapper is now fitted to the canvas (measured as the page styled it, re-measured on resize and after fullscreen). Checked in a real browser at 1600, 700 and 1600 px.
8
+
9
+ **Added**
10
+
11
+ - `pixi-effects-view` (`node ai/tools/view.mjs page.html`): opens your browser on the page with its timeline under it and a playhead that follows the movie. Click or drag the timeline to seek, click a layer's name to jump to its start, Space plays / pauses, ← / → step a frame (Shift: a second), + / − or Ctrl/⌘ + wheel zoom (fit = the whole movie, no sideways scrolling; at most about 12 px per frame; the names stay in a fixed column; the view follows the playhead). The page is shown whole above the timeline, scaled down to leave room for it. `movie.timelineSvg({ chartWidth, labels })` is the chart alone, with its geometry in `data-*` attributes (the ticks get finer as it gets wider).
12
+ - The timeline as a picture: `movie.timelineChart()` returns one self-contained HTML page (an inline SVG): a bar per layer on a time axis, ◆ at each keyframe, a shaded band per transition, tooltips. `movie.timelineData()` is the same as data (absolute start / end / keyframe times). Layers of one type whose names differ only by numbers (four or more) are one row, so the 706-layer `countdown-newyear` is 28 rows. `pixi-effects-check` writes it as `timeline.html`.
13
+ - `pixi-effects-render` (`node ai/tools/render.mjs page.html -o out.mp4`): renders a composition page to a video file, headless, in one command. Its own private static server and Chrome; waits for `window.__ready`; the file is streamed to disk by an upload from the page (no base64 over DevTools, so long videos are fine); mp4 / webm / mov / mkv from the extension; `--quality`, `--query`, `--fail-on-warn`, `--quiet`; exit 0 = written, 1 = failed (no file), 2 = bad usage. Rendered a 12 s gallery piece with audio in 11 s (h264 + aac).
14
+ - Filters by name, as plain data: `filters: [{ type: 'glow', name: 'halo', outerStrength: 3 }]` instead of `{ type: 'custom', filter: new GlowFilter(…) }`. The type is the class name without `Filter` in camelCase: `blur noise alpha colorMatrix` (pixi.js; `colorMatrix` has presets `sepia grayscale negative polaroid technicolor vintage kodachrome browni`) and the 38 filters of `pixi-filters` (`glow dropShadow outline pixelate crt rgbSplit oldFilm glitch twist …`). `pixi-filters` is an optional peer dependency, loaded on first use (an import-map entry or `npm i pixi-filters`; `ai/template.html` has it). A misspelt type says what you probably meant. 24 of them were rendered and exported in a real browser (`examples/_checks/named-filters.html`); the gotchas that showed up (centres in canvas pixels, margins filled black, pixelate's `sizeX`) are in `docs/dsl.md` and the cheatsheet.
15
+
16
+ ## 0.6.0
17
+
18
+ **Added**
19
+
20
+ - `examples/gallery/teatime.html` (33rd piece): transparent images. Four museum teapots cut out of their studio backgrounds are saved as lossless-alpha WebP (64–94 KB each; 650–700 KB as PNG) and stand in front of a giant word, on feathered RGBA PNG shadows (`examples/_assets/cutouts/`, licences in `examples/_assets/LICENSES.md`).
21
+ - `examples/gallery/still-water.html` (32nd piece): the first piece built on real photographs (three public-domain lake photos in `examples/_assets/photos/`, licences in `examples/_assets/LICENSES.md`): Ken Burns, a dip and a crossfade, a viewfinder drawn on with `trimEnd`, a caption re-typed with `set: { text }` and `visibleChars`.
22
+ - `examples/gallery/blueprint-house.html` (31st piece): a blueprint that draws itself with `trimEnd`, `visibleChars` and `set: { text }`.
23
+ - Draw-on strokes: `trimStart` / `trimEnd` (0–1 of the outline, animatable) on `line`, `polygon`, `path`, `rect`, `circle` and `ellipse`. `trimEnd: 0` plus a keyframe `to: { trimEnd: 1 }` draws it on; `trimStart` wipes it off; both give a travelling dash. The fill is not trimmed. A rect starts top-left and a circle at 12 o'clock, both clockwise; a polygon counts its closing edge; an SVG path's curves are followed. Not for `arc` (it warns: animate `endAngle`). `examples/14-draw-on.html`.
24
+ - Text that changes over time: a keyframe `set: { text: 'Two' }` swaps the string at that time (undone on seek back), and `visibleChars` (a number, the first N characters) is a typewriter. `to` / `from` with `text` warn: a string cannot be tweened. The `{value}` lint knows about swapped strings.
25
+ - `movie.on('play', fn)`: emitted when `play()` starts a paused movie.
26
+
27
+ **Fixed**
28
+
29
+ - The player bar showed the ▶ icon while a host page's `movie.play()` was running (it only updated for its own button): it follows the movie's `'play'` / `'pause'` events now.
30
+ - `movie.play()` before the browser allows sound (an AudioContext stays `suspended` until a user gesture): the picture ran on silently and the audio was scheduled at a stale position. It now starts the sound when the context runs (it asks to resume again on the first click / key press), from the position the movie has reached by then.
31
+ - Gallery: a deep link or a reload opened the piece at its poster frame (e.g. 0:09). Every piece now opens at 0:00; a card click plays at once, a deep link keeps the poster with a play button (a browser would block the sound without a click).
32
+
3
33
  ## 0.5.0
4
34
 
5
35
  **Added**
package/README.md CHANGED
@@ -1,20 +1,23 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.5.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `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.7.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `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
- **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 30 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
+ **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 33 portfolio pieces written as plain data by AI models · **[Live demos →](https://yjmtmtk.github.io/pixi-effects/)** · 12 numbered examples + an in-browser playground.
6
6
 
7
7
  Declarative composition and video rendering for the web. After Effects-style timelines on top of [PixiJS v8](https://pixijs.com/) and [GSAP](https://gsap.com/), with strict TypeScript types. Render to MP4 / WebM / MOV via [mediabunny](https://mediabunny.dev/).
8
8
 
9
9
  - **Declarative DSL** — describe your composition as a tree of typed sequences (text, image, video, audio, shapes, nested compositions). No imperative tween code.
10
10
  - **Sound effects without files** — `{ type: 'audio', sfx: 'swoosh', at: 2 }`: 11 synthesised presets (click, pop, swoosh, riser, hit, chime …) with `pitch` / `brightness` / `seed`, or your own `voices`; deterministic and in the exported file at the same moments as in the browser; `movie.inspectAudio()` checks them as numbers.
11
11
  - **Expression language** — sprinkle `'GW * 0.5'` or `'min(W, H) / 2'` anywhere a number goes. Resolved at runtime against a sequence-relative scope.
12
- - **Filters, masks, transitions** — chroma key, blur, color matrix (or any PixiJS filter); inline masks; seven scene transitions (`crossfade`, `wipe`, `iris`, `slide`, `dip`, `zoom`, `dissolve`).
12
+ - **Filters by name** — `filters: [{ type: 'glow', outerStrength: 3 }]`, no import: `blur noise alpha colorMatrix` (pixi.js) and the 38 filters of [`pixi-filters`](https://github.com/pixijs/filters) (`dropShadow crt rgbSplit oldFilm glitch pixelate twist …`), animated with `'filters.<name>.<option>'`. Any other PixiJS `Filter` goes in as `{ type: 'custom' }`.
13
+ - **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.
14
+ - **Masks, transitions, chroma key** — inline masks; chroma key; seven scene transitions (`crossfade`, `wipe`, `iris`, `slide`, `dip`, `zoom`, `dissolve`).
13
15
  - **Presets** — `kenBurns` for stills, `withFade` for fade-in/out.
14
16
  - **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.
15
17
  - **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`).
16
18
  - **Built-in player UI** — drop-in HTML5-`<video>`-style overlay controller (play, scrub, mute, volume, fullscreen, export-to-file).
17
- - **MP4 / WebM / MOV export** — pick container and quality from the controller, or call `movie.render()` from code.
19
+ - **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`).
20
+ - **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.
18
21
  - **Tiny dependency surface** — only `mediabunny` (runtime) plus PixiJS and GSAP (peer deps). three.js is an *optional* peer, needed only if you import `pixi-effects/three`.
19
22
 
20
23
  ## Install
@@ -22,6 +25,7 @@ Declarative composition and video rendering for the web. After Effects-style tim
22
25
  ```bash
23
26
  npm install pixi-effects pixi.js gsap
24
27
  npm install three # optional — only if you import `pixi-effects/three`
28
+ npm install pixi-filters # optional — only if a layer uses a named filter other than blur / noise / alpha / colorMatrix
25
29
  ```
26
30
 
27
31
  ### Browser via CDN (no bundler)
@@ -36,8 +40,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
36
40
  "gsap": "https://esm.sh/gsap@3.12.5",
37
41
  "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
38
42
  "mediabunny": "https://esm.sh/mediabunny",
39
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/index.js",
40
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/Controller.js"
43
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.7.0/dist/index.js",
44
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.7.0/dist/Controller.js"
41
45
  }
42
46
  }
43
47
  </script>
@@ -48,7 +52,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
48
52
  </script>
49
53
  ```
50
54
 
51
- Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.5.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.
55
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.7.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.
52
56
 
53
57
  > **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
54
58
  >
@@ -64,7 +68,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
64
68
  "imports": {
65
69
  "...": "(everything from the importmap above)",
66
70
  "three": "https://esm.sh/three@0.178.0",
67
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/three.js"
71
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.7.0/dist/three.js"
68
72
  }
69
73
  }
70
74
  </script>
@@ -161,7 +165,9 @@ This library is designed to be written by AI: a video is plain data, and every m
161
165
  - [`ai/SKILL.md`](./ai/SKILL.md) — a [skill](https://docs.claude.com/en/docs/claude-code/skills) (workflow, rules, verification loop). Copy the `ai/` folder to `~/.claude/skills/pixi-effects/` (or your project's `.claude/skills/`) to have Claude load it automatically when you ask for a video.
162
166
  - [`ai/reference/cheatsheet.md`](./ai/reference/cheatsheet.md), [`recipes.md`](./ai/reference/recipes.md) (tested), [`pitfalls.md`](./ai/reference/pitfalls.md), and a starter [`ai/template.html`](./ai/template.html).
163
167
 
164
- - **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 to look at. Exit code 0 / 1.
168
+ - **Look at a page with its timeline, and scrub one with the other:** `npx pixi-effects-view my-video.html` (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.
169
+ - **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.
170
+ - **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.
165
171
 
166
172
  They ship in the npm package (`node_modules/pixi-effects/ai/`).
167
173
 
@@ -175,7 +181,7 @@ They ship in the npm package (`node_modules/pixi-effects/ai/`).
175
181
  - `03-shapes.html` — every shape primitive
176
182
  - `04-media.html` — image / video / audio
177
183
  - `05-composition-mask.html` — nested compositions and masks
178
- - `06-filters.html` — built-in and pixi-filters via `{ type: 'custom' }`
184
+ - `06-filters.html` — built-in and pixi-filters (as `{ type: 'custom' }`; most can now be written by name, see `docs/dsl.md` → Named filters)
179
185
  - `07-transitions.html` — all seven transition kinds in one timeline
180
186
  - `08-presets-export.html` — `kenBurns` preset and `movie.render()` from code
181
187
  - `09-audio.html` — multi-track audio mixing with BGM ducking and SFX cues
@@ -183,6 +189,7 @@ They ship in the npm package (`node_modules/pixi-effects/ai/`).
183
189
  - `11-depth.html` — 2.5D layers and camera: parallax, spin, dolly zoom
184
190
  - `12-title-motion.html` — a staggered 2.5D title: letters fly in from depth, particles, orbiting camera
185
191
  - `13-sfx.html` — synthesised sound effects (no audio files), a riser into a hit, a custom voice
192
+ - `14-draw-on.html` — draw-on strokes (`trimStart` / `trimEnd`) and text that changes over time (`visibleChars`, `set: { text }`)
186
193
  - `playground.html` — in-browser editor with preset dropdown
187
194
 
188
195
  ## Browser support
package/ai/SKILL.md CHANGED
@@ -11,7 +11,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
11
11
 
12
12
  1. **Start from `template.html`** (copy it; it loads the library from a CDN, collects every console warning into `window.__logs`, and exposes `window.movie`). One HTML file, plain JS. Default canvas 1280×720 @ 30 fps.
13
13
  2. **Write the composition as data.** Anything repeated (letters, bars, particles, scenes) is a JS function or loop that returns specs — never copy-paste objects. Derive coordinates and times from constants/data, not magic numbers.
14
- 3. **Run the check — one command does steps 3, 4 and the export:** `node ai/tools/check.mjs my-video.html` (Node ≥ 22 and Chrome installed; no npm install; in an installed package it is `npx pixi-effects-check my-video.html`). It starts its own private Chrome (nothing is shared with other sessions), waits for `window.__ready`, and prints: console warnings, `movie.inspect` over the WHOLE timeline (grouped), `movie.inspectAudio()`, then exports an mp4 and decodes it again (video size and length, audio length, loudness per second, silence). It writes `check-out/<name>/sheet.png` — **open that picture and look at it** — plus the mp4 and `report.json`. Exit 0 = nothing to fix; exit 1 = a list of problems; fix them and run it again. Text that overlaps text is listed under `review` (not a failure: ghost layers, glow copies and per-letter boxes are often intentional; look at the sheet; `--strict` makes them fail). The manual steps below are for looking at one frame closely.
14
+ 3. **Show the human:** `node ai/tools/view.mjs my-video.html` opens their browser on the page with a scrubbable, zoomable timeline under it (click / drag to seek, + / − or Ctrl/⌘ + wheel to zoom) (`npx pixi-effects-view …` in an installed package); it keeps running until Ctrl-C. **Deliver the file:** `node ai/tools/render.mjs my-video.html -o my-video.mp4` (`npx pixi-effects-render …` in an installed package) renders the page headless to mp4 / webm / mov / mkv and exits 0 only when the file is written; run it AFTER the check below passes. **Run the check — one command does steps 3, 4 and the export:** `node ai/tools/check.mjs my-video.html` (Node ≥ 22 and Chrome installed; no npm install; in an installed package it is `npx pixi-effects-check my-video.html`). It starts its own private Chrome (nothing is shared with other sessions), waits for `window.__ready`, and writes `timeline.html` (every layer as a bar on a time axis: ◆ keyframes, shaded transitions; hand it to the human to open) and prints: console warnings, `movie.inspect` over the WHOLE timeline (grouped), `movie.inspectAudio()`, then exports an mp4 and decodes it again (video size and length, audio length, loudness per second, silence). It writes `check-out/<name>/sheet.png` — **open that picture and look at it** — plus the mp4 and `report.json`. Exit 0 = nothing to fix; exit 1 = a list of problems; fix them and run it again. Text that overlaps text is listed under `review` (not a failure: ghost layers, glow copies and per-letter boxes are often intentional; look at the sheet; `--strict` makes them fail). The manual steps below are for looking at one frame closely.
15
15
  **Or by hand: run it and read the logs first.** `window.__ready` must be `true` and `window.__logs` should be `[]`. A pixi-effects warning is an instruction: fix what it names.
16
16
  4. **Look at it** — you cannot judge motion from code. The library has the tools built in:
17
17
  - `await movie.contactSheet({ count: 6, as: 'dataURL' })` → ONE image of several labelled frames (pass `frames: [...]` / `times: [...]` to choose; include the middle of every transition and the last second). Save it and view it.
@@ -35,7 +35,7 @@ Operators `+ - * /`, parentheses, unary `-`. Functions `min max abs floor ceil r
35
35
  name, at, duration,
36
36
  initial: { ...props applied before any keyframe },
37
37
  keyframes: [ { at, duration, ease, set | to | from | (from + to), repeat?, yoyo?, repeatDelay? } ],
38
- filters: [ { type: 'chromaKey', keyColor, threshold, smoothing, spill } | { type: 'custom', name, filter: <Pixi Filter> } ],
38
+ filters: [ { type: 'chromaKey', keyColor, threshold, smoothing, spill } | { type: 'glow', name?, outerStrength: 3, color: '#ffd166' } /* any named filter, see below */ | { type: 'custom', name, filter: <Pixi Filter> } ],
39
39
  mask: <a layer spec>, maskInverted, // a mask with no `at` of its own starts and ends with the layer it masks (its keyframes count from that layer's start); an explicit mask `at` is composition time
40
40
  filterArea: { x, y, width, height } // in the layer's OWN coordinates; lets blur/glow draw past the layer's bounds
41
41
  threeD: true // opt into 2.5D (see below)
@@ -48,7 +48,7 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
48
48
 
49
49
  ## Layer types
50
50
 
51
- **text** — `text`, `style` (any PixiJS TextStyle field: `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding`; expressions OK for numbers), `colorSpace`. Default anchor is **top-left**; use `anchorX/anchorY: 0.5` to centre on `x,y`. A text layer has one animatable number, `value`, printed where the text contains `{value}` (`text: '{value} users'`, `initial: { value: 0 }`, keyframe `to: { value: 2480 }`; `format: { decimals, grouping, pad /* zero-pad the whole part: pad 2 → 05 */ }`) — that is how counters work. Other content cannot change over time. Multi-line text is centre-aligned by default (`style.align: 'left'` to change).
51
+ **text** — `text`, `style` (any PixiJS TextStyle field: `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding`; expressions OK for numbers), `colorSpace`. Default anchor is **top-left**; use `anchorX/anchorY: 0.5` to centre on `x,y`. A text layer has one animatable number, `value`, printed where the text contains `{value}` (`text: '{value} users'`, `initial: { value: 0 }`, keyframe `to: { value: 2480 }`; `format: { decimals, grouping, pad /* zero-pad the whole part: pad 2 → 05 */ }`) — that is how counters work. **Changing text:** a keyframe `set: { text: 'Two' }` swaps the string at that time (undone on seek back; `to` / `from` cannot tween a string); `visibleChars` (number, first N characters; starts at 0 once animated) is a typewriter: `to: { visibleChars: 30 }, duration: 2.4, ease: 'none'` — use `anchorX: 0` + `style.align: 'left'` so it grows rightward. Multi-line text is centre-aligned by default (`style.align: 'left'` to change).
52
52
 
53
53
  **image** — `asset`, `tint`, `colorSpace: 'rgb'|'oklab'|'oklch'`. Default anchor top-left; natural size = `w`,`h`.
54
54
 
@@ -69,6 +69,8 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
69
69
  | polygon | `points: [[x,y],…] open` — canvas coordinates like `line` |
70
70
  | path | `d` (SVG path data) — canvas coordinates like `line` |
71
71
 
72
+ **Draw-on:** every shape but `arc` has `trimStart` / `trimEnd` (0–1 of the outline, default 0 / 1, animatable): `trimEnd: 0` + keyframe `to: { trimEnd: 1 }` draws a line / border / path on; `trimStart` 0 → 1 after it wipes it off; both = a travelling dash. The fill is NOT trimmed. Rect outline starts top-left, circle / ellipse at 12 o'clock, both clockwise. Use `strokeCap: 'round'`.
73
+
72
74
  Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha strokeWidth`; `colorSpace: 'oklab'|'oklch'` for clean colour tweens. rect/circle/ellipse are **centred on `x,y` by default** (`anchorX/anchorY` default 0.5): for a bar growing from its base use `anchorY: 1` (or `anchorX: 0` for left-to-right). **`fillGradient`** (top level or `initial`; instead of `fillColor`): `{ type?: 'linear'|'radial', stops: [[0, '#000'], [1, 'rgba(0,0,0,.6)']], angle? /* linear, deg, 90 = top→bottom */, center?, innerRadius?, radius? /* radial, 0–1 of bounds */ }` — stops may have alpha, so a radial transparent→dark is a vignette. Stroke ends and corners: `strokeCap: 'butt'|'round'|'square'`, `strokeJoin: 'miter'|'round'|'bevel'` (top level or in `initial`; not animated — `round` keeps sharp corners like an M from poking out).
73
75
 
74
76
  **composition** — `width height duration sequences transitions`. With `threeD: true` it is a **card**: its children are drawn into one texture (content outside its `width × height` is clipped: size it for a soft shadow too; a plain 2D composition does not clip, but is skipped when its whole rectangle is off screen) and move / rotate in depth together. Children use the composition's local coordinates and times. Position/rotate/scale it as a unit; to rotate/scale about its centre set `pivotX/pivotY` to the centre and `x/y` to where that point should sit.
@@ -81,6 +83,10 @@ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha stro
81
83
 
82
84
  Add `threeD: true` to any visual layer, then use `z`, `rotationX`, `rotationY` (centre rotation with `anchorX/Y: 0.5` or `pivotX/Y`). Add a `{ type: 'camera' }` layer for a view. A `threeD` layer at `z: 0` with the default camera looks identical to a 2D one. Consecutive `threeD` layers are drawn farthest-first (equal `z` keeps array order); non-`threeD` layers ignore the camera and keep array order. Layers at/behind the camera plane are hidden. Not supported on `threeD` layers: masks, transitions, `filterArea`.
83
85
 
86
+ ## Filters by name (no import)
87
+
88
+ `filters: [{ type: 'glow', name: 'halo', outerStrength: 3, color: '#ffd166' }]` — `type` = the pixi / pixi-filters class name without `Filter`, camelCase (`glow dropShadow outline blur noise alpha adjustment hslAdjustment grayscale pixelate crt rgbSplit oldFilm glitch bulgePinch twist zoomBlur kawaseBlur radialBlur motionBlur emboss ascii colorOverlay advancedBloom bevel dot crossHatch tiltShift …`), the other keys are its options. `colorMatrix` takes `preset: 'sepia'|'grayscale'|'negative'|'polaroid'|'technicolor'|'vintage'|'kodachrome'|'browni'`. Name it to animate: `'filters.halo.outerStrength'` (scalars only; `pixelate` via `sizeX`/`sizeY`). Options: `glow` distance outerStrength innerStrength color alpha · `dropShadow` offset{x,y} blur alpha color · `outline` thickness color · `blur` strength · `adjustment` gamma contrast saturation brightness · `hslAdjustment` hue lightness saturation · `pixelate` size · `crt` curvature lineWidth noise vignetting · `rgbSplit` red/green/blue {x,y} · `oldFilm` sepia noise scratch vignetting · `glitch` slices offset · `twist` radius angle offset · `bulgePinch` center radius strength · `zoomBlur` strength center innerRadius · `colorOverlay` color alpha. **Centres/offsets are CANVAS pixels** (default top-left corner): put them on the layer. Glow / shadow / blur draw outside the layer: widen `filterArea` (not for `grayscale` / `oldFilm`, which fill the margin black). Full table: `docs/dsl.md` → Named filters.
89
+
84
90
  ## Transitions (on the parent composition)
85
91
 
86
92
  ```js
@@ -77,3 +77,4 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
77
77
  50. **An image (or video) works as a mask, `maskInverted` included** *(fixed)*: PIXI hides a sprite mask with `renderable = false` and the lifespan toggles used to switch it back on, so the mask picture was drawn over the layer (a white disc) and `maskInverted` looked ignored. Use the picture's alpha (or red channel) as the shape: an opaque word or logo on a transparent canvas image is a text-shaped mask with real glyph edges. A `text` layer also works as a mask (cut to its glyphs; measured with a heavy sans `I`). *(docs / fixed)*
78
78
  51. **Arcs and rings are `shape: 'arc'`** (`radius`, `innerRadius`, `startAngle`, `endAngle`, degrees; 0° = 3 o'clock, clockwise). A progress ring is an arc with `startAngle: -90` and an animated `endAngle` (−90 → 270); `strokeCap: 'round'` rounds BOTH ends. Fill it for a pie slice, add `innerRadius` for a donut slice (a 120-polygon donut is no longer needed). Style (`strokeColor`, `strokeWidth`, `fillColor`) goes in `initial`; `strokeCap` / `strokeJoin` may be top level. *(fixed)*
79
79
  52. **Measure and split text with `measureText` / `splitText`, not by hand.** `splitText(text, style, { by, x, y, align })` returns each piece's x / y / width in the renderer's own font metrics (kerning kept; verified: a whole line vs the same line in per-letter layers differs only by anti-aliasing). The pieces are for text layers with the default top-left anchor and the same `style`. Load a web font before measuring (`await document.fonts.load('24px "Name"')`) or the fallback font's widths are used. Size a pill from `measureText(...).width + padding` instead of `0.58 × fontSize × length`. *(fixed)*
80
+ 53. **Draw-on and typewriter need no masks or per-letter layers.** `trimStart` / `trimEnd` (0–1 of the outline, animatable, every shape but `arc`) draw a stroke on: `trimEnd: 0` plus a keyframe `to: { trimEnd: 1 }`. The fill is not trimmed, so fade `fillAlpha` in afterwards; use `strokeCap: 'round'`. Text changes over time with `set: { text }` (swap at a time) and `visibleChars` (typewriter; left-align it so it grows rightward). A `to: { text }` cannot tween a string and warns. *(fixed)*
@@ -252,7 +252,7 @@ return [
252
252
 
253
253
  ## Slideshow with transitions and background music
254
254
 
255
- Scene `i` starts at `i × (L − T)` and overlaps the next by the transition length `T`; the transition's `at` is the next scene's start; total = `n × (L − T) + T`. Transitions and `kenBurns` combine freely. Captions are top-level layers (transitions never touch layers they do not name); keep them above the bottom ~60 px. `bgm.mp3` is only 6 s: **`loop: true`** or it falls silent.
255
+ Scene `i` starts at `i × (L − T)` and overlaps the next by the transition length `T`; the transition's `at` is the next scene's start; total = `n × (L − T) + T`. Transitions and `kenBurns` combine freely. Captions are top-level layers (transitions never touch layers they do not name); keep them above the bottom ~60 px. A music file shorter than the movie (`bgm.mp3` is a 16 s loop) needs **`loop: true`** or it falls silent.
256
256
 
257
257
  ```js
258
258
  // @recipe slideshow
@@ -372,6 +372,41 @@ return [
372
372
 
373
373
  ---
374
374
 
375
+ ## Draw a line, border or check mark on, and type text out
376
+
377
+ `trimEnd` 0 → 1 strokes a shape's outline on (`trimStart` 0 → 1 afterwards wipes it off); the fill is NOT trimmed, so fade `fillAlpha` in once the outline is done. A rect's outline starts top-left and goes clockwise, a circle's at 12 o'clock. `strokeCap: 'round'` rounds the ends. For text, `visibleChars` types it out and `set: { text }` swaps the string at a time.
378
+
379
+ ```js
380
+ // @recipe draw-on-and-type
381
+ const ACCENT = '#ffd166', TEAL = '#4cc9f0', GREEN = '#7bd88f';
382
+ const drawOn = (at, duration = 1.2) => ({ at, to: { trimEnd: 1 }, duration, ease: 'power2.inOut' });
383
+ return [
384
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0a0e1a' } },
385
+ // an underline that draws itself
386
+ { type: 'shape', shape: 'line', from: [340, 120], to: [940, 120], trimEnd: 0, strokeCap: 'round',
387
+ initial: { strokeColor: ACCENT, strokeWidth: 6 }, keyframes: [drawOn(0.2, 1)] },
388
+ // a card border (clockwise from the top-left), then its fill fades in
389
+ { type: 'shape', shape: 'rect', width: 360, height: 200, cornerRadius: 28, trimEnd: 0, strokeJoin: 'round',
390
+ initial: { x: 400, y: 360, strokeColor: TEAL, strokeWidth: 8, fillColor: TEAL, fillAlpha: 0 },
391
+ keyframes: [drawOn(0.6, 1.4), { at: 2, to: { fillAlpha: 0.2 }, duration: 0.6 }] },
392
+ // an SVG check mark
393
+ { type: 'shape', shape: 'path', d: 'M 0 50 L 45 95 L 130 0', trimEnd: 0, strokeCap: 'round', strokeJoin: 'round',
394
+ initial: { x: 880, y: 360, strokeColor: GREEN, strokeWidth: 16 }, keyframes: [drawOn(1.8, 0.9)] },
395
+ // a typewriter: left-aligned so it grows rightward; 30 characters over 2.4 s
396
+ { type: 'text', text: 'No mask, no per-letter layers.', at: 3, duration: 5,
397
+ style: { fontSize: 40, fill: '#e8eefc', fontFamily: 'ui-monospace, Menlo, monospace' },
398
+ initial: { x: 120, y: 560, anchorX: 0, anchorY: 0.5, visibleChars: 0 },
399
+ keyframes: [{ at: 0, to: { visibleChars: 30 }, duration: 2.4, ease: 'none' }] },
400
+ // a countdown: one layer whose string changes
401
+ { type: 'text', text: '3', at: 3, duration: 5,
402
+ style: { fontSize: 150, fontWeight: '900', fill: ACCENT },
403
+ initial: { x: 1060, y: 540, anchorX: 0.5, anchorY: 0.5 },
404
+ keyframes: [{ at: 1, set: { text: '2' } }, { at: 2, set: { text: '1' } }, { at: 3, set: { text: 'Go!' } }] },
405
+ ];
406
+ ```
407
+
408
+ ---
409
+
375
410
  ## Letters and words that animate one by one (`splitText`)
376
411
 
377
412
  Text content cannot change, so a per-letter or per-word effect is one text layer per piece. `splitText(text, style, { by: 'chars' | 'words' | 'lines', x, y, align })` measures the text with the renderer's own font metrics (kerning kept) and returns each piece's `text`, `x`, `y`, `width`, `index` and `line`: put them straight into the layers. Use the same `style` for the whole text and each piece, and a web font must be loaded first. `measureText(text, style)` gives `{ width, height, lines }` for sizing pills and underlines.
package/ai/template.html CHANGED
@@ -36,9 +36,10 @@
36
36
  "gsap": "https://esm.sh/gsap@3.12.5",
37
37
  "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
38
38
  "mediabunny": "https://esm.sh/mediabunny",
39
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/index.js",
40
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/Controller.js",
41
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/three.js",
39
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.7.0/dist/index.js",
40
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.7.0/dist/Controller.js",
41
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.7.0/dist/three.js",
42
+ "pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js",
42
43
  "three": "https://esm.sh/three@0.178.0"
43
44
  }
44
45
  }
@@ -85,9 +85,10 @@ export function splitIssues(groups, strict = false) {
85
85
  const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.mp4': 'video/mp4', '.webm': 'video/webm', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf', '.wasm': 'application/wasm', '.map': 'application/json', '.md': 'text/plain; charset=utf-8', '.txt': 'text/plain; charset=utf-8' };
86
86
 
87
87
  /** A plain static server for `root` (no redirects, so `?query` survives). Resolves { server, port }. */
88
- export function serve(root) {
88
+ export function serve(root, onRequest = null, port = 0) {
89
89
  const server = http.createServer((req, res) => {
90
90
  try {
91
+ if (onRequest && onRequest(req, res)) return; // a hook (the render tool receives its file this way)
91
92
  let rel = decodeURIComponent(new URL(req.url, 'http://x').pathname);
92
93
  let file = path.join(root, rel);
93
94
  if (!file.startsWith(root)) { res.writeHead(403).end(); return; }
@@ -97,7 +98,7 @@ export function serve(root) {
97
98
  fs.createReadStream(file).pipe(res);
98
99
  } catch { res.writeHead(500).end(); }
99
100
  });
100
- return new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port })));
101
+ return new Promise(resolve => server.listen(port, '127.0.0.1', () => resolve({ server, port: server.address().port })));
101
102
  }
102
103
 
103
104
  /** The nearest folder at or above `dir` that contains dist/index.js (the library build), else `dir`. */
@@ -110,7 +111,7 @@ export function findRoot(dir, exists = fs.existsSync) {
110
111
 
111
112
  // ───────────────────────────── Chrome DevTools Protocol, minimal ─────────────────────────────
112
113
 
113
- class Cdp {
114
+ export class Cdp {
114
115
  constructor(ws) {
115
116
  this.ws = ws; this.id = 0; this.pending = new Map(); this.listeners = [];
116
117
  ws.onmessage = ev => {
@@ -136,7 +137,7 @@ class Cdp {
136
137
  }
137
138
  }
138
139
 
139
- async function launchChrome(chrome, userDataDir) {
140
+ export async function launchChrome(chrome, userDataDir) {
140
141
  const proc = spawn(chrome, [
141
142
  '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check',
142
143
  '--autoplay-policy=no-user-gesture-required', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader', '--window-size=1400,900', 'about:blank',
@@ -206,7 +207,7 @@ const exportScript = format => `(() => {
206
207
 
207
208
  // ───────────────────────────── the check ─────────────────────────────
208
209
 
209
- const sleep = ms => new Promise(r => setTimeout(r, ms));
210
+ export const sleep = ms => new Promise(r => setTimeout(r, ms));
210
211
  /** A path for display: relative to the cwd when it is inside it, absolute otherwise. */
211
212
  export const shown = f => { const r = path.relative(process.cwd(), f); return r.startsWith('..') || path.isAbsolute(r) ? f : r; };
212
213
  const kb = n => (n >= 1048576 ? `${(n / 1048576).toFixed(2)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
@@ -278,6 +279,14 @@ export async function runCheck(opts, log = console.log) {
278
279
  fs.writeFileSync(sheetFile, Buffer.from(sheet.slice(sheet.indexOf(',') + 1), 'base64'));
279
280
  report.files.sheet = shown(sheetFile);
280
281
 
282
+ // the timeline: every layer as a bar on a time axis (for a human to open in a browser)
283
+ const timeline = await cdp.eval(`movie.timelineChart({ title: ${JSON.stringify(name)} })`).catch(() => null);
284
+ if (timeline) {
285
+ const timelineFile = path.join(outDir, 'timeline.html');
286
+ fs.writeFileSync(timelineFile, timeline);
287
+ report.files.timeline = shown(timelineFile);
288
+ }
289
+
281
290
  // audio
282
291
  report.audio = info.hasAudio ? await cdp.eval('movie.inspectAudio()') : null;
283
292
  if (report.audio?.issues?.length) report.problems.push(`${report.audio.issues.length} audio issue(s) from movie.inspectAudio`);
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pixi-effects render — turn a composition page into a video file, headless, in ONE command.
4
+ *
5
+ * node ai/tools/render.mjs my-video.html -o my-video.mp4 (or: npx pixi-effects-render my-video.html -o out.mp4)
6
+ *
7
+ * It starts a private static server and a private headless Chrome, opens the page, waits for `window.__ready === true`
8
+ * (the contract of ai/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).
10
+ *
11
+ * Needs: Node >= 22 (built-in WebSocket), Chrome / Chromium installed (or CHROME=PATH / --chrome PATH). No npm dependencies.
12
+ * Exit code: 0 = the file was written, 1 = the page or the render failed (no file), 2 = bad usage.
13
+ *
14
+ * Options: -o, --out FILE (default ./<page name>.<format>; the container follows the extension: mp4 webm mov mkv) ·
15
+ * --format mp4|webm|mov|mkv · --quality very-low|low|medium|high|very-high (video and audio bitrate, default high) ·
16
+ * --video-codec C · --audio-codec C · --query "a=1&b=2" (added to the page URL) · --fail-on-warn (exit 1 when the page
17
+ * logged a warning; the file is still written) · --quiet (no progress line) · --timeout SECONDS (default 900) ·
18
+ * --root DIR (static server root; default: the nearest folder above the page with dist/) · --chrome PATH
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { pathToFileURL } from 'node:url';
24
+ import { findChrome, findRoot, serve, launchChrome, shown, sleep } from './check.mjs';
25
+
26
+ const FORMATS = ['mp4', 'webm', 'mov', 'mkv'];
27
+ const QUALITIES = ['very-low', 'low', 'medium', 'high', 'very-high'];
28
+
29
+ /** 'clip.WEBM' → 'webm'; no extension → null; any other extension is an error. */
30
+ export function formatFromPath(file) {
31
+ const ext = path.extname(file).slice(1).toLowerCase();
32
+ if (!ext) return null;
33
+ if (!FORMATS.includes(ext)) throw new Error(`cannot tell the container from ".${ext}": use an output ending in mp4, webm, mov or mkv (or --format)`);
34
+ return ext;
35
+ }
36
+
37
+ export function defaultOutput(page, format) {
38
+ return path.resolve(path.basename(page).replace(/\.[^.]+$/, '') + '.' + format);
39
+ }
40
+
41
+ export function parseRenderArgs(argv) {
42
+ const o = { page: null, out: null, format: null, quality: 'high', videoCodec: null, audioCodec: null, query: null, failOnWarn: false, quiet: false, timeout: 900, root: null, chrome: null };
43
+ const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a === '-o' || a === '--out') o.out = need(i++, a);
47
+ else if (a === '--format') {
48
+ o.format = need(i++, a).toLowerCase();
49
+ if (!FORMATS.includes(o.format)) throw new Error(`--format must be mp4, webm, mov or mkv (got "${o.format}")`);
50
+ } else if (a === '--quality') {
51
+ o.quality = need(i++, a);
52
+ if (!QUALITIES.includes(o.quality)) throw new Error(`--quality must be very-low, low, medium, high or very-high (got "${o.quality}")`);
53
+ } else if (a === '--video-codec') o.videoCodec = need(i++, a);
54
+ else if (a === '--audio-codec') o.audioCodec = need(i++, a);
55
+ else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
56
+ else if (a === '--fail-on-warn') o.failOnWarn = true;
57
+ else if (a === '--quiet') o.quiet = true;
58
+ else if (a === '--timeout') o.timeout = Math.max(10, Number(need(i++, a)) || 900);
59
+ else if (a === '--root') o.root = need(i++, a);
60
+ else if (a === '--chrome') o.chrome = need(i++, a);
61
+ else if (a.startsWith('-')) throw new Error(`unknown option ${a} (see the header of render.mjs)`);
62
+ else if (!o.page) o.page = a;
63
+ else throw new Error(`unexpected argument ${a}`);
64
+ }
65
+ o.format ??= (o.out ? formatFromPath(o.out) : null) ?? 'mp4';
66
+ return o;
67
+ }
68
+
69
+ const UPLOAD = '/__pixi_effects_out';
70
+ const kb = n => (n >= 1048576 ? `${(n / 1048576).toFixed(2)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
71
+
72
+ /** The page's side: render, and PUT the Blob to our server (so a long video never goes through base64). */
73
+ const renderScript = (format, quality, videoCodec, audioCodec) => `(() => {
74
+ window.__r = { state: 'running', progress: 0 };
75
+ movie.on('progress', e => { window.__r.progress = e.progress; });
76
+ (async () => {
77
+ try {
78
+ const video = { bitrate: ${JSON.stringify(quality)}${videoCodec ? `, codec: ${JSON.stringify(videoCodec)}` : ''} };
79
+ const audio = { bitrate: ${JSON.stringify(quality)}${audioCodec ? `, codec: ${JSON.stringify(audioCodec)}` : ''} };
80
+ const blob = await movie.render({ format: ${JSON.stringify(format)}, video, audio });
81
+ const res = await fetch(${JSON.stringify(UPLOAD)}, { method: 'PUT', body: blob });
82
+ if (!res.ok) throw new Error('could not hand the file to the command (' + res.status + ')');
83
+ window.__r = { state: 'done', bytes: blob.size, type: blob.type };
84
+ } catch (e) { window.__r = { state: 'error', message: String((e && e.message) || e) }; }
85
+ })();
86
+ return 0;
87
+ })()`;
88
+
89
+ export async function runRender(opts, log = console.log, progress = () => {}) {
90
+ const pagePath = path.resolve(opts.page);
91
+ if (!fs.existsSync(pagePath)) throw new Error(`page not found: ${opts.page}`);
92
+ const out = path.resolve(opts.out ?? defaultOutput(pagePath, opts.format));
93
+ const chrome = opts.chrome ?? findChrome();
94
+ if (!chrome) throw new Error('Chrome / Chromium not found: install it, or set CHROME=/path/to/chrome (or --chrome PATH)');
95
+ const root = path.resolve(opts.root ?? findRoot(path.dirname(pagePath)));
96
+ if (!pagePath.startsWith(root)) throw new Error(`the page must be inside the server root (${root}); use --root`);
97
+ fs.mkdirSync(path.dirname(out), { recursive: true });
98
+ const part = out + '.part';
99
+ const deadline = Date.now() + opts.timeout * 1000;
100
+ const left = () => { if (Date.now() > deadline) throw new Error(`timed out after ${opts.timeout} s`); };
101
+
102
+ let written = 0;
103
+ const upload = (req, res) => {
104
+ if (req.method !== 'PUT' || !req.url.startsWith(UPLOAD)) return false;
105
+ const ws = fs.createWriteStream(part);
106
+ req.pipe(ws);
107
+ ws.on('finish', () => { written = ws.bytesWritten; res.writeHead(204).end(); });
108
+ ws.on('error', () => res.writeHead(500).end());
109
+ return true;
110
+ };
111
+
112
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pixi-effects-render-'));
113
+ const { server, port } = await serve(root, upload);
114
+ let chromeProc = null;
115
+ const result = { page: shown(pagePath), file: null, logs: [], problems: [] };
116
+ try {
117
+ const { proc, cdp } = await launchChrome(chrome, userDataDir);
118
+ chromeProc = proc;
119
+ const consoleLines = [];
120
+ cdp.on(m => { if (m.method === 'Runtime.exceptionThrown') consoleLines.push(`uncaught: ${m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text}`); });
121
+ await cdp.send('Runtime.enable');
122
+ await cdp.send('Page.enable');
123
+ const url = `http://127.0.0.1:${port}/${path.relative(root, pagePath).split(path.sep).join('/')}${opts.query ? '?' + opts.query : ''}`;
124
+ const t0 = Date.now();
125
+ await cdp.send('Page.navigate', { url });
126
+
127
+ let info = null;
128
+ for (;;) {
129
+ left();
130
+ await sleep(300);
131
+ try {
132
+ const ready = await cdp.eval('window.__ready === true');
133
+ info = await cdp.eval(`(() => { const m = window.movie; return { hasMovie: !!m, logs: window.__logs || [], duration: m && m.duration, totalFrames: m && m.totalFrames, width: m && m.width, height: m && m.height, frameRate: m && m.frameRate }; })()`);
134
+ if (ready || (info.logs || []).some(l => /^(init|uncaught)/.test(l)) || consoleLines.length) break;
135
+ } catch { /* the page is still loading */ }
136
+ if (Date.now() - t0 > 60000) break;
137
+ }
138
+ const ready = await cdp.eval('window.__ready === true').catch(() => false);
139
+ result.logs = [...new Set([...(info?.logs ?? []), ...consoleLines])];
140
+ if (!ready || !info?.hasMovie) {
141
+ result.problems.push(`the page did not become ready (window.__ready !== true${info?.hasMovie ? '' : '; window.movie is missing'})${result.logs.length ? ': ' + result.logs[0] : ''}`);
142
+ return result;
143
+ }
144
+ Object.assign(result, { duration: info.duration, frames: info.totalFrames, width: info.width, height: info.height, frameRate: info.frameRate });
145
+
146
+ await cdp.eval(renderScript(opts.format, opts.quality, opts.videoCodec, opts.audioCodec));
147
+ let state;
148
+ for (;;) {
149
+ left();
150
+ await sleep(400);
151
+ const r = await cdp.eval('window.__r');
152
+ state = r.state;
153
+ progress(r.progress ?? 0);
154
+ if (state !== 'running') { result.render = r; break; }
155
+ }
156
+ result.logs = [...new Set([...result.logs, ...(await cdp.eval('window.__logs || []').catch(() => []))])];
157
+ if (state === 'error') { result.problems.push(`render failed: ${result.render.message}`); return result; }
158
+ if (!written) { result.problems.push('the render finished but no file reached the command'); return result; }
159
+ fs.renameSync(part, out);
160
+ result.file = out;
161
+ result.bytes = written;
162
+ result.seconds = Math.round((Date.now() - t0) / 100) / 10;
163
+ return result;
164
+ } finally {
165
+ try { chromeProc?.kill(); } catch { /* already gone */ }
166
+ server.close();
167
+ await sleep(200);
168
+ try { fs.rmSync(part, { force: true }); } catch { /* nothing to clean */ }
169
+ try { fs.rmSync(userDataDir, { recursive: true, force: true }); } catch { /* chrome may still hold files */ }
170
+ }
171
+ }
172
+
173
+ // ───────────────────────────── CLI ─────────────────────────────
174
+
175
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
176
+ try {
177
+ if (typeof WebSocket === 'undefined') throw new Error('Node >= 22 is needed (built-in WebSocket)');
178
+ const opts = parseRenderArgs(process.argv.slice(2));
179
+ if (!opts.page) {
180
+ console.error('usage: node ai/tools/render.mjs <page.html> [-o out.mp4] [--format mp4|webm|mov|mkv] [--quality very-low|low|medium|high|very-high] [--query a=1] [--fail-on-warn] [--quiet] [--timeout S] [--root DIR] [--chrome PATH]');
181
+ process.exit(2);
182
+ }
183
+ let last = -1;
184
+ const progress = p => {
185
+ if (opts.quiet || !process.stderr.isTTY) return;
186
+ const pct = Math.min(100, Math.round(p));
187
+ if (pct !== last) { last = pct; process.stderr.write(`\rrendering ${pct}%`); }
188
+ };
189
+ const r = await runRender(opts, console.log, progress);
190
+ if (!opts.quiet && process.stderr.isTTY) process.stderr.write('\r \r');
191
+ for (const w of r.logs) console.error(`page: ${w.length > 300 ? w.slice(0, 297) + '…' : w}`);
192
+ if (r.problems.length || !r.file) {
193
+ console.error(`pixi-effects render failed: ${r.problems.join('; ') || 'no file written'}`);
194
+ process.exit(1);
195
+ }
196
+ console.log(`${shown(r.file)} ${kb(r.bytes)} · ${r.duration} s · ${r.width}×${r.height} @ ${r.frameRate} fps · ${opts.format} · rendered in ${r.seconds} s`);
197
+ process.exit(opts.failOnWarn && r.logs.length ? 1 : 0);
198
+ } catch (e) {
199
+ console.error(`pixi-effects render failed: ${e.message}`);
200
+ process.exit(2);
201
+ }
202
+ }