pixi-effects 0.22.0 → 0.26.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 (92) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +17 -15
  3. package/ai/CHAT.md +2 -2
  4. package/ai/chat-template.html +3 -3
  5. package/ai/tools/check.mjs +10 -3
  6. package/ai/tools/pixi-effects.mjs +48 -0
  7. package/ai/tools/render.mjs +3 -3
  8. package/ai/tools/view.mjs +3 -3
  9. package/dist/{Base-gu-5X0Cv.d.ts → Base-BgnRXbtJ.d.ts} +1 -1
  10. package/dist/{Base-BzlPgd-D.d.cts → Base-DHVKqA46.d.cts} +1 -1
  11. package/dist/Composition-2GJSCHPP.cjs +16 -0
  12. package/dist/{Composition-BK5SH6J7.cjs.map → Composition-2GJSCHPP.cjs.map} +1 -1
  13. package/dist/Composition-C4FSWJZF.js +7 -0
  14. package/dist/{Composition-Q5TVG3HM.js.map → Composition-C4FSWJZF.js.map} +1 -1
  15. package/dist/Controller.cjs +2 -2
  16. package/dist/Controller.d.cts +2 -2
  17. package/dist/Controller.d.ts +2 -2
  18. package/dist/Controller.js +1 -1
  19. package/dist/{Movie--_TJvdJa.d.ts → Movie-DP5p1r4E.d.ts} +5 -1
  20. package/dist/{Movie-DMxOoTdW.d.cts → Movie-DXReF4-W.d.cts} +5 -1
  21. package/dist/Presenter.cjs +2 -2
  22. package/dist/Presenter.d.cts +2 -2
  23. package/dist/Presenter.d.ts +2 -2
  24. package/dist/Presenter.js +1 -1
  25. package/dist/{blendModes-HCP5XTNP.js → blendModes-G3NOAU3T.js} +5 -5
  26. package/dist/{blendModes-HCP5XTNP.js.map → blendModes-G3NOAU3T.js.map} +1 -1
  27. package/dist/{blendModes-5E4G2ZZQ.cjs → blendModes-YV43K6E7.cjs} +6 -6
  28. package/dist/{blendModes-5E4G2ZZQ.cjs.map → blendModes-YV43K6E7.cjs.map} +1 -1
  29. package/dist/{chunk-K4JVI5HK.cjs → chunk-3DLYAPK7.cjs} +7 -9
  30. package/dist/{chunk-K4JVI5HK.cjs.map → chunk-3DLYAPK7.cjs.map} +1 -1
  31. package/dist/{chunk-YEPDQ7I3.js → chunk-AXZUKNXF.js} +6 -6
  32. package/dist/{chunk-YEPDQ7I3.js.map → chunk-AXZUKNXF.js.map} +1 -1
  33. package/dist/{chunk-NHICCDTC.js → chunk-BXOCEZ4U.js} +12 -3
  34. package/dist/chunk-BXOCEZ4U.js.map +1 -0
  35. package/dist/{chunk-IFZOI3JQ.cjs → chunk-DP37TTMO.cjs} +842 -40
  36. package/dist/chunk-DP37TTMO.cjs.map +1 -0
  37. package/dist/{chunk-BPTW7L25.cjs → chunk-ECA2WNRX.cjs} +23 -9
  38. package/dist/chunk-ECA2WNRX.cjs.map +1 -0
  39. package/dist/{chunk-DC4JPY6A.js → chunk-PRZ4U6JN.js} +814 -22
  40. package/dist/chunk-PRZ4U6JN.js.map +1 -0
  41. package/dist/chunk-SY27HZC5.cjs +1727 -0
  42. package/dist/chunk-SY27HZC5.cjs.map +1 -0
  43. package/dist/{chunk-E7RONUZ7.js → chunk-TLYTYBWX.js} +20 -6
  44. package/dist/chunk-TLYTYBWX.js.map +1 -0
  45. package/dist/{chunk-UZTKEYFP.cjs → chunk-TWZNOUM7.cjs} +12 -3
  46. package/dist/chunk-TWZNOUM7.cjs.map +1 -0
  47. package/dist/chunk-XS6GSJCN.js +1724 -0
  48. package/dist/chunk-XS6GSJCN.js.map +1 -0
  49. package/dist/index.cjs +234 -77
  50. package/dist/index.cjs.map +1 -1
  51. package/dist/index.d.cts +5 -5
  52. package/dist/index.d.ts +5 -5
  53. package/dist/index.js +173 -16
  54. package/dist/index.js.map +1 -1
  55. package/dist/loader.css +1 -1
  56. package/dist/{music-FUN7N225.js → music-AF4VTFAF.js} +3 -3
  57. package/dist/{music-FUN7N225.js.map → music-AF4VTFAF.js.map} +1 -1
  58. package/dist/{music-GEAH2XCT.cjs → music-KB7YZLHP.cjs} +7 -7
  59. package/dist/{music-GEAH2XCT.cjs.map → music-KB7YZLHP.cjs.map} +1 -1
  60. package/dist/{sfx-2DYTYNYW.cjs → sfx-2O6UQEUP.cjs} +8 -8
  61. package/dist/{sfx-2DYTYNYW.cjs.map → sfx-2O6UQEUP.cjs.map} +1 -1
  62. package/dist/{sfx-3IDAJ3UZ.js → sfx-VUKARHDX.js} +3 -3
  63. package/dist/{sfx-3IDAJ3UZ.js.map → sfx-VUKARHDX.js.map} +1 -1
  64. package/dist/three.cjs +7 -7
  65. package/dist/three.d.cts +2 -2
  66. package/dist/three.d.ts +2 -2
  67. package/dist/three.js +3 -3
  68. package/dist/{types-DBbZVKGS.d.cts → types-CoT0ypcx.d.cts} +60 -4
  69. package/dist/{types-DBbZVKGS.d.ts → types-CoT0ypcx.d.ts} +60 -4
  70. package/docs/api.md +2 -2
  71. package/docs/dsl.md +140 -7
  72. package/llms-full.txt +328 -39
  73. package/llms.txt +6 -5
  74. package/package.json +3 -3
  75. package/{ai → skills/pixi-effects}/SKILL.md +16 -12
  76. package/{ai → skills/pixi-effects}/reference/cheatsheet.md +10 -7
  77. package/{ai → skills/pixi-effects}/reference/pitfalls.md +22 -4
  78. package/{ai → skills/pixi-effects}/reference/recipes.md +133 -2
  79. package/{ai/tools → skills/pixi-effects/scripts}/save-image.py +1 -1
  80. package/{ai → skills/pixi-effects}/template.html +6 -6
  81. package/dist/Composition-BK5SH6J7.cjs +0 -16
  82. package/dist/Composition-Q5TVG3HM.js +0 -7
  83. package/dist/chunk-BPTW7L25.cjs.map +0 -1
  84. package/dist/chunk-DC4JPY6A.js.map +0 -1
  85. package/dist/chunk-E7RONUZ7.js.map +0 -1
  86. package/dist/chunk-IFZOI3JQ.cjs.map +0 -1
  87. package/dist/chunk-MHOO2CVU.cjs +0 -840
  88. package/dist/chunk-MHOO2CVU.cjs.map +0 -1
  89. package/dist/chunk-NHICCDTC.js.map +0 -1
  90. package/dist/chunk-UQDEDYVM.js +0 -838
  91. package/dist/chunk-UQDEDYVM.js.map +0 -1
  92. package/dist/chunk-UZTKEYFP.cjs.map +0 -1
package/llms-full.txt CHANGED
@@ -4,18 +4,20 @@ Concatenation of the skill, cheatsheet, pitfalls, recipes and the DSL / API refe
4
4
 
5
5
 
6
6
 
7
- <!-- ===== ai/SKILL.md ===== -->
7
+ <!-- ===== skills/pixi-effects/SKILL.md ===== -->
8
+
9
+ > Written for pixi-effects 0.26.0. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
8
10
 
9
11
  # pixi-effects — write a video as data
10
12
 
11
- You describe a video as a tree of plain objects; the library plays it in a canvas and exports it (MP4/WebM/MOV). You never write per-frame code. **Read `reference/cheatsheet.md` once before writing anything**, copy a recipe from `reference/recipes.md` when one fits, and skim `reference/pitfalls.md` — every item there cost a previous session a retry.
13
+ You describe a video as a tree of plain objects; the library plays it in a canvas and exports it (MP4/WebM/MOV). You never write per-frame code. **Read `reference/cheatsheet.md` once before writing anything** (about 35 KB: conventions, layer types, keyframes). `reference/recipes.md` (67 KB) is an index of tested recipes: `grep -n "^## " reference/recipes.md`, then read only the one that fits (a title, a lower third, a chart, a kinetic-type line, a 2.5D scene …). `reference/pitfalls.md` (41 KB, numbered) is for when a warning or the picture surprises you: search it for the word, do not read it end to end. A simple title needs the cheatsheet and one recipe, nothing more.
12
14
 
13
15
  ## Workflow
14
16
 
15
17
  1. **Start from `template.html`** (copy it; it also has the shared loader (`<div class="pe-loader" data-label="LOADING">` + `pixi-effects/loader.css`: HTML and CSS only, animating just `transform` / `opacity`, so it keeps moving while the page builds the video; `movie.init()` fades it out). Keep it; a loader of your own is fine (`pe-loader--custom`) as long as it only animates `transform` / `opacity`, never with JavaScript or `requestAnimationFrame`; 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.
16
18
  2. **Give the movie a poster time.** In `movie.init` add `poster: <seconds>`: the most striking moment (a title fully on screen, not the first frame, which is often black). The canvas shows that picture before play (the playhead stays at 0 and play starts from 0), and the check writes it as `poster.jpg`.
17
19
  3. **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.
18
- 4. **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. A key a layer does not have (a typo in a layer key, a keyframe property or a text style key; `anchorX` outside `initial`; `pitch` outside `sfx`) is a warning that names the key and what you probably meant: treat it like any other warning. 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. The same judgement is in the page: `await movie.review({ at: 'title@end' })` returns `{ frames, problems, review, fonts, audio, at }` (what `check` prints, as data) and `movie.resolveAt('3.5, title@end')` turns moments into frames.
20
+ 4. **Show the human:** `npx pixi-effects view 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); it keeps running until Ctrl-C. **Deliver the file:** `npx pixi-effects render my-video.html -o my-video.mp4` 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:** `npx pixi-effects check my-video.html` (Node ≥ 22 and Chrome installed). **If `npx` says command not found, or prints nothing** (your npm may hold back a release that is a few days old, or an old release is cached): `npm i pixi-effects`, then `node node_modules/pixi-effects/ai/tools/check.mjs my-video.html` (the same for `render.mjs` and `view.mjs`). 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. A key a layer does not have (a typo in a layer key, a keyframe property or a text style key; `anchorX` outside `initial`; `pitch` outside `sfx`) is a warning that names the key and what you probably meant: treat it like any other warning. 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. The same judgement is in the page: `await movie.review({ at: 'title@end' })` returns `{ frames, problems, review, fonts, audio, at }` (what `check` prints, as data) and `movie.resolveAt('3.5, title@end')` turns moments into frames.
19
21
  **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.
20
22
  5. **Look at it** — you cannot judge motion from code. The library has the tools built in:
21
23
  - `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.
@@ -24,7 +26,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
24
26
  - `movie.inspectAudio()` → **hear with numbers**: for every sound its `start`, `sound.length`, `sound.peakDb`, `sound.loudestAt` (line this up with the picture) and `sound.brightnessHz` (< 500 dark, > 8000 airy), plus `issues` (limited mix, inaudible or cut-off sounds). Check it whenever the movie has audio; `render()` encodes this same mix.
25
27
  - **A moment, a movement, the sound (`pixi-effects-check` options):** `--at 3.5,title@end,50%,f120` writes those pictures to `check-out/<name>/frames/*.png` (and `at.png`): the last frame of a layer is `name@end`, the middle `name@mid`. `--onion 1:3` writes `onion.png`, eight frames of seconds 1–3 overlaid (the later the stronger): a trail that shows a movement's path and easing (even steps = constant speed, bunched = slow). `report.audio` now has `loudness` (`integratedLufs`, `truePeakDb`, `clippedSamples`, `silences`), `scenes` (per named top-level composition), `cues` (when each sound starts) and `notes` (advice: quiet or loud for web video, little headroom, silent stretches; web video is typically −14 to −16 LUFS); `waveform.png` draws the mix with the scene edges and the cues. A web font that failed to load fails the check; a text layer none of whose fonts is installed is listed for review.
26
28
  - **A deck** (`composition.stops`, `deck()`): `await movie.contactSheet({ frames: movie.stops.map(s => s.frame), as: 'dataURL' })` is one picture of every stop (the check writes it as `stops.png`); what the audience reads while a page waits is that picture, so check every one. `await movie.inspectStops()` lists the stops where the picture is still changing (a stop landing before its animation ends); the check prints it as a review list: move the stop later or flag a settled one `pdf: true`. `await movie.stopImages({ which: 'pages' })` / `movie.exportPDF()` give the pages themselves.
27
- From a browser tool the result is a `data:` URL string: `agent-browser eval "movie.contactSheet({ count: 6, as: 'dataURL' })" | python3 ai/tools/save-image.py /absolute/sheet.png`, then open the PNG. Sheet tiles are small (~480 px): use `snapshot` for detail. `inspect` output is long on busy scenes: pass `{ layers: 'none' }` and read `issues` first. Give layers a `name` so inspect paths are readable. A moving ticker is not flagged as cut off.
29
+ From a browser tool the result is a `data:` URL string: `agent-browser eval "movie.contactSheet({ count: 6, as: 'dataURL' })" | python3 scripts/save-image.py /absolute/sheet.png`, then open the PNG. Sheet tiles are small (~480 px): use `snapshot` for detail. `inspect` output is long on busy scenes: pass `{ layers: 'none' }` and read `issues` first. Give layers a `name` so inspect paths are readable. A moving ticker is not flagged as cut off.
28
30
  6. **Iterate** on what you saw (clipped text, collisions, wrong timing). Fast motion looks choppy: add `motionBlur: true` to `movie.init` (render, snapshot and contact sheet then show a film-style blur; renders take ~8× longer). Then export: `const blob = await movie.render({ format: 'mp4' })` (about real time; `movie.on('progress', …)` reports 0–100). **While you iterate, do not export the whole movie:** `check --no-export --at <the moment you changed>` takes seconds (3 to 10 s on the gallery pieces: most of it is loading the page and measuring the sound); to see motion, `render({ range: [from, to] })` / `pixi-effects-render --range 10:15` / `--scene name` exports only that part (picture and sound); `draft: true` / `--draft` is half size, low quality and no motion blur: a much smaller file (a piece that asks for motion blur renders much faster as a draft: 4 s of `hanabi-night` took 14.0 s, and 8.2 s as a draft, about 7 s of either being the browser's start-up), but the drawing itself is not faster. Export the whole movie once, at the end.
29
31
 
30
32
  ## The rules that cause most failures
@@ -34,12 +36,14 @@ You describe a video as a tree of plain objects; the library plays it in a canva
34
36
  - **Angles are degrees. `+z` is toward the viewer. Numbers can be expressions** (`'GW/2 - w/2'`; no `pi`; no per-frame variable).
35
37
  - **Anchors**: rect/circle/ellipse are centred on `x,y`; text and image are top-left (use `anchorX/anchorY: 0.5`); compositions use `pivotX/pivotY`. A bar that grows from its base needs `anchorY: 1`.
36
38
  - **Sound effects need no files:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` — presets `click pop swoosh swipe hit riser chime beep coin glitch typewriter`; knobs `pitch`, `brightness`, `seed`. The layer lasts as long as the sound (`duration` stretches it); a `riser` is loudest at its end (`at = hit − duration`), a `swoosh` 0.16 s in. A music file shorter than its layer goes silent — `loop: true`.
37
- - **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.**
39
+ - **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.** **One mask for several layers: name the layer and write its name as the `mask`** (`mask: 'band'`; `{ layer, channel: 'luma', invert: true }`; a list intersects); the named layer is not drawn and must live as long as its users. A **luma wipe** is a transition: `{ kind: 'luma', map: 'radial' | 'linear' | 'diagonal' | '<grayscale asset>', softness, flip }`.
38
40
  - **2.5D needs `threeD: true`** on each layer plus a `{ type: 'camera' }` layer; camera props go in `initial`/keyframes; keep every `z` below the camera distance (≈ 989 at 720p, fov 40); under a dolly zoom only `z = 0` stays fixed; **depth of field** is two camera props, `focus` (a threeD layer's name or a z) and `aperture` (30 default, 0 off), and a rack focus is a keyframe on `focus`.
39
41
  - **Group / parent layers = `{ type: 'null', name: 'rig' }`** (draws nothing, only moves) + `parent: 'rig'` on the layers that should follow it: their `x`/`y` are then measured from the null's origin, and the null's position / rotation / scale / alpha carry them (orbits, clock hands, grouped fades). Nulls chain. Only `null` layers can be parents; not for `threeD` layers.
40
42
  - **Time:** `speed` (slow motion, backward) and `time` (a curve of the layer's own seconds: freeze, rewind, ramp) on video, audio-file and composition layers; put what you want to rewind in a composition. Children of such a composition are in ITS seconds. `ease: 'cubic-bezier(.4, 0, .2, 1)'` is the CSS curve. See the recipe `rewind-and-replay` and pitfalls 69–73.
41
- - **Built in:** `animateText()` (per-letter / per-word / per-line entrances and exits in one call: `...animateText('Title', style, { x, y, align: 'center', at: 0.5, duration: 5, in: 'rise', out: 'fade', stagger: { each: 0.05 } })`), `splitText()` / `measureText()` (per-letter / per-word layers, exact positions, by hand), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`, seeded randomness (`rand(seed)` / `noise(x, seed)` in expressions, `random(seed)` in JS, never `Math.random()`) `wiggle()` (a seeded shake as keyframes; on a camera put it on `offsetX` / `offsetY` so it rides on the move) `cameraPath()` (a camera flight through `[x, y, z]` points as keyframes; layers it passes: `hideBehindCamera: true`) depth of field (camera `focus` + `aperture`: `{ type: 'camera', initial: { focus: 'title' } }` blurs the other threeD layers by depth; a rack focus is a keyframe on `focus`) `music` (a tune with no file: `{ type: 'audio', music: { bpm, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4' }, { inst: 'bass', notes: 'c2:2 g1:2' }], drums: { from: 8, kick: 'x...x...' } } }`: notes and chords as text, counted in beats; instruments `keys pluck pad bass sub lead bell musicbox`; `musicEnvelope(music)` feeds `react()`), `audioEnvelope()` / `bpmEnvelope()` + `react()` (visuals that follow music: `const env = await audioEnvelope('music.mp3', { frameRate })` before `movie.init`, then `keyframes: react(env, { duration, props: { scale: { base: 1, amount: 0.4, band: 'bass', release: 0.2 } } })`; `beats: true` pulses on the beats; `bpmEnvelope(120, …)` when there is no file), `deck()` + `Presenter` (a talk as one movie: pages, steps, stops that pause playback, arrow keys / click / swipe to move on: `...deck({ transition: { kind: 'slide', duration: 0.6 }, pages: [{ name, duration, stops, sequences }] })`, `new Presenter(movie, { canvas })` from `pixi-effects/presenter`; G = page overview, P = presenter view with notes and a timer; `pixi-effects-render talk.html -o talk.pdf` = a PDF of the pages), `particles()` (bursts, snow, confetti, sparks as seeded layers: `...particles({ count: 90, at: 2, life: [1, 1.8], area: { x: 640, y: 330 }, speed: [120, 420], gravity: 240, fade: { out: 0.6 } })`), `followPath()` (a layer along an SVG path: `keyframes: followPath({ d, duration, orient: true })`), path morphing (`{ shape: 'path', d, morphTo, initial/keyframes: morph 0 → 1 }`) and `stagger()` (delays for a wave of layers: `stagger(letters, { each: 0.05, from: 'center' })`, or `stagger(n, …)` for numbers; GSAP-style `each` / `amount` / `from` / `grid` / `ease`). **Not in the DSL (hand-roll with a JS loop):** other per-frame curves (waves, spirals: sample them into short linear keyframes), shared masks. (Draw-on strokes `trimEnd`, text that changes over time `set: { text }` / `visibleChars`, shake `wiggle()` and particles are built in.)
42
- - **Blend modes:** `blendMode` takes 18 CSS names (`add` `screen` `multiply` `overlay` `soft-light` `hard-light` `color-dodge` `color-burn` `darken` `lighten` `difference` `exclusion` `hue` `saturation` `color` `luminosity` `linear-burn`, hyphenated) on any layer: glows and light leaks (`add`, `color-dodge`), colour casts and contrast (`soft-light`, `overlay`), vignettes (`multiply`). It blends with everything below the layer; on a composition each child blends (as one picture: `threeD: true` or a filter); 10+ of the new modes on screen at once warn.
43
+ - **Built in:** `animateText()` (per-letter / per-word / per-line entrances and exits in one call: `...animateText('Title', style, { x, y, align: 'center', at: 0.5, duration: 5, in: 'rise', out: 'fade', stagger: { each: 0.05 } })`), `splitText()` / `measureText()` (per-letter / per-word layers, exact positions, by hand), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`, seeded randomness (`rand(seed)` / `noise(x, seed)` in expressions, `random(seed)` in JS, never `Math.random()`) `wiggle()` (a seeded shake as keyframes; on a camera put it on `offsetX` / `offsetY` so it rides on the move) `cameraPath()` (a camera flight through `[x, y, z]` points as keyframes; layers it passes: `hideBehindCamera: true`) depth of field (camera `focus` + `aperture`: `{ type: 'camera', initial: { focus: 'title' } }` blurs the other threeD layers by depth; a rack focus is a keyframe on `focus`) `music` (a tune with no file: `{ type: 'audio', music: { bpm, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4' }, { inst: 'bass', notes: 'c2:2 g1:2' }], drums: { from: 8, kick: 'x...x...' } } }`: notes and chords as text, counted in beats; instruments `keys pluck pad bass sub lead bell musicbox`; `musicEnvelope(music)` feeds `react()`), `audioEnvelope()` / `bpmEnvelope()` + `react()` (visuals that follow music: `const env = await audioEnvelope('music.mp3', { frameRate })` before `movie.init`, then `keyframes: react(env, { duration, props: { scale: { base: 1, amount: 0.4, band: 'bass', release: 0.2 } } })`; `beats: true` pulses on the beats; `bpmEnvelope(120, …)` when there is no file), `deck()` + `Presenter` (a talk as one movie: pages, steps, stops that pause playback, arrow keys / click / swipe to move on: `...deck({ transition: { kind: 'slide', duration: 0.6 }, pages: [{ name, duration, stops, sequences }] })`, `new Presenter(movie, { canvas })` from `pixi-effects/presenter`; G = page overview, P = presenter view with notes and a timer; `pixi-effects-render talk.html -o talk.pdf` = a PDF of the pages), `particles()` (bursts, snow, confetti, sparks as seeded layers: `...particles({ count: 90, at: 2, life: [1, 1.8], area: { x: 640, y: 330 }, speed: [120, 420], gravity: 240, fade: { out: 0.6 } })`), `followPath()` (a layer along an SVG path: `keyframes: followPath({ d, duration, orient: true })`), path morphing (`{ shape: 'path', d, morphTo, initial/keyframes: morph 0 → 1 }`) and `stagger()` (delays for a wave of layers: `stagger(letters, { each: 0.05, from: 'center' })`, or `stagger(n, …)` for numbers; GSAP-style `each` / `amount` / `from` / `grid` / `ease`). **Not in the DSL (hand-roll with a JS loop):** other per-frame curves (waves, spirals: sample them into short linear keyframes). (Draw-on strokes `trimEnd`, text that changes over time `set: { text }` / `visibleChars`, shake `wiggle()` and particles are built in.)
44
+ - **Shader layer and warp:** `{ type: 'shader', fragment: 'void mainImage(out vec4 fragColor, in vec2 fragCoord) { … }', uniforms: { speed: 1, tint: '#ff8040' } }` draws a Shadertoy-style formula (GLSL ES 3.00; `iTime` `iResolution` `iFrame` are declared; keyframes move `'uniforms.speed'`; opaque by default; it cannot read the layers behind it; a compile error comes back in your own line numbers). `filters: [{ type: 'warp', kind: 'wave' | 'haze', strength: 6 }]` bends a layer's picture like water or heat.
45
+ - **Light, shadow, fog (2.5D):** a `{ type: 'light', kind: 'ambient', initial: { intensity: 0.2 } }` plus a `spot` / `point` / `parallel` light (`castsShadows: true` on the light AND on the `threeD` layers that cast) shades every `threeD` layer by angle and distance; `lit: false` opts a layer out; camera `fogNear` / `fogFar` / `fogColor` fade what is far. What no light reaches is black (add the ambient light); layers are drawn in distance order (no depth buffer). `check --at` prints how lit each layer is.
46
+ - **Blend modes:** `blendMode` takes 18 CSS names (`add` `screen` `multiply` `overlay` `soft-light` `hard-light` `color-dodge` `color-burn` `darken` `lighten` `difference` `exclusion` `hue` `saturation` `color` `luminosity` `linear-burn`, hyphenated) on any layer: glows and light leaks (`add`, `color-dodge`), colour casts and contrast (`soft-light`, `overlay`), vignettes (`multiply`). It blends with everything below the layer; on a composition each child blends (as one picture: `threeD: true` or a filter); 10+ of the new modes on screen at once warn; it works with every mask.
43
47
  - **One `Movie` per page.** A sound effect needs no `duration` (it lasts as long as its clip). Seeking is safe: counters, colours and growing shapes are right after any jump.
44
48
 
45
49
  ## Layout sanity (the cheap bugs a screenshot catches)
@@ -48,17 +52,17 @@ Centred text must be positioned by `anchorX/anchorY: 0.5`; estimate text width a
48
52
 
49
53
  ## Files
50
54
 
51
- - `CHAT.md` and `chat-template.html` — the short version for an AI in a chat with no shell: one self-contained page that loads from a single CDN, shows warnings in a red box, and a worked example.
55
+ - For an AI in a chat with no shell, the short version is https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/CHAT.md (it copies https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/chat-template.html: one self-contained page that loads from a single CDN and shows warnings in a red box).
52
56
  - `reference/cheatsheet.md` — every layer type, prop, default and rule on one page.
53
57
  - `reference/recipes.md` — tested building blocks: slam type, lower-third, marquee, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions + music, looping, particles, gradients/vignette, three.js metal.
54
58
  - `reference/pitfalls.md` — the full list of real mistakes, with status.
55
- - `tools/check.mjs` — the one-command review (see Workflow step 5).
56
- - `template.html` — the starting file. (It loads the library from a CDN; when testing against a local build, point the three `pixi-effects*` importmap entries at `../../dist/…`.)
57
- - `tools/save-image.py` — save a `dataURL` result (contact sheet / snapshot) to a PNG.
58
- - Full reference: `docs/dsl.md` and `docs/api.md` in the repository.
59
+ - `npx pixi-effects check` — the one-command review (see Workflow step 5).
60
+ - `template.html` — the starting file. (It loads the released library from a CDN, so it works from any folder.)
61
+ - `scripts/save-image.py` — save a `dataURL` result (contact sheet / snapshot) to a PNG.
62
+ - Full reference: https://yjmtmtk.github.io/pixi-effects/docs/dsl.md and https://yjmtmtk.github.io/pixi-effects/docs/api.md
59
63
 
60
64
 
61
- <!-- ===== ai/reference/cheatsheet.md ===== -->
65
+ <!-- ===== skills/pixi-effects/reference/cheatsheet.md ===== -->
62
66
 
63
67
  # pixi-effects cheatsheet
64
68
 
@@ -71,7 +75,7 @@ A video is **data**: a tree of plain objects. Everything below is a JS object yo
71
75
  | Canvas | pixels, origin top-left, `+x` right, `+y` down. Typical: 1280×720 @ 30 fps |
72
76
  | Angles | **degrees**: `rotation`, `skew*`, `rotationX`, `rotationY` |
73
77
  | Depth (`z`) | `+z` toward the viewer (bigger, nearer) |
74
- | Numbers | any number may be an **expression string**: `'GW/2 - w/2'`, `'min(W,H)*0.4'` (build-time only; no per-frame time variable). Functions: `min max abs floor ceil round sqrt pow sin cos tan lerp clamp smoothstep mod step`, `PI`, and **seeded** `rand(seed)` (0…1) / `noise(x, seed)` (−1…1): give each layer its own seed, never `Math.random()` |
78
+ | Numbers | any number may be an **expression string**: `'GW/2 - w/2'`, `'min(W,H)*0.4'` (build-time only; no per-frame time variable). Functions: `min max abs floor ceil round sqrt pow sin cos tan lerp clamp smoothstep mod step`, `PI`, and **seeded** `rand(seed)` (0…1) / `noise(x, seed)` (−1…1): give each layer its own seed, never `Math.random()`. **No relative values** (`'+=36'` does not exist: write the absolute start, `from: { x: 'GW/2 - 36' }`). Expressions work in layer `x y` and the other numeric props, **not** in `line.from / to`, `polygon.points` or `path.d`: compute those in JS (`const W = 1920`) |
75
79
  | Colours | `'#rrggbb'` strings or numbers; text colour is `style.fill`, shape colour is `fillColor`/`strokeColor`, image colour is `tint` |
76
80
  | Z-order | **array order**, later = on top. A layer is hidden (not removed) outside `[at, at+duration)` and keeps its last values |
77
81
  | Time of a sequence's `at` | seconds from the start of its **parent composition** (default 0) |
@@ -99,7 +103,7 @@ name, at, duration,
99
103
  initial: { ...props applied before any keyframe },
100
104
  keyframes: [ { at, duration, ease, set | to | from | (from + to), repeat?, yoyo?, repeatDelay? } ],
101
105
  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> } ],
102
- 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
106
+ mask: <a layer spec> | 'matteName' | { layer: 'matteName', channel: 'alpha' | 'luma', invert: true } | [ …refs ], maskInverted, // an inline 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. A NAME points at a sibling layer (the matte, shared by any number of layers, NOT drawn itself, alive for its own `at`/`duration`); a list is an intersection; subtract = `invert: true`
103
107
  filterArea: { x, y, width, height } // in the layer's OWN coordinates; lets blur/glow draw past the layer's bounds
104
108
  threeD: true // opt into 2.5D (see below)
105
109
  blendMode: 'normal' | 'add' | 'screen' | 'multiply' | 'overlay' | 'soft-light' | 'hard-light' | 'color-dodge' | 'color-burn' | 'darken' | 'lighten' | 'difference' | 'exclusion' | 'hue' | 'saturation' | 'color' | 'luminosity' | 'linear-burn' // CSS names (hyphenated); blends with EVERYTHING BELOW the layer; add / screen brighten (glows), multiply darkens, soft-light / overlay for colour casts and contrast, color-dodge for light sources; on a composition the children each blend (to blend it as one picture: threeD: true or a filter); the 14 new ones cost ~2 ms a layer on WebGL (10+ on screen warns)
@@ -135,10 +139,12 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
135
139
 
136
140
  **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. A `path` with several sub-paths walks them in order as one length; `trimEach: true` trims each on its own (all draw on together). Use `strokeCap: 'round'`. **Dashes:** there is no dash option; write the outline as alternating sub-paths (`d: 'M 0 0 L 10 0 M 20 0 L 30 0 …'`, or sample the curve) and `trimEnd` 0 → 1 walks them in order, so the dashes draw on one after another (as in `replay-highlight`).
137
141
 
138
- 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 the SHAPE's own bounds, not the canvas: a glow on a 1500 px circle is placed with fractions of that circle */ }` — stops may have alpha, so a radial transparent→dark is a vignette. **Animate it** with a partial gradient in a keyframe (`to: { fillGradient: { angle: 360, stops: [...] } }`: keys you leave out stay; the same number of stops; `type` cannot change; a start gradient is required; use `colorSpace: 'oklch'` for a vivid fade). A text layer takes `fillGradient` too (LINEAR only: angle and stops; type / center / radius are ignored with a warning; letters re-rasterised per change). `from` alone runs from that gradient to the layer's own. The names `'fillGradient.angle'` / `gradientAngle` are not properties: write `fillGradient: { angle }`. 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).
142
+ 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; DEFAULT 90 = top→bottom, 0 = left→right: a wide gradient slid sideways as a matte needs angle: 0 */, center?, innerRadius?, radius? /* radial, 0–1 of the SHAPE's own bounds, not the canvas: a glow on a 1500 px circle is placed with fractions of that circle */ }` — stops may have alpha, so a radial transparent→dark is a vignette. **Animate it** with a partial gradient in a keyframe (`to: { fillGradient: { angle: 360, stops: [...] } }`: keys you leave out stay; the same number of stops; `type` cannot change; a start gradient is required; use `colorSpace: 'oklch'` for a vivid fade). A text layer takes `fillGradient` too (LINEAR only: angle and stops; type / center / radius are ignored with a warning; letters re-rasterised per change). `from` alone runs from that gradient to the layer's own. The names `'fillGradient.angle'` / `gradientAngle` are not properties: write `fillGradient: { angle }`. 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).
139
143
 
140
144
  **composition** — `width height duration sequences transitions`, **`speed`** (the composition's own time: its content plays backward, in slow motion, … — `time` keyframes give a freeze, a rewind, a ramp; a composition's sound and sfx follow; its own x / alpha keyframes stay in the outer time; **children are placed in the composition's local seconds**; the content is as long as the larger of `duration` and the farthest `time` it reaches (`speed: 2` → twice `duration`; `time` keyframes → their largest number), and children default to that length. A child whose life ends exactly where a held `time` stands is hidden during the hold (a life is [at, at + duration)): give it a `duration` that reaches past. `time` keyframes and ordinary ones (`x`, `alpha`, a `wiggle`) may share one `keyframes` list. A sound inside goes silent while the clock stands still). 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.
141
145
 
146
+ **shader** — `{ type: 'shader', fragment: 'void mainImage(out vec4 fragColor, in vec2 fragCoord) { … }', uniforms: { speed: 1, tint: '#ff8040', pos: [0.5, 0.5] }, width?, height?, resolution?: 0.5, transparent?: false }` — a Shadertoy fragment shader drawn into a layer (GLSL ES 3.00): `iResolution` `iTime` (the layer's own seconds) `iFrame` and your uniforms are declared for you; keyframes move uniforms as `'uniforms.speed'` and components as `'uniforms.tint.0'`; opaque unless `transparent: true`; it cannot read what is behind it (no `iChannel` / `iMouse` / `iDate`); no `while` or non-constant `for`; a compile error is reported in your own line numbers and the layer is a checkerboard.
147
+
142
148
  **null + `parent`** — `{ type: 'null', name: 'rig', initial: { x, y }, keyframes: [...] }` draws nothing; layers with `parent: 'rig'` are drawn inside it and follow its `x y rotation scale skew alpha pivotX pivotY`. Their `x / y` are measured **from the null's origin** (null at (640,360), child `x: 200` → drawn at 840), so rotating the null makes the child circle it. Nulls chain (`parent` on a null). Children are drawn at the null's place in the stack, together. Only `null` layers can be parents; not for `threeD` layers; a mask follows its layer.
143
149
 
144
150
  **camera** (2.5D) — props in `initial`/keyframes: `x y z lookAtX lookAtY lookAtZ fov focus aperture` (defaults: centred, looking at the z = 0 plane, `fov` 40, `z` auto = `(H/2)/tan(fov/2)` ≈ 989 at 720p). **Depth of field:** `focus` = the sharp plane (a `threeD` layer's name = its first z, or a z number), `aperture` = the lens diameter in px (30 default, 0 off); writing either blurs the other `threeD` layers by their distance from the focus (a rack focus is a keyframe on `focus`; recipe `rack-focus`; `inspect` reports `depthBlur`, and `check --at 2.4` prints each layer's blur). A dolly with no typed distance: leave `z` automatic and animate `offsetZ`. Never on the camera object itself. An orbit is `orbit()`; by hand: `x = cx + R·sin θ`, `z = R·cos θ`, `R = (H/2)/tan(fov/2)`, `lookAt` = the centre at `z = 0`.
@@ -151,7 +157,7 @@ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha stro
151
157
 
152
158
  ## 2.5D in one paragraph
153
159
 
154
- 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`.
160
+ 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`. **Light:** `{ type: 'light', kind: 'ambient' | 'point' | 'spot' | 'parallel', falloff?: 'none' | 'smooth' | 'inverseSquare', castsShadows?, initial: { x, y, z, lookAtX, lookAtY, lookAtZ, intensity, color, coneAngle, coneFeather, radius, falloffDistance, shadowDarkness, shadowDiffusion } }` lights every `threeD` layer of the composition (`lit: false` opts out; what no light reaches is black, so add `kind: 'ambient'`); a shadow needs `castsShadows: true` on the light AND on the layer (hard by default, `shadowDiffusion` softens; up to 4 casters per receiver). **Fog:** camera `initial: { fogNear, fogFar, fogColor, fogAmount }` (any one turns it on).
155
161
 
156
162
  ## Poster
157
163
 
@@ -163,15 +169,16 @@ Add `threeD: true` to any visual layer, then use `z`, `rotationX`, `rotationY` (
163
169
 
164
170
  ## Filters by name (no import)
165
171
 
166
- `filters: [{ type: 'glow', name: 'halo', outerStrength: 3, color: '#ffd166' }]` — `type` = the pixi / pixi-filters class name without `Filter`, camelCase, or this library's own **`grain`** (film grain: `{ type: 'grain', amount: 0.08, size: 1.5, seed: 0, fps: 24, color: 0 }`, seeded, new every 1/fps s, mid-tone weighted, same grain pattern on every GPU (values within 1/255), anchored to the filter's frame (it moves with a layer), no pixi-filters needed; on a layer or on `composition.filters`; it costs video bitrate; `noise` is NOT film grain: it is frozen and lifts blacks) (`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.
172
+ `filters: [{ type: 'glow', name: 'halo', outerStrength: 3, color: '#ffd166' }]` — `type` = the pixi / pixi-filters class name without `Filter`, camelCase, or this library's own **`warp`** (bends the picture: `{ type: 'warp', kind: 'wave' | 'haze', strength: 6 (px), scale: 80 (px), speed: 0.5, angle: 0, seed: 0 }`; `wave` = a travelling wave, `haze` = drifting noise; moves with the frame's time by itself; options animate by name) or **`grain`** (film grain: `{ type: 'grain', amount: 0.08, size: 1.5, seed: 0, fps: 24, color: 0 }`, seeded, new every 1/fps s, mid-tone weighted, same grain pattern on every GPU (values within 1/255), anchored to the filter's frame (it moves with a layer), no pixi-filters needed; on a layer or on `composition.filters`; it costs video bitrate; `noise` is NOT film grain: it is frozen and lifts blacks) (`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: https://yjmtmtk.github.io/pixi-effects/docs/dsl.md → Named filters.
167
173
 
168
174
  ## Transitions (on the parent composition)
169
175
 
170
176
  ```js
171
- transitions: [{ kind: 'crossfade' | 'wipe' | 'iris' | 'slide' | 'dip' | 'zoom' | 'dissolve',
177
+ transitions: [{ kind: 'crossfade' | 'wipe' | 'iris' | 'slide' | 'dip' | 'zoom' | 'dissolve' | 'luma',
172
178
  from: 'nameA', to: 'nameB', at: 3, duration: 1, ease: 'none',
173
179
  direction: 'left|right|up|down' /* wipe, slide */, mode: 'in|out' /* iris, zoom */,
174
- smoothing, fromScale /* zoom */, scale, seed /* dissolve */ }]
180
+ smoothing, fromScale /* zoom */, scale, seed /* dissolve */,
181
+ map: 'linear' | 'diagonal' | 'radial' | '<grayscale image asset>', softness, flip /* luma */ }]
175
182
  ```
176
183
  `from`/`to` are sibling layer `name`s, `to` declared after `from`, and both layers must be alive for the whole window (overlap them by `duration`). Only those two layers are affected; every other layer stays put. Wipe/iris/dissolve/zoom wrap the layers for you.
177
184
 
@@ -241,13 +248,13 @@ new Controller(movie, { canvas }) // optional player bar (overlay
241
248
  ```
242
249
 
243
250
 
244
- <!-- ===== ai/reference/pitfalls.md ===== -->
251
+ <!-- ===== skills/pixi-effects/reference/pitfalls.md ===== -->
245
252
 
246
253
  # pixi-effects pitfalls — what actually went wrong
247
254
 
248
255
  Collected by having fresh AI sessions build pieces from the docs alone: first six (kinetic type, news lower-third, slideshow, 2.5D showcase, three.js title, bar chart), then a portfolio of 16 more (items 32 and up), made by three different models. Each entry is a real stumble. **Status**: `fixed` = the library now handles or warns; `docs` = nothing can catch it, so remember it; `hand-roll` = the DSL has no feature, see the workaround.
249
256
 
250
- A good habit that catches most of these: after `init`, read `window.__logs` (see `ai/template.html`) — every pixi-effects warning says what to change.
257
+ A good habit that catches most of these: after `init`, read `window.__logs` (see `template.html`) — every pixi-effects warning says what to change.
251
258
 
252
259
  ## Time
253
260
 
@@ -317,9 +324,9 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
317
324
  43. **Layers built in a loop need their z-order planned**: anything pushed to the array after the loop is drawn over what the loop made (rings crossed over a header). Keyframes on different properties of one layer may overlap in time. Keep a subject in the upper ~60 % of a source image if a caption will sit under it (`inspect` does not look at picture content). *(docs)*
318
325
  44. **Zero-pad a counter with `format: { pad: 2 }`** (`18:42:{value}` shows `18:42:05`). A `{value}` placeholder that nothing sets or animates prints 0 and now warns: `{value}` is reserved, there is no way to print it literally. *(fixed)*
319
326
  45. **The same mistake in a loop warns once**: the warning "keyframe starts after the layer ends" is summarised after three layers. The usual cause is an off-by-one (`f <= frames` instead of `f < frames`) that puts the last sample exactly at the layer's end. *(fixed)*
320
- 46. **Still not in the DSL (hand-roll):** shared masks (one mask for several layers), `zIndex` (stack order is list order). *(Earlier round-two notes listed draw-on strokes, changing text, jitter and particle emitters here: they are built in now: `trimEnd`, `set: { text }` / `visibleChars`, `wiggle()`, `particles()`.)*
327
+ 46. **Still not in the DSL (hand-roll):** `zIndex` (stack order is list order). *(Earlier round-two notes listed draw-on strokes, changing text, jitter and particle emitters here: they are built in now: `trimEnd`, `set: { text }` / `visibleChars`, `wiggle()`, `particles()`.)*
321
328
  46b. **Blend modes exist now** (`blendMode`: `add` `screen` `multiply` and, since 0.22, 15 more CSS names (pitfalls 78–80), on any layer, threeD layers and compositions included; a composition's children inherit it). Additive white cores no longer read grey on a coloured background. It also works on a layer with `filters` (a blurred glow with `add` / `multiply`): the mode is applied by the last filter, because a container's blend inside a filter pass multiplied against nothing and rendered black. With `add`, use dim saturated colours: overlaps sum, and near-white sums to pure white. *(fixed)*
322
- 47. **Browser tooling: prefer `node ai/tools/check.mjs page.html`** — it runs its own private Chrome, so none of the following can bite. If you drive a browser by hand instead: the `agent-browser` daemon is shared, so a `contactSheet` can come back from another session: after saving an image, look at it and confirm it is YOUR piece. In zsh a variable holding `agent-browser --session x` is not split into words (use a small wrapper script). `inspect` now clips overlaps to the visible area, ignores the two scenes of a running transition, treats text inside a moving (panned) composition as moving, and does not report text scaled to 0 as "no size"; it still does not compare text with picture content or shapes.
329
+ 47. **Browser tooling: prefer `npx pixi-effects check page.html`** — it runs its own private Chrome, so none of the following can bite. If you drive a browser by hand instead: the `agent-browser` daemon is shared, so a `contactSheet` can come back from another session: after saving an image, look at it and confirm it is YOUR piece. In zsh a variable holding `agent-browser --session x` is not split into words (use a small wrapper script). `inspect` now clips overlaps to the visible area, ignores the two scenes of a running transition, treats text inside a moving (panned) composition as moving, and does not report text scaled to 0 as "no size"; it still does not compare text with picture content or shapes.
323
330
  48. **A composition's `alpha` is applied to each child separately, not to the flattened group** (measured: two overlapping opaque circles in a composition at `alpha: 0.5` show the lower one through the upper one in the overlap). Fading a group that has overlapping children therefore shows the overlap; fade the children themselves, or keep overlapping parts out of the group. Expressions have no way to read a sibling's size: measure text with `canvas.measureText` (the font string Pixi uses) and compute pill widths in JS. *(docs)*
324
331
  49. **sfx timing and level.** An sfx layer's `duration` is the sound's length (it stretches, it does not trim). Align the loud point: a `riser` peaks at its end, a `swoosh` 0.16 s in. Many sounds at the same instant add up: above 0.9 the mix is limited and a warning names the layers to turn down. Preset names, knobs and voice fields are closed lists — a guess (`whoosh`, `ding`, `impact`, `sawtooth`, `lp`, `decay`) warns with the right word. *(fixed: warns)*
325
332
  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)*
@@ -341,7 +348,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
341
348
  66. **Animating a gradient: one `fillGradient` object, partial, in the keyframe.** `to: { fillGradient: { angle: 360, stops: [...] } }` (keys you leave out stay). Not `'fillGradient.angle'`, not `gradientAngle`, not `fillGradient: 'red'` (each warns once with what to write). The layer must start with a `fillGradient`; `stops` keep their count; `type` cannot change. A fade between two colours in the default `rgb` passes through grey (green → magenta): set `colorSpace: 'oklch'`. Under a mask the stops still span the whole shape (see 39).
342
349
  67. **A key the layer does not have is warned about now (it used to be ignored in silence).** `{ type: 'text', anchorX: 0.5 }` (anchors go in `initial`), `{ type: 'audio', sfx: 'click', pitch: 3 }` (the knob goes inside: `sfx: { preset: 'click', pitch: 3 }`), `{ type: 'shape', shape: 'rect', radius: 5 }` (that is a circle's key; a rect has `cornerRadius`), a mistyped property in a keyframe (`alhpa`), a mistyped text `style` key. The warning names the key, the kind of layer and what was probably meant; fix the key, do not silence the warning. Three gallery pieces had the `pitch` mistake for months: their sound effects were never pitched.
343
350
  68. **A pitched sfx keeps its own length; a radial gradient is placed in the shape's bounds.** `sfx: { preset: 'chime', pitch: -5 }` lasts as long as the sound does, so near the end of the movie give the layer a `duration` that ends with it (`inspectAudio()` reports a sound cut off by the end). A radial `fillGradient`'s `center` / `radius` are fractions of the SHAPE, not the canvas: for a glow on a big circle, convert canvas pixels to fractions of the circle. `music.reverb` is 0–0.6.
344
- 69. **Inside a remapped composition, `at` is in THAT composition's seconds.** With `speed: 0.5` a 4 s composition shows only 2 s of its content; a layer at `at: 3` inside is never seen (warns: "its clock only reaches 0–2s"). With `speed: 2` the content is 8 s long, so children default to 8 s and `at: 5` is fine. The composition's own `at`, `duration`, x / alpha keyframes are outer time. To freeze, hold `time` at one value (`to` the same number); to rewind, `to: { time: 0 }`. A sound inside goes silent while the clock stands still, and an `sfx` longer than the clock's reach is reported as cut off by the clock: give it a shorter `duration` (the sfx limit is 10 s) or let the clock run on. A child whose life ends exactly where a held `time` stands is hidden during the hold: give it a `duration` that reaches past (the content is as long as the larger of `duration` and the largest `time`).
351
+ 69. **Inside a remapped composition, `at` is in THAT composition's seconds.** With `speed: 0.5` a 4 s composition shows only 2 s of its content; a layer at `at: 3` inside is never seen (warns: "its clock only reaches 0–2s"). With `speed: 2` the content is 8 s long, so children default to 8 s and `at: 5` is fine. The composition's own `at`, `duration`, x / alpha keyframes are outer time. To freeze, hold `time` at one value (`to` the same number); to rewind, `to: { time: 0 }`. A sound inside goes silent while the clock stands still, and an `sfx` longer than the clock's reach is reported as cut off by the clock: give it a shorter `duration` (the sfx limit is 10 s) or let the clock run on. A child whose life ends exactly where a held `time` stands is hidden during the hold: give it a `duration` that reaches past (the content is as long as the larger of `duration` and the largest `time`). **With `time` keyframes the composition's own `duration` is its life in OUTER time and must reach the last keyframe** (`keyframes[1] starts at 2.3s, after the layer ends` means `duration` is too short): `at` of a `time` keyframe counts from the composition's start, the content length is a separate thing that comes out of the `time` values.
345
352
  70. **`speed` is a setting, `time` is the property.** `speed: 2` on the layer; to change the speed over time animate `time` (a curve of seconds in the file), never `keyframes: [{ to: { speed: 3 } }]` (warns). `speed: 0` is not allowed (hold a moment with `time` to the same value twice); `speed` together with keyframed `time`: `time` wins. Other names (`playbackRate`, `timeScale`, `rate`, `reverse: true`, `timeRemap`) are warned about with what to write. `particles({ speed })` is something else: launch speed in px per second.
346
353
  71. **Sound follows `speed` like a tape: the pitch moves with it.** An audio file or a video's sound at `speed: 2` is an octave up; backward plays reversed. Not for `sfx` / `music` layers (warns): change an sfx with `pitch` (semitones), or put the sfx **inside a remapped composition** and it follows that clock (slower and lower, or backward).
347
354
  72. **A video at `speed: 2` with no `duration` is half as long; with a `duration` longer than the file it holds the last frame** (add `loop: true` to repeat). A negative `time` counts back from the end of the file (`initial: { time: -2 }` = two seconds before the end).
@@ -353,9 +360,27 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
353
360
  78. **A blend mode mixes the layer with everything BELOW it, in the order of the array.** The backdrop goes first, the blended layer after it; a layer placed before its backdrop blends with nothing. Names are CSS names with the hyphen: `soft-light`, not `softLight` (the warning says which you meant).
354
361
  79. **On a plain `composition` a `blendMode` makes each child blend by itself, not the composition as one picture.** To blend the whole group give the composition `threeD: true` (z 0, default camera: nothing else changes) or a filter. A text, image or video layer as the (not inverted) `mask` cannot carry a blend (it warns, draws normal); a shape mask and `maskInverted` work.
355
362
  80. **`soft-light` / `overlay` do almost nothing over near-black or near-white; `color-dodge` over dark gives a saturated colour, not white; `blendMode` cannot be keyframed** (use a second short-lived layer). **The 14 new modes cost about 2 ms a layer on WebGL; 10 or more on screen at once warns.** A glow, a haze or a colour cast is ONE layer with a gradient (`fillGradient`), not dozens of circles; keep `add` / `screen` / `multiply` for big piles of particles.
356
-
357
-
358
- <!-- ===== ai/reference/recipes.md ===== -->
363
+ 81. **A layer named in `mask` is a matte: it is not drawn, and it must be alive whenever the layers it cuts are.** `{ name: 'band', … }` plus `mask: 'band'` on the text hides the band itself; the matte's time is the composition's time (its own `at` / `duration`). Where the matte is missing the layers it cuts are invisible: the build warns when its lifespan does not cover its users. To show the band as well, add a second layer that looks the same.
364
+ 82. **A `mask` name works inside one composition only.** The matte and the layers that name it are siblings in the same `sequences`. A layer inside a nested composition cannot name a matte outside it (warns, with the closest name if there is one, and the layer is drawn without the matte); a layer that a transition wraps keeps its matte. Put the matte inside the nested composition, or the layer next to it.
365
+ 83. **`channel: 'luma'` reads brightness, `'alpha'` (the default) reads opacity.** A white shape is 1 for both, a black shape is 0 for luma and 1 for alpha, a grey `#808080` is 0.5 luma. Outside the matte layer's own shape both are 0, so a wide gradient rectangle slid across the layers is a soft moving reveal. Use `luma` with a grayscale image or a gradient layer; use the default for shapes and text. `luminance`, `brightness` and `grayscale` are not channel names: they warn ("did you mean luma?") and alpha is used.
366
+ 84. **Subtract with `invert: true` in a list; a list multiplies.** `mask: ['panel', { layer: 'hole', invert: true }]` is "inside `panel`, outside `hole`". `maskInverted: true` is for an inline mask only: with a named matte it warns and tells you to write `invert: true`. A luma wipe's `map` is a built-in name (`linear` `diagonal` `radial`) or a grayscale image asset; an unknown name warns once and wipes as if the map were white.
367
+ 85. **One light layer lights every `threeD` layer, and what no light reaches is black.** Add `{ type: 'light', kind: 'ambient', initial: { intensity: 0.2 } }` (a white ambient light at 1 is the same as no light). A layer you want flat-lit is `lit: false`. Layers without `threeD` are never lit, and a light only counts while it is alive (give it the scene's `at` / `duration`): with no light alive the layers are drawn unlit.
368
+ 86. **A shadow needs `castsShadows: true` on the light AND on the layer.** Either alone shows nothing (the build says which half is missing). The caster has to be a `threeD` layer in front of the receiver (between it and the light); a receiver takes at most 4 casters. A hard shadow can look like a dark slab: `shadowDiffusion: 10`–`20` softens it, and soft shadows cost the most, so use them on one or two lights. A spot's pool on the wall has radius about `distance × tan(coneAngle / 2)` and can hide behind the card that stands in front of it.
369
+ 87. **The light's numbers go in `initial` / `keyframes`; `kind`, `falloff` and `castsShadows` are on the layer.** `{ type: 'light', intensity: 1 }` warns; so do `target` / `pointOfInterest` (use `lookAtX / lookAtY / lookAtZ`), `angle` (`coneAngle`), `castShadows` (`castsShadows`) and `intensity: 80` (it is a multiplier, 1 = full, not a percentage). A spot needs a look-at point (it defaults to the middle of the picture).
370
+ 88. **A room is layers in distance order, not geometry.** There is no depth buffer: a floor or wall is a big `threeD` layer whose origin is at its far edge (`anchorY: 0` / a `z` behind what stands on it), and planes that cross cannot be drawn. Each composition has its own lights: a light in a nested composition lights only that composition's layers. Fog is the camera's (`fogNear` / `fogFar` / `fogColor`), fades `threeD` layers only, so draw the background in the fog colour.
371
+ 89. **A shader layer is written like Shadertoy, in GLSL ES 3.00: `mainImage` and `fragColor`.** Not `void main()`, `gl_FragColor`, `texture2D`, `varying`, `#version` or a `uniform float iTime;` (each warns with what to write). `iTime` is the layer's own seconds from its start (not the movie's), `fragCoord` starts at the bottom left, the colour is opaque unless `transparent: true`, and the layer cannot read what is behind it (it is a layer, not a filter): there is no `iChannel0`, `iMouse` or `iDate` in this version.
372
+ 90. **`uniforms` are declared for you; keyframes name them `'uniforms.speed'`, and vectors and colours by component `'uniforms.tint.0'`.** `uniforms: { tint: '#ff8040' }` is a `vec3` of 0..1, so animate its channels, not the string. Declare every uniform you animate with a starting value (a keyframe on a name that is not there warns with the likely name). Do not write `uniform float speed;` yourself.
373
+ 91. **A shader that does not compile is a magenta checkerboard, and the warning has your line numbers.** Fix the line it names. Loops need a constant bound (`for (int i = 0; i < 8; i++)`): a `while`, or a bound that is a variable, can hang the GPU and warns. A heavy shader is cheaper with `resolution: 0.5`; many small shader layers cost more than one big one. A mask or matte cuts what the shader drew, it does not move the pattern: put the pattern's centre (a `vec2` uniform) where the matte is. A shader layer with a small `width` / `height` sits by its top-left corner at `initial.x / y`. With `resolution` below 1 the shader's own pixels are fewer: write positions as `fragCoord / iResolution.xy`, or multiply pixel uniforms by `resolution`. A shader can itself be a mask (`mask: { type: 'shader', transparent: true, fragment }`: its alpha is the mask).
374
+ 92. **`warp` is for bending a layer's picture, a `shader` layer is for drawing something new.** `warp` takes `strength` in px (a ripple is 4 to 10, over 200 is refused) and `scale` in px; `speed: 0` stands still. It follows the time of the frame by itself: do not keyframe a time. Its options animate by the filter's name (`'filters.ripple.strength'`). For a swirl or a lens use `twist` or `bulgePinch`.
375
+ 93. **A `threeD` layer's shadow budget is four casters per receiver, counted in layer order, not geometry.** Five letters as five `castsShadows` layers over one floor: the fifth casts nothing (the warning names it). Make the word ONE text layer (the light is per pixel, each letter is still lit on its own) or give only the four that matter `castsShadows`. Splitting the floor does not help: each half takes the first four.
376
+ 94. **`resolution` below 1 softens crisp lines in a shader.** `fwidth` anti-aliased edges come out a little blurry after the upscale: keep `resolution: 1` for line art and use 0.5 only for soft fields (flowing colour, glow).
377
+ 95. **A `color-dodge` leak over a dark picture is a muddy red disc.** Dodge divides each channel of near-black separately, red first. Cut the dodge to the bright subject with a matte (`mask: 'letters'`, a matte-only copy of the word) and put the warm haze in a separate `screen` radial.
378
+ 96. **A filter option that is a point (`rgbSplit.red`, `glitch` offsets) cannot be tweened as a whole**, but a scalar can: `set: { 'filters.tear.seed': n }` steps `glitch.seed` (a `set` keyframe works on filter options; no warning, visible tearing). To switch between looks on one subject, put the subject in time-windowed `composition` copies, each carrying the filters of its look.
379
+ 97. **A deck inside your own composition loses its `transitions` and `stops`** unless you take them across: `const d = deck({ width: 1920, height: 1080, pages })`, then `{ type: 'composition', sequences: d.composition.sequences, transitions: d.composition.transitions }`. Pass `width` / `height` yourself for a page that is not the movie's own size.
380
+ 98. **A matte layer needs a fill.** A named matte (`mask: 'glass'`) that is a shape with no `fillColor` / `fillGradient` (or a text with no fill) cuts everything away and nothing warns: the layers it should show simply vanish. Give the matte a fill (white for `alpha`, a gradient for `luma`).
381
+
382
+
383
+ <!-- ===== skills/pixi-effects/reference/recipes.md ===== -->
359
384
 
360
385
  # pixi-effects recipes
361
386
 
@@ -1136,7 +1161,7 @@ return [
1136
1161
 
1137
1162
  ## Light leaks and colour casts (blend modes)
1138
1163
 
1139
- `blendMode` mixes a layer with everything **below** it. A colour cast is one `soft-light` gradient over the whole picture, a light leak is a `color-dodge` glow that drifts across it, a vignette is a `multiply` radial gradient that goes from clear to dark. Each is ONE layer (a gradient), not a pile of shapes: the blend modes beyond `add` / `screen` / `multiply` cost a full-frame pass each.
1164
+ `blendMode` mixes a layer with everything **below** it. A colour cast is one `soft-light` gradient over the whole picture, a light leak is a `color-dodge` glow that drifts across it, a vignette is a `multiply` radial gradient that goes from clear to dark. Each is ONE layer (a gradient), not a pile of shapes: the blend modes beyond `add` / `screen` / `multiply` cost a full-frame pass each. Over a dark picture a `color-dodge` glow turns the near-black into a muddy red disc: cut it to the bright subject with a matte (`mask: 'letters'`) and put the warm haze in a separate `screen` radial.
1140
1165
 
1141
1166
  ```js
1142
1167
  // @recipe light-leak
@@ -1164,6 +1189,137 @@ return { duration: 6, sequences: [
1164
1189
 
1165
1190
  ---
1166
1191
 
1192
+ ## A matte shared by several layers (`mask: 'name'`)
1193
+
1194
+ Name a layer and write its name as the `mask` of the layers it should cut: the matte is drawn once and **not shown itself**. Here one band sweeps across three lines of text; one line is cut by brightness (`luma`) and one by the opposite of the band (`invert`), so it fills in where the others are cut.
1195
+
1196
+ ```js
1197
+ // @recipe shared-matte
1198
+ const line = (y, text, mask, extra = {}) => ({
1199
+ type: 'text', text, mask, style: { fontSize: 120, fill: '#ffffff', fontWeight: 'bold' },
1200
+ initial: { x: 640, y, anchorX: 0.5, anchorY: 0.5 }, ...extra,
1201
+ });
1202
+ return { duration: 5, sequences: [
1203
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', anchorX: 0, anchorY: 0, initial: { x: 0, y: 0, fillColor: '#101626' } },
1204
+ // the matte: a bright band that grows to the right; it lives as long as the layers it cuts
1205
+ { type: 'shape', shape: 'rect', name: 'band', anchorX: 0, anchorY: 0, width: 0, height: 720,
1206
+ initial: { x: 140, y: 0, fillColor: '#ffffff' },
1207
+ keyframes: [{ at: 0.4, to: { width: 1000 }, duration: 2, ease: 'power2.inOut' }] },
1208
+ line(200, 'ONE MATTE', 'band'),
1209
+ line(360, 'SHARED BY', { layer: 'band', channel: 'luma' }),
1210
+ line(520, 'THREE LAYERS', { layer: 'band', invert: true }, { initial: { x: 640, y: 520, anchorX: 0.5, anchorY: 0.5, fillColor: '#ffd166' } }),
1211
+ ] };
1212
+ ```
1213
+
1214
+ ---
1215
+
1216
+ ## A luma wipe (a transition that follows a brightness map)
1217
+
1218
+ `{ kind: 'luma', map: 'radial' }` opens the next scene from the middle; `'linear'` sweeps left to right, `'diagonal'` corner to corner, or give the name of a grayscale image asset (dark parts change first). `softness` is the width of the soft edge and `flip` reverses the direction.
1219
+
1220
+ ```js
1221
+ // @recipe luma-wipe
1222
+ const scene = (name, at, color, label) => ({
1223
+ type: 'composition', name, at, duration: 4, width: 1280, height: 720,
1224
+ sequences: [
1225
+ { type: 'shape', shape: 'rect', width: 1280, height: 720, initial: { x: 640, y: 360, fillColor: color } },
1226
+ { type: 'text', text: label, style: { fontSize: 120, fill: '#ffffff', fontWeight: 'bold' }, initial: { x: 640, y: 360, anchorX: 0.5, anchorY: 0.5 } },
1227
+ ],
1228
+ });
1229
+ return {
1230
+ sequences: [scene('a', 0, '#3b5bdb', 'One'), scene('b', 3, '#e64980', 'Two')], // they overlap for 1 s: 3 to 4
1231
+ transitions: [{ kind: 'luma', from: 'a', to: 'b', at: 3, duration: 1, map: 'radial', softness: 0.15 }],
1232
+ duration: 7,
1233
+ };
1234
+ ```
1235
+
1236
+ ---
1237
+
1238
+ ## A spotlight across a dark room (light and shadow)
1239
+
1240
+ An ambient light keeps the room from going black, a `spot` light sweeps across three cards and they throw shadows on the wall behind them. The shadow needs `castsShadows` on the light **and** on each card; `lookAtX` moves the beam and `x` moves the lamp with it.
1241
+
1242
+ ```js
1243
+ // @recipe spot-room
1244
+ const card = (name, x, fill) => ({
1245
+ type: 'shape', shape: 'rect', name, width: 300, height: 200, anchorX: 0.5, anchorY: 0.5, threeD: true, castsShadows: true,
1246
+ initial: { x, y: 300, z: 0, fillColor: fill },
1247
+ });
1248
+ return { duration: 6, sequences: [
1249
+ { type: 'light', kind: 'ambient', initial: { intensity: 0.18 } },
1250
+ { type: 'light', kind: 'spot', castsShadows: true,
1251
+ initial: { x: 160, y: -120, z: 700, lookAtX: 260, lookAtY: 330, lookAtZ: 0, coneAngle: 34, coneFeather: 0.7, intensity: 1.1, shadowDiffusion: 14 },
1252
+ keyframes: [{ at: 0.5, to: { x: 1100, lookAtX: 1020 }, duration: 4.5, ease: 'sine.inOut' }] },
1253
+ // the wall, big and behind the cards (a room is layers in distance order: no depth buffer)
1254
+ { type: 'shape', shape: 'rect', name: 'wall', width: 1800, height: 900, anchorX: 0.5, anchorY: 0.5, threeD: true, initial: { x: 640, y: 300, z: -260, fillColor: '#6b7280' } },
1255
+ card('a', 300, '#e0a458'), card('b', 640, '#58a4e0'), card('c', 980, '#e05876'),
1256
+ ] };
1257
+ ```
1258
+
1259
+ ---
1260
+
1261
+ ## Fog that thickens with distance (camera fog)
1262
+
1263
+ Fog is the camera's (`fogNear`, `fogFar`, `fogColor`, `fogAmount`): `threeD` layers are mixed toward the fog colour by how far they are. 2D layers are not fogged, so the background is drawn in the fog colour and what is far dissolves into it.
1264
+
1265
+ ```js
1266
+ // @recipe fog-depth
1267
+ const posts = Array.from({ length: 10 }, (_, i) => ({
1268
+ type: 'shape', shape: 'rect', name: 'post-' + i, width: 80, height: 420, anchorX: 0.5, anchorY: 1, threeD: true,
1269
+ initial: { x: 200 + (i % 2) * 880, y: 620, z: -i * 220, fillColor: '#1f2a44' },
1270
+ }));
1271
+ return { duration: 6, sequences: [
1272
+ { type: 'shape', shape: 'rect', name: 'sky', width: 'GW', height: 'GH', anchorX: 0, anchorY: 0, initial: { x: 0, y: 0, fillColor: '#9fb4d6' } },
1273
+ { type: 'camera', initial: { fogNear: 900, fogFar: 2600, fogColor: '#9fb4d6', fogAmount: 0.95 },
1274
+ keyframes: [{ at: 0, to: { offsetZ: -700 }, duration: 6, ease: 'sine.inOut' }] }, // dolly in: the nearer posts come out of the mist
1275
+ ...posts,
1276
+ ] };
1277
+ ```
1278
+
1279
+ ---
1280
+
1281
+ ## A flowing colour field (a `shader` layer)
1282
+
1283
+ A shader is a layer drawn by a formula: write a Shadertoy `mainImage`. `iTime` is the layer's own clock, the `uniforms` are declared for you and keyframes move them (`'uniforms.speed'`, and a component of a colour as `'uniforms.tint.2'`). Opaque by default.
1284
+
1285
+ ```js
1286
+ // @recipe shader-plasma
1287
+ return { duration: 6, sequences: [
1288
+ { type: 'shader', name: 'plasma',
1289
+ fragment: `
1290
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1291
+ vec2 uv = fragCoord / iResolution.xy;
1292
+ float v = sin(uv.x * 9.0 + iTime * speed) + sin(uv.y * 7.0 - iTime * speed * 1.3) + sin((uv.x + uv.y) * 5.0 + iTime);
1293
+ vec3 deep = vec3(0.04, 0.02, 0.18);
1294
+ fragColor = vec4(mix(deep, tint, 0.5 + 0.25 * v), 1.0);
1295
+ }`,
1296
+ uniforms: { tint: '#ff7a3d', speed: 0.6 },
1297
+ keyframes: [{ at: 0, to: { 'uniforms.speed': 2, 'uniforms.tint.2': 0.9 }, duration: 6, ease: 'sine.inOut' }] },
1298
+ { type: 'text', text: 'PLASMA', style: { fontSize: 140, fill: '#ffffff', fontWeight: 'bold', letterSpacing: 12 }, initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 } },
1299
+ ] };
1300
+ ```
1301
+
1302
+ ---
1303
+
1304
+ ## Text seen through water (the `warp` filter)
1305
+
1306
+ `warp` bends a layer's picture: `kind: 'wave'` is a travelling wave (the surface of water, a flag), `kind: 'haze'` is drifting noise (heat shimmer). `strength` is the largest shift in px, `scale` the wavelength in px, `speed` how fast it moves (0 stands still). It follows the frame's time by itself, and its options move with keyframes by the filter's name.
1307
+
1308
+ ```js
1309
+ // @recipe warp-water
1310
+ return { duration: 6, sequences: [
1311
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', anchorX: 0, anchorY: 0, initial: { x: 0, y: 0 },
1312
+ fillGradient: { stops: [[0, '#0b3a5c'], [1, '#021a2e']] } },
1313
+ { type: 'text', text: 'UNDER WATER', style: { fontSize: 120, fill: '#d8f3ff', fontWeight: 'bold', letterSpacing: 6 },
1314
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
1315
+ filters: [{ type: 'warp', name: 'ripple', kind: 'wave', strength: 6, scale: 70, speed: 0.8, angle: 90 },
1316
+ { type: 'warp', kind: 'haze', strength: 3, scale: 30, speed: 0.4 }],
1317
+ keyframes: [{ at: 0, to: { 'filters.ripple.strength': 16 }, duration: 6, ease: 'sine.inOut' }] },
1318
+ ] };
1319
+ ```
1320
+
1321
+ ---
1322
+
1167
1323
  ## A hand-held shake and a flickering light (`wiggle`, seeded)
1168
1324
 
1169
1325
  `wiggle()` returns keyframes (spread it into `keyframes`): each property drifts between seeded random targets and ends back at `around`. Use `freq` ~3 for a slow float, 8+ for a shake, `ease: 'none'` for a jittery flicker. Per-layer variety: give each its own `seed`.
@@ -1355,7 +1511,7 @@ const sequences = [
1355
1511
  # batch.sh: one video per row of table.csv (name,score). --draft first, to look; remove it for the real files.
1356
1512
  mkdir -p out
1357
1513
  while IFS=, read -r name score; do
1358
- npx pixi-effects-render batch.html --query "name=$(printf %s "$name" | jq -sRr @uri)&score=$score" -o "out/$name.mp4" --quiet
1514
+ npx pixi-effects render batch.html --query "name=$(printf %s "$name" | jq -sRr @uri)&score=$score" -o "out/$name.mp4" --quiet
1359
1515
  done < table.csv
1360
1516
  ```
1361
1517
 
@@ -1831,6 +1987,35 @@ For rect / circle / ellipse, `anchorX` / `anchorY` (default `0.5` each; not avai
1831
1987
 
1832
1988
  ---
1833
1989
 
1990
+ ### `shader`
1991
+
1992
+ A fragment shader as a layer: write a Shadertoy `mainImage` and it is drawn into a picture that is a layer like any other (it moves, fades, masks, filters, blends, goes into 2.5D and receives lights). Use it for what is drawn by a formula: flowing colour fields, plasma, noise, waves, patterns.
1993
+
1994
+ ```ts
1995
+ { type: 'shader', name: 'plasma',
1996
+ fragment: `
1997
+ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
1998
+ vec2 uv = fragCoord / iResolution.xy;
1999
+ float v = sin(uv.x * 10.0 + iTime) + sin(uv.y * 8.0 - iTime * 1.3);
2000
+ fragColor = vec4(mix(tint, vec3(0.1, 0.0, 0.3), 0.5 + 0.25 * v), 1.0);
2001
+ }`,
2002
+ uniforms: { tint: '#ff8040', speed: 1 }, // declared for you: `uniform vec3 tint;` and `uniform float speed;`
2003
+ keyframes: [{ at: 0, to: { 'uniforms.speed': 3, 'uniforms.tint.2': 0.8 }, duration: 4 }] }
2004
+ ```
2005
+
2006
+ Fields: `fragment` (the GLSL, required), `uniforms` (below), `width` / `height` (px, expressions allowed; default the composition's size), `resolution` (default 1; `0.5` draws a quarter of the pixels and scales up: the way out for a heavy shader), `transparent` (default `false`), and everything every layer has.
2007
+
2008
+ Rules:
2009
+ 1. **Write `mainImage`** — `void mainImage(out vec4 fragColor, in vec2 fragCoord)`, GLSL ES 3.00. `fragCoord` is in the layer's pixels, origin at the **bottom left** (Shadertoy's convention). `iResolution` (a `vec3`: width, height, 1), **`iTime`** (the layer's own seconds, from 0 at its start; inside a time-remapped composition, that composition's clock) and `iFrame` (an `int`) are declared for you, and so is every name in `uniforms`. Do not write `#version`, `precision`, `main()`, `gl_FragColor` (write the colour into `fragColor`), `texture2D` (it is `texture`), `varying` or `attribute`: each warns with what to write.
2010
+ 2. **`uniforms`**: a number is a `float`, 2 to 4 numbers are a `vec2` / `vec3` / `vec4`, a colour `'#rrggbb'` is a `vec3` in 0..1. Keyframes move them as `'uniforms.speed'`; a component of a vector or a colour as `'uniforms.tint.0'` (0 = x or red). A name or index that does not exist warns once with the likely name.
2011
+ 3. **Opaque by default**: the alpha you write is ignored. `transparent: true` uses it (the shader's colour is premultiplied by its alpha, so soft edges blend over what is behind).
2012
+ 4. **It is a layer, not a filter**: it cannot read what is behind it, and this version has no `iChannel` textures, `iMouse`, `iDate` or `iTimeDelta` (using one warns). One pass only: no buffers or feedback.
2013
+ 5. **A compile error is said in your own line numbers** (`line 2: 'x' : undeclared identifier`) and the layer is drawn as a magenta checkerboard until it is fixed, so a mistake is never a silent blank layer.
2014
+ 6. **The GPU cannot be stopped**: a `while` loop, or a `for` whose bound is not a constant, can hang it, so both warn. Give loops a constant bound and `break` early.
2015
+ 7. **Deterministic, with one caveat**: a frame is a pure function of `iTime`, `iFrame` and the uniforms, so playback, seeking and export agree. Different GPUs can round differently, so an export from another machine can differ in the last bits.
2016
+ 8. **Size and place**: with `width` / `height` smaller than the composition the layer's **top-left corner** sits at `initial.x` / `initial.y` (like an image; a shader layer has no anchor to set). **A mask or matte cuts the picture the shader drew over its whole size, it does not move it**: a ripple centred in the shader's own frame stays there, so to show the middle of a pattern through a disc at x = 410, put the pattern's centre at x = 410 (a `vec2` uniform: `uniforms: { centre: [410, 360] }`) or size and place the layer to match. With `resolution` below 1, `iResolution` and `fragCoord` are in the **scaled** buffer's pixels (half size at 0.5), so a pixel-unit uniform (a centre, a radius) must be multiplied by `resolution`; `fragCoord / iResolution.xy` needs no correction.
2017
+ 9. **Cost** (a prototype measurement at 1080p, on one machine, an Apple M1 Pro; the released layer is not re-measured): a 160×90 shader with an 8-step loop, 20 layers, cost 11 ms (WebGPU) to 17 ms (WebGL) a frame more than 20 plain rectangles; one full-size 1920×1080 layer cost about 10 ms more. Every layer pays a fixed cost (about 0.5 to 0.85 ms), so use a few big shaders rather than many small ones, and `resolution` for heavy ones. All shader layers share **one** WebGL2 context (so there is no limit from the browser's number of contexts); a shader layer needs WebGL2 (without it the build warns and draws the checkerboard).
2018
+
1834
2019
  ## 3D layers & camera
1835
2020
 
1836
2021
  Place any 2D layer in depth and view it through a camera — After Effects style, no three.js. Everything uses the normal `initial` / `keyframes` / expression vocabulary.
@@ -1952,11 +2137,63 @@ A focus pull is a normal keyframe on `focus`; a layer name works there too:
1952
2137
  - A negative or non-numeric `aperture` warns once and turns depth of field off.
1953
2138
  - `movie.inspect(frame)` reports each `threeD` layer's blur as `depthBlur` (px, 0 = sharp).
1954
2139
 
2140
+ ### Lights
2141
+
2142
+ A `light` layer lights the `threeD` layers of its composition: they get darker and brighter with their angle and distance, like cards in a dark room. It is a layer like the camera: it draws nothing, and its numbers go in `initial` and `keyframes` (so it moves, springs, takes expressions and `ease`).
2143
+
2144
+ ```ts
2145
+ { type: 'light', kind: 'ambient', initial: { intensity: 0.2 } }, // what no other light reaches is this dark, not black
2146
+ { type: 'light', kind: 'spot', castsShadows: true, // a spotlight that crosses the picture
2147
+ initial: { x: 160, y: -120, z: 700, lookAtX: 260, lookAtY: 330, lookAtZ: 0, coneAngle: 34, coneFeather: 0.7, intensity: 1.1 },
2148
+ keyframes: [{ at: 0.5, to: { x: 1100, lookAtX: 1020 }, duration: 4.5, ease: 'sine.inOut' }] },
2149
+ { type: 'shape', shape: 'rect', name: 'card', width: 300, height: 200, threeD: true, castsShadows: true, initial: { x: 300, y: 300, z: 0, fillColor: '#e0a458' } },
2150
+ ```
2151
+
2152
+ `kind` (a fixed setting on the layer): `'ambient'` (the same colour everywhere), `'point'` (from a position in every direction; the default), `'spot'` (a point light inside a cone around the direction to its look-at point), `'parallel'` (a direction only, from the position toward the look-at point: the sun). `falloff` (also fixed): `'none'` (default), `'smooth'` (full inside `radius`, fading to nothing over `falloffDistance` more), `'inverseSquare'` (full at `radius`, a quarter at twice that). Numbers you can animate:
2153
+
2154
+ | name | meaning | default |
2155
+ |---|---|---|
2156
+ | `x` `y` `z` | where the light is (px, like a layer; `z` toward the viewer) | up and to the left of the picture, 0.6 of the camera's distance |
2157
+ | `lookAtX` `lookAtY` `lookAtZ` | the point a `spot` or `parallel` light points at | the middle of the picture |
2158
+ | `intensity` | a multiplier: 1 = full, 0.25 = a quarter (not a percentage) | 1 |
2159
+ | `color` | any colour (`'#ffd9a0'`) | white |
2160
+ | `coneAngle` `coneFeather` | a spot's full angle in degrees, and the soft part of its edge as a fraction of the half angle (0 = hard, 1 = soft all the way in) | 90, 0.5 |
2161
+ | `radius` `falloffDistance` | where the falloff starts and how far it runs (px) | 500, 500 |
2162
+ | `shadowDarkness` `shadowDiffusion` | how dark the shadows of this light are (0..1) and how soft their edge is (px; 0 = hard) | 1, 0 |
2163
+
2164
+ Rules:
2165
+ 1. **A composition with a `light` layer lights every `threeD` layer in it.** To keep one unlit, write `lit: false` on it. Layers without `threeD` are never lit. **A composition with no light and no fog makes nothing new: the shader is not built and the picture and the cost do not change.**
2166
+ 2. **What no light reaches is black.** Add an ambient light (the build warns when there is none). A white ambient light at intensity 1 is no light at all: the picture is the one you would get without lights. A light behind a layer does not light the side the camera sees.
2167
+ 3. **A light counts only while it is alive** (its own `at` and `duration`). With no light alive at a moment, the layers are drawn unlit, as in a composition without lights; so give the lights the length of the scene.
2168
+ 4. **Each composition has its own lights.** A light inside a nested composition lights only that composition's `threeD` layers; an outer light does not reach inside it. A `threeD` composition (a card) is lit as one picture by its parent's lights.
2169
+ 5. **Layers are drawn in distance order and there is no depth buffer.** Planes that cross each other cannot be shown; for a floor or a wall, put the layer's origin at its far edge so it sorts behind what stands on it.
2170
+ 6. **Shading works on the sRGB values** (like After Effects): a strong light clips to white. The shading is `colour × (ambient + the lights)`, each light by `N·L`, its falloff and its cone.
2171
+ 7. **Order:** the layer's own `filters` run first (they draw into the layer's picture), then the shading, the fog, the depth-of-field blur, and last the `blendMode`. All of them work together.
2172
+ 8. At most 8 lights besides the ambient ones are used (the first 8 in layer order; the build says which are left out). Ambient lights are added up.
2173
+ 9. `color` keyframes mix in sRGB like other colour keyframes (`'#ff0000'` → `'#0000ff'` passes `#800080`). A light has no `colorSpace`.
2174
+ 10. `movie.inspect(frame)` gives each lit `threeD` layer a `light`: how lit its middle is (1 = as bright as with no light, 0 = black); `check.mjs --at` prints the most lit first. It is the number at the layer's **middle** and shadows are not in it, so it can say 1 for a layer whose lit part is hidden.
2175
+ 11. **A spot's pool on a wall** is a circle of radius about `distance × tan(coneAngle / 2)` around where its axis meets the wall (a 36° cone from 700 px away lights a circle about 230 px in radius), softened by `coneFeather`. A card in front of the wall throws its shadow on the far side from the light and can cover the pool behind it: make the cards smaller than the pool, or bring the lamp closer. A caster outside the pool still throws its shadow into it. A **floor** is a big layer with `rotationX: 90` and its origin at its far edge (`anchorY: 0`).
2176
+
2177
+ Checked against an independent JS reference of the shading (every pixel within 2/255) on WebGPU and WebGL, for ambient, point, spot and parallel lights, the three falloffs, a card turned on two axes, and a card seen from its back. Cost, measured at 1080p on one machine (an Apple M1 Pro, headless Chrome) as the extra time per frame against the same scene without light: lights alone cost about 1 ms on WebGPU (20 layers, 3 lights: 2.6 ms) and about the same on WebGL; shadows are the price (below).
2178
+
2179
+ ### Shadows
2180
+
2181
+ A shadow needs both halves: the **light** has `castsShadows: true` and the **layer** has `castsShadows: true`. Then the layer's outline (its own picture's alpha) darkens the other lit `threeD` layers behind it, on the side away from the light.
2182
+
2183
+ - Hard edges by default (a hard shadow can read as a dark slab: `shadowDiffusion` of 10 to 20 usually looks right). `shadowDiffusion` (px, on the light) widens the edge, wider the farther the shadow falls from the caster, so a card near the wall has a sharp shadow and one far from it a soft one. `shadowDarkness` (0..1) sets how dark.
2184
+ - A layer receives shadows from at most **4** casters (the first 4 in layer order; the build says which are left out), never from itself, and a caster that is hidden (outside its life, alpha 0, behind the camera) casts nothing. Several shadows join by the strongest, not by adding up, so overlaps are not darker.
2185
+ - A shadow is the shadow of a flat picture: a card turned on its axis casts the turned outline; there is no self-shadowing, no shadow on a layer that is not lit (`lit: false`), no coloured or see-through shadow.
2186
+ - **Soft shadows are the expensive part** (16 samples per pixel per caster). Measured at 1080p, extra ms per frame (WebGPU / WebGL): 5 layers, 1 light, hard 1.4 / 1.6, soft 4.6 / 6.1; 20 layers, 1 light, hard 0.8 / 0.1, soft 5.3 / 10.4; 20 layers, 3 lights, hard 2.6 / 8.7, **soft 19.8 / 21.6**. Three or more lights with soft shadows warn; give `shadowDiffusion` to one or two lights.
2187
+
2188
+ ### Fog
2189
+
2190
+ The camera takes `fogNear`, `fogFar` (distances along the view direction, px), `fogColor` and `fogAmount` (0..1, how much of the colour the farthest things get). Writing any one of them turns fog on (the others default to: near = the camera's home distance, far = three times that, black, 1). A `threeD` layer is mixed toward the fog colour by its depth, per pixel (so a floor going away gets a true gradient). It comes after the shading and before the depth-of-field blur. 2D layers are not fogged: draw the background in the fog colour so what is far dissolves into it. Without lights, fog alone costs almost nothing. `fogFar` not beyond `fogNear`, or an amount outside 0..1, warns.
2191
+
1955
2192
  ### Depth order and limits
1956
2193
 
1957
2194
  - Consecutive `threeD` layers are drawn farthest-first. A non-`threeD` layer between them splits the group (like After Effects).
1958
2195
  - A layer at or behind the camera is hidden for that frame.
1959
- - v1 limits: planes do not intersect (whole layers are sorted); masks, transitions and `filterArea` are not supported on `threeD` layers (a mask is ignored with a warning); each `threeD` layer costs one extra render pass per frame.
2196
+ - v1 limits: planes do not intersect (whole layers are sorted; with lights this shows when you build a room: see [Lights](#lights)); masks, transitions and `filterArea` are not supported on `threeD` layers (a mask is ignored with a warning); each `threeD` layer costs one extra render pass per frame.
1960
2197
  - Wrong-but-likely names (`rotateY`, `translateZ`, `depth`, `perspective`, `zoom`) are not accepted; the console tells you the right name.
1961
2198
 
1962
2199
  ---
@@ -2070,8 +2307,8 @@ The mask is itself a sequence, so it can have `keyframes` of its own — useful
2070
2307
 
2071
2308
  Notes:
2072
2309
  - The mask sequence is rendered as a mask, not as a normal child — its `fillColor` / `strokeColor` only matter for which pixels are kept, not for the visible colour.
2073
- - Any sequence type works as a mask (shape / image / text / nested composition); shapes are the natural choice for geometric reveals.
2074
- - A **text** mask is the letters, not their bounding box (it is drawn into a texture and its alpha is used, so the edges are soft and antialiased); so is any mask with `maskInverted`. Both cost one extra offscreen pass for the masked layer. An ordinary shape mask is a hard-edged stencil.
2310
+ - Any sequence type works as a mask (shape / image / text / video / nested composition); shapes are the natural choice for geometric reveals.
2311
+ - A plain **shape** mask (not inverted) is a hard-edged stencil, the cheapest way. Every other mask (a text, image or video layer, `maskInverted`, a named matte, a list) is drawn into a texture once per frame and applied as a **matte filter**: its edges are soft and antialiased, it costs one offscreen pass, and `blendMode` works with it (see [Mattes](#named-mattes-and-luma)).
2075
2312
  - For a left-to-right wipe, give the mask `anchorX: 0` (rect/circle/ellipse) so `width: 0 → full` grows rightward from the left edge.
2076
2313
 
2077
2314
  #### `maskInverted`
@@ -2095,7 +2332,41 @@ When `true`, flip the mask sense: pixels INSIDE the mask shape become transparen
2095
2332
  }
2096
2333
  ```
2097
2334
 
2098
- Routed through PIXI v8's native `setMask({ inverse: true })`.
2335
+ An inverted mask goes through the matte filter (the letters of a text mask, or a hole with a soft edge), so it works with every `blendMode` and with a mask that has its own `filters`. `maskInverted` is the same thing as `invert: true` on a named matte; with a named matte, write `invert: true` (below).
2336
+
2337
+ #### Named mattes and luma
2338
+
2339
+ A layer can be named and used as the mask of **several** layers (After Effects' track matte). Write its name as the `mask`; the matte layer itself is then **not drawn**. It is drawn once per frame into a texture, so three layers sharing one moving matte cost one extra pass, not three.
2340
+
2341
+ ```ts
2342
+ // one moving band cuts three layers; the band itself is not on screen
2343
+ { type: 'shape', shape: 'rect', name: 'wipe', anchorX: 0, width: 0, height: 420,
2344
+ initial: { x: 60, y: 300 }, keyframes: [{ at: 0.3, to: { width: 560 }, duration: 1.5 }] },
2345
+ { type: 'text', text: 'SHARED', mask: 'wipe' },
2346
+ { type: 'text', text: 'MASK', at: 0.2, mask: 'wipe' },
2347
+ { type: 'composition', width: 400, height: 300, mask: 'wipe', sequences: [ /* a whole picture, cut as one */ ] },
2348
+
2349
+ // by brightness instead of opacity (a grayscale image or a gradient layer), inverted, and a list
2350
+ { type: 'image', asset: 'photo', mask: { layer: 'vignette', channel: 'luma' } },
2351
+ { type: 'shape', shape: 'rect', width: 400, height: 300, mask: ['panel', { layer: 'hole', invert: true }] }, // inside `panel`, outside `hole`
2352
+ ```
2353
+
2354
+ | form | meaning |
2355
+ |---|---|
2356
+ | `mask: 'name'` | the same as `{ layer: 'name' }` |
2357
+ | `mask: { layer, channel?, invert? }` | `channel: 'alpha'` (default: how opaque the matte is) or `'luma'` (how bright it is, Rec. 709); `invert: true` flips it (1 − matte) |
2358
+ | `mask: [ref, ref, …]` | every one must let the pixel through: the result is the **product** (an intersection). **To subtract a matte, put `invert: true` on it** |
2359
+ | `mask: { type: …, … }` | an inline mask layer, as above |
2360
+
2361
+ Rules:
2362
+ 1. **Outside the matte layer's own shape the matte is 0, for `luma` too** (nothing there is transparent and black), so a matte can be a wide gradient rectangle that you slide across the layers for a soft, moving reveal. **A layer that is used as a matte is not drawn.** It still needs to live long enough: the matte's time is the composition's time (its own `at` and `duration`). Where the matte is missing, the layers it cuts are **invisible**, so a matte whose lifetime does not cover its users warns.
2363
+ 2. **The name must be in the same composition** as the layer that uses it (a layer inside a nested composition cannot name a matte outside it). A layer that a transition wraps keeps its matte: the transition carries the `mask` to the wrapper.
2364
+ 3. A matte with a `parent` follows its parents (the null layers) as any layer does. A masked layer with a `parent` warns: the matte is in the composition's space, the layer in the null's space; put the mask on the null instead.
2365
+ 4. `threeD` layers cannot be mattes and cannot have one (a warning; the matte is drawn as the `threeD` layer it is, the mask is ignored).
2366
+ 5. **`blendMode` works with every matte**: the layer is cut by the matte and then blended (inside the matte it blends with the backdrop, outside it is the backdrop). A matte on a composition cuts the composition as one picture, as any filter does (its children's blends happen inside that picture).
2367
+ 6. A matte inside a **rotated or scaled nested composition** differs from the stencil only at the edges (soft instead of hard).
2368
+ 7. **Cost** (a prototype measurement at 1080p; not yet re-measured on the released code): WebGPU 0.32 ms for one layer, 0.75 ms for three, 2.65 ms for ten (0.67 ms and 1.97 ms when the layers share one matte); WebGL 1.22 / 4.03 / 11.0 ms. One matte is a texture of the composition's size (about 8 MiB at 1080p, more with anti-aliasing): **more than 8 mattes in one composition warns**. Share a matte between layers, or use fewer.
2369
+ 8. `inspect` does not list a matte layer (it is not drawn), and does not clip a masked layer's box by a named matte.
2099
2370
 
2100
2371
  #### `blendMode`
2101
2372
 
@@ -2126,7 +2397,7 @@ Rules:
2126
2397
  7. **Together with `filters`, `threeD` and depth of field they all work** (the blend is applied after the layer's own filters, and after the depth-of-field blur).
2127
2398
  8. **Some modes only act on some tones.** `overlay` and `soft-light` change almost nothing over a near-black or a near-white backdrop (their formulas leave 0 and 1 where they are), so a colour cast needs a **mid-tone** picture under it; `color-dodge` over a dark backdrop gives a saturated colour (each channel is lifted separately), over a bright one it burns out to white; `multiply` can only darken and `screen` / `add` only brighten.
2128
2399
  9. **`blendMode` is a setting of the layer, not an animatable property** (`initial` or a keyframe with it warns). To change the mode for a moment, put a second, short-lived layer (`at` / `duration`) on top, or fade a layer's `alpha`.
2129
- 10. **With `mask`:** an inverted mask (`maskInverted: true`) works with every mode, a mask with its own `filters` (a blurred, feathered hole) included. A **text, image or video layer as the mask** (not inverted) cannot carry a blend (it warns and draws the layer normal): put it on a layer of its own with the `blendMode`, or use `maskInverted`. A shape as a (not inverted) mask works as it is.
2400
+ 10. **With `mask`:** every mask works with every mode: the layer is cut by the mask first, then blended. A plain shape mask (not inverted) is a stencil as before; a text, image or video mask, an inverted mask and a named matte go through the matte filter (see [Named mattes and luma](#named-mattes-and-luma)), and the blend is applied after it.
2130
2401
 
2131
2402
  Checked against the W3C formulas on WebGPU and WebGL: every pixel within 2/255 (measured worst: 0.5/255 at alpha 1, 1.5/255 at alpha 0.5; over opaque backdrops and over nothing, as inside a `threeD` composition: a half-transparent layer over a half-transparent backdrop can differ more, because Pixi lays the blended result over the backdrop once more); on the same machine a frame is the same picture however it was reached. Another GPU can differ by a step or two.
2132
2403
 
@@ -2488,6 +2759,22 @@ Pixel-grain noise reveal driven by deterministic 2D Perlin noise. Pixels with a
2488
2759
  }
2489
2760
  ```
2490
2761
 
2762
+ ### `luma`
2763
+
2764
+ A wipe that follows a **brightness map** (After Effects' Gradient Wipe): the dark parts of the map change first, the light parts last. `to` appears through a soft edge; `from` holds until `to` has covered it (a hard cutoff, as `wipe` and `iris` do).
2765
+
2766
+ ```ts
2767
+ {
2768
+ kind: 'luma', from: 'A', to: 'B', at: 4, duration: 1,
2769
+ map: 'radial', // 'linear' (left to right, default), 'diagonal', 'radial' (from the middle, a circle on screen),
2770
+ // or the name of a grayscale image asset (dark parts change first)
2771
+ softness: 0.1, // width of the soft band, in brightness units (0..1). Default 0.1.
2772
+ flip: false, // true = reverse the map (light parts first)
2773
+ }
2774
+ ```
2775
+
2776
+ The three built-in maps need no image (they are computed on the GPU from the position on screen). A `map` name that is neither a built-in nor a loaded asset warns once (with the likely name) and wipes as if the map were white. The map covers the whole composition, so give it the composition's aspect ratio.
2777
+
2491
2778
  ---
2492
2779
 
2493
2780
  ## Filters
@@ -2523,7 +2810,7 @@ A filter by name, as plain data. `type` is the filter's class name without `Filt
2523
2810
  keyframes: [{ at: 0, to: { 'filters.halo.outerStrength': 6 }, duration: 1 }] } // animate any scalar option by name
2524
2811
  ```
2525
2812
 
2526
- Name a filter (`name: 'halo'`) to animate it with `'filters.halo.<option>'`. `blur`, `noise`, `alpha`, `colorMatrix` and `grain` need no extra package (`grain` is this library's own). All the others come from the **`pixi-filters`** package, which is loaded the first time a layer uses one (add `"pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js"` to the import map, or `npm i pixi-filters`; `ai/template.html` already has it). A misspelt type says what you probably meant. Rendering and exporting were verified in a real browser for 40 of them (`examples/_checks/named-filters.html` and `named-filters-2.html`); `colorMap` and `simpleLightmap` need a texture, which JSON cannot hold, so use `custom` for those two:
2813
+ Name a filter (`name: 'halo'`) to animate it with `'filters.halo.<option>'`. `blur`, `noise`, `alpha`, `colorMatrix` and `grain` need no extra package (`grain` is this library's own). All the others come from the **`pixi-filters`** package, which is loaded the first time a layer uses one (add `"pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js"` to the import map, or `npm i pixi-filters`; `skills/pixi-effects/template.html` already has it). A misspelt type says what you probably meant. Rendering and exporting were verified in a real browser for 40 of them (`examples/_checks/named-filters.html` and `named-filters-2.html`); `colorMap` and `simpleLightmap` need a texture, which JSON cannot hold, so use `custom` for those two:
2527
2814
 
2528
2815
  | `type` | Options (the useful ones) | What it does |
2529
2816
  |---|---|---|
@@ -2543,6 +2830,7 @@ Name a filter (`name: 'halo'`) to animate it with `'filters.halo.<option>'`. `bl
2543
2830
  | `rgbSplit` | `red green blue` (each `{ x, y }` offset) | Chromatic aberration |
2544
2831
  | `oldFilm` | `sepia noise scratch scratchDensity vignetting seed` | Aged film |
2545
2832
  | `glitch` | `slices offset direction seed average` | Digital glitch |
2833
+ | `warp` | `kind: 'wave' \| 'haze'`, `strength` (max shift, px, 6), `scale` (wavelength or cell size, px, 80), `speed` (0.5), `angle` (degrees: the way a wave travels, 0), `seed` (`haze`) | Bends the picture: a travelling wave (flags, water) or drifting noise (heat haze). This library's own, time-driven |
2546
2834
  | `bulgePinch` | `center radius strength` | Bulge (+) / pinch (−) |
2547
2835
  | `twist` | `radius angle offset: { x, y }` | Swirl |
2548
2836
  | `zoomBlur` | `strength center innerRadius radius` | Radial zoom streaks |
@@ -2567,6 +2855,7 @@ Name a filter (`name: 'halo'`) to animate it with `'filters.halo.<option>'`. `bl
2567
2855
 
2568
2856
  Gotchas, all measured:
2569
2857
 
2858
+ - **Distortion:** `warp` (`wave`: a flag or the surface of water, `haze`: heat shimmer; moves with the frame's time, no keyframes needed, and pads the layer by `strength` so a shifted edge is not cut), `twist` (a swirl), `bulgePinch` (a lens), `shockwave` (a ring that spreads), `rgbSplit` (colour fringes), `glitch` (slices). `warp`'s time is the time of the frame (inside a time-remapped composition, that composition's clock), `speed: 0` stands still, `strength` is in px and is capped at 200. It bends the layer's picture; to draw something that does not exist yet, use a `shader` layer.
2570
2859
  - **Centres and offsets are in canvas pixels, not the layer's:** `twist.offset`, `bulgePinch.center`, `zoomBlur.center`, `radialBlur.center`, `shockwave.center` default to the canvas's top-left corner, so a layer in the middle looks unaffected. Put the centre on the layer: `{ type: 'twist', offset: { x: 640, y: 360 } }` for a layer centred on a 1280×720 canvas.
2571
2860
  - **A filter works inside the layer's bounds.** Glow, dropShadow, outline and blur draw outside it and are clipped unless you widen the area (`filterArea: { x: -24, y: -24, width: w + 48, height: h + 48 }`, in the layer's own coordinates). Do not widen it for `grayscale`, `oldFilm` or other filters that paint the whole area: they fill the extra margin with black.
2572
2861
  - **Animate scalars by name** (`'filters.halo.outerStrength'`). Options that are points (`rgbSplit.red`, `dropShadow.offset`) do not tween as a whole; animate `'filters.<name>.red.x'` if the filter exposes it, or swap the filter. `pixelate` animates through `sizeX` / `sizeY`, not `size`.
@@ -2784,7 +3073,7 @@ import { cameraPath, wiggle } from 'pixi-effects';
2784
3073
  | `from` / `to` | fractions 0–1 of the route to start and stop at (default 0 → 1; `from: 1, to: 0` flies it backwards) |
2785
3074
  | `frameRate` | samples per second: your movie's `frameRate` (default 30) |
2786
3075
 
2787
- Layers the camera passes take `hideBehindCamera: true`. The recipe `camera-fly-through` in `ai/reference/recipes.md` is a whole corridor.
3076
+ Layers the camera passes take `hideBehindCamera: true`. The recipe `camera-fly-through` in `skills/pixi-effects/reference/recipes.md` is a whole corridor.
2788
3077
 
2789
3078
  ### `followPath`
2790
3079
 
@@ -3203,7 +3492,7 @@ const pages = await movie.stopImages({ as: 'dataURL', type: 'image/jpeg', scale:
3203
3492
  const pdf = await movie.exportPDF({ title: 'My talk' }); // a Blob: one PDF page per page of the deck (JPEG at the canvas size)
3204
3493
  ```
3205
3494
 
3206
- Both pause the movie and leave the playhead where it was; a movie with no stops returns an empty list / throws (a PDF needs pages). The picture of a page is, in order: its last stop flagged `pdf: true`, else its last (with `pick: 'first'`, first) stop that is not `pdf: false`; a page whose stops are all `pdf: false` is left out of a PDF (a page list still shows it by its last stop); `which: 'stops'` makes a page of every stop that is not `pdf: false`. `exportPDF` options: `which`, `pick`, `scale`, `quality` (JPEG, default 0.92), `title`, `motionBlur`, `onImage`. A transparent background comes out black in a JPEG: give the movie a `background`. From a script, `npx pixi-effects-render my-talk.html -o my-talk.pdf` (add `--all-stops` for a page per stop).
3495
+ Both pause the movie and leave the playhead where it was; a movie with no stops returns an empty list / throws (a PDF needs pages). The picture of a page is, in order: its last stop flagged `pdf: true`, else its last (with `pick: 'first'`, first) stop that is not `pdf: false`; a page whose stops are all `pdf: false` is left out of a PDF (a page list still shows it by its last stop); `which: 'stops'` makes a page of every stop that is not `pdf: false`. `exportPDF` options: `which`, `pick`, `scale`, `quality` (JPEG, default 0.92), `title`, `motionBlur`, `onImage`. A transparent background comes out black in a JPEG: give the movie a `background`. From a script, `npx pixi-effects render my-talk.html -o my-talk.pdf` (add `--all-stops` for a page per stop).
3207
3496
 
3208
3497
  ### Audio-reactive: `audioEnvelope()`, `react()`, `bpmEnvelope()`
3209
3498
 
@@ -3274,7 +3563,7 @@ el('play').onclick = () => (movie.isPlaying ? movie.pause() : movie.play());
3274
3563
  el('seek').oninput = e => movie.gotoFrame(+e.target.value);
3275
3564
  ```
3276
3565
 
3277
- What you have: `play()`, `pause()`, `gotoFrame(frame)`, `volume`, `muted` / `toggleMute()`; the state `currentFrame`, `totalFrames`, `frameRate`, `duration`, `isPlaying`, `isReady`; the events above; `render()` for a download button; `snapshot()` for a thumbnail; `timelineData()` / `timelineSvg()` for a timeline like `pixi-effects-view`'s. Keep the movie's `canvas` wherever your layout wants it (the page's CSS sizes it). To only change how `Controller` looks, see **Theme** below.
3566
+ What you have: `play()`, `pause()`, `gotoFrame(frame)`, `volume`, `muted` / `toggleMute()`; the state `currentFrame`, `totalFrames`, `frameRate`, `duration`, `isPlaying`, `isReady`, `audioBlocked` (true while the browser keeps the movie's sound silent until a tap: show "tap for sound"; a tap or key press that arrives while `play()` is pending, or the next `play()` made from a tap, starts it); the events above; `render()` for a download button; `snapshot()` for a thumbnail; `timelineData()` / `timelineSvg()` for a timeline like `pixi-effects-view`'s. Keep the movie's `canvas` wherever your layout wants it (the page's CSS sizes it). To only change how `Controller` looks, see **Theme** below.
3278
3567
 
3279
3568
  ## `Controller`
3280
3569