pixi-effects 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +18 -11
  3. package/ai/SKILL.md +1 -1
  4. package/ai/reference/cheatsheet.md +9 -1
  5. package/ai/template.html +4 -3
  6. package/ai/tools/check.mjs +14 -5
  7. package/ai/tools/render.mjs +202 -0
  8. package/ai/tools/view.mjs +269 -0
  9. package/dist/{Base-4vNLl4tH.d.cts → Base-CSIulyIq.d.cts} +1 -1
  10. package/dist/{Base-CD4AoT5-.d.ts → Base-DVgwyIUj.d.ts} +1 -1
  11. package/dist/Composition-CM62XJKT.cjs +14 -0
  12. package/dist/{Composition-Y5VZSDDP.cjs.map → Composition-CM62XJKT.cjs.map} +1 -1
  13. package/dist/Composition-RJDSQYTV.js +5 -0
  14. package/dist/{Composition-7SO5WYHU.js.map → Composition-RJDSQYTV.js.map} +1 -1
  15. package/dist/Controller.cjs +122 -20
  16. package/dist/Controller.cjs.map +1 -1
  17. package/dist/Controller.d.cts +45 -3
  18. package/dist/Controller.d.ts +45 -3
  19. package/dist/Controller.js +122 -21
  20. package/dist/Controller.js.map +1 -1
  21. package/dist/{Movie-Ch2SUI1h.d.ts → Movie-CQOhUmko.d.cts} +75 -2
  22. package/dist/{Movie-CADDBfpl.d.cts → Movie-HF3pSGjQ.d.ts} +75 -2
  23. package/dist/{chunk-KQM2ZZMK.cjs → chunk-AGMNSJJH.cjs} +175 -29
  24. package/dist/chunk-AGMNSJJH.cjs.map +1 -0
  25. package/dist/{chunk-7IJHJXDI.js → chunk-FNKSXBKB.js} +136 -10
  26. package/dist/chunk-FNKSXBKB.js.map +1 -0
  27. package/dist/{chunk-B2FTJLNB.js → chunk-O4AJYUPK.js} +3 -3
  28. package/dist/{chunk-B2FTJLNB.js.map → chunk-O4AJYUPK.js.map} +1 -1
  29. package/dist/{chunk-4AGJF5RK.cjs → chunk-PDBDDGG5.cjs} +21 -21
  30. package/dist/{chunk-4AGJF5RK.cjs.map → chunk-PDBDDGG5.cjs.map} +1 -1
  31. package/dist/index.cjs +255 -25
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +4 -4
  34. package/dist/index.d.ts +4 -4
  35. package/dist/index.js +248 -18
  36. package/dist/index.js.map +1 -1
  37. package/dist/three.cjs +6 -5
  38. package/dist/three.cjs.map +1 -1
  39. package/dist/three.d.cts +2 -2
  40. package/dist/three.d.ts +2 -2
  41. package/dist/three.js +2 -1
  42. package/dist/three.js.map +1 -1
  43. package/dist/{types-DrW9QeKU.d.ts → types-oJijbn-K.d.cts} +14 -1
  44. package/dist/{types-DrW9QeKU.d.cts → types-oJijbn-K.d.ts} +14 -1
  45. package/docs/api.md +57 -0
  46. package/docs/dsl.md +60 -1
  47. package/llms-full.txt +127 -3
  48. package/package.json +7 -4
  49. package/dist/Composition-7SO5WYHU.js +0 -4
  50. package/dist/Composition-Y5VZSDDP.cjs +0 -13
  51. package/dist/chunk-7IJHJXDI.js.map +0 -1
  52. package/dist/chunk-KQM2ZZMK.cjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,9 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ **Added**
6
+
7
+ - Movie events with the names an HTML5 `<video>` uses: `ended` (playback ran off the end, after `pause`), `seeking` / `seeked` (a `gotoFrame()` to another frame; not for playback ticks, `render()`, `snapshot()` or `contactSheet()`), `volumechange` (`{ volume, muted }`) and `error` (`{ where: 'init' | 'render' | 'playback', message, error }`; the call still rejects). A player of your own needs nothing else: `examples/15-custom-player.html` is a complete one, and `docs/api.md` has a section on it.
8
+ - `Controller` theme: the bar's colours and thickness are CSS custom properties (`--mc-accent`, `--mc-fg`, `--mc-track`, `--mc-bar-bg`, `--mc-track-height`, `--mc-font`) with the old values as defaults, set by `new Controller(movie, { canvas, theme: { accent: '#ff4d6d' } })`, by `controller.setTheme(…)` (`null` = default) or by your page CSS. `CONTROLLER_CSS` is exported.
9
+ - `{ type: 'colorGradient', gradientType: 'linear' | 'radial' | 'conic' }`: the filter's own `type` option clashes with the DSL's `type`. 40 of the named filters are now rendered and exported in a real browser (`examples/_checks/named-filters-2.html` is the second half); the table in `docs/dsl.md` lists each one's options, and which ones are driven by `time` or need a texture.
10
+ - CI (`.github/workflows/ci.yml`): typecheck, build and unit tests on every push and pull request (Node 22), and the real-browser tool tests in headless Chrome as a non-blocking job.
11
+
12
+ **Fixed**
13
+
14
+ - The player showed a fullscreen button on browsers without the Fullscreen API for elements (iPhone Safari, which can only fullscreen a `<video>`): a button that did nothing. It is not shown there now, and the F key does nothing.
15
+ - Exporting a movie WITH SOUND to mp4 / mov failed on browsers without an AAC encoder (Chrome on Linux, which is also what a Linux server or CI runs) with "This specific encoder configuration (mp4a.40.2 …) is not supported". The audio codec is now the first one the browser can encode (aac, then opus, mp3, flac for mp4; opus, vorbis for webm), with a warning that says so; a codec you name with `audio.codec` is never swapped, and its failure says which one would work. Found by the real-browser CI job; checked by making `AudioEncoder.isConfigSupported` refuse AAC: the file was h264 + opus.
16
+
17
+ ## 0.7.0
18
+
19
+ **Fixed**
20
+
21
+ - 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.
22
+
23
+ **Added**
24
+
25
+ - `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).
26
+ - 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`.
27
+ - `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).
28
+ - 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.
29
+
3
30
  ## 0.6.0
4
31
 
5
32
  **Added**
6
33
 
34
+ - `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`).
35
+ - `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`.
7
36
  - `examples/gallery/blueprint-house.html` (31st piece): a blueprint that draws itself with `trimEnd`, `visibleChars` and `set: { text }`.
8
37
  - 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`.
9
38
  - 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.
package/README.md CHANGED
@@ -1,20 +1,23 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.6.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `0.6.0` adds draw-on strokes (`trimEnd`) and text that changes over time, `0.5.0` adds synthesised sound effects (`sfx`, no files) and the `check` tool, `0.4.0` makes a mask share its layer's lifetime, `0.3.0` made keyframe `at` relative to the layer).
3
+ > **Status**: experimental — current release `0.8.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `0.8.0` adds events and a theme for building your own player, `0.7.0` adds filters by name, the `pixi-effects-render` and `pixi-effects-view` commands and the timeline chart, `0.6.0` adds draw-on strokes (`trimEnd`) and text that changes over time, `0.5.0` adds synthesised sound effects (`sfx`, no files) and the `check` tool, `0.4.0` makes a mask share its layer's lifetime, `0.3.0` made keyframe `at` relative to the layer).
4
4
 
5
- **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 31 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
- - **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.
18
+ - **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`).
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.6.0/dist/index.js",
40
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.6.0/dist/Controller.js"
43
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.8.0/dist/index.js",
44
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.8.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.6.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.8.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.6.0/dist/three.js"
71
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.8.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
+ - `15-custom-player.html` — a complete player of your own in about 40 lines (play, seek, volume, replay) using the movie's events
186
193
  - `14-draw-on.html` — draw-on strokes (`trimStart` / `trimEnd`) and text that changes over time (`visibleChars`, `set: { text }`)
187
194
  - `playground.html` — in-browser editor with preset dropdown
188
195
 
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)
@@ -83,6 +83,14 @@ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha stro
83
83
 
84
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`.
85
85
 
86
+ ## Player
87
+
88
+ `new Controller(movie, { canvas, theme: { accent: '#ff4d6d' } })` (import from `pixi-effects/controller`; theme keys `accent foreground track barBackground trackHeight font`, or CSS `--mc-accent` …; `controller.setTheme(…)`). Your own player: `movie.play() pause() gotoFrame(f) volume muted toggleMute()`, state `currentFrame totalFrames frameRate duration isPlaying`, events `ready frame play pause ended seeking seeked volumechange error progress` (`<video>` names). `examples/15-custom-player.html` is a complete one.
89
+
90
+ ## Filters by name (no import)
91
+
92
+ `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 · `colorGradient` gradientType('linear'|'radial'|'conic') stops[{offset,color,alpha}] angle · `colorReplace` originalColor targetColor tolerance · `convolution` matrix(9) · `shockwave` / `godray` are driven by `time` (animate it) · `colorMap` / `simpleLightmap` need a texture (use `custom`). **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.
93
+
86
94
  ## Transitions (on the parent composition)
87
95
 
88
96
  ```js
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.6.0/dist/index.js",
40
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.6.0/dist/Controller.js",
41
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.6.0/dist/three.js",
39
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.8.0/dist/index.js",
40
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.8.0/dist/Controller.js",
41
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.8.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
+ }