pixi-effects 0.4.0 → 0.5.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 (61) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +9 -5
  3. package/ai/SKILL.md +7 -4
  4. package/ai/reference/cheatsheet.md +16 -5
  5. package/ai/reference/pitfalls.md +15 -11
  6. package/ai/reference/recipes.md +117 -0
  7. package/ai/template.html +6 -3
  8. package/ai/tools/check.mjs +385 -0
  9. package/dist/{Base-5R7C5pHR.d.cts → Base-CPSFBkPm.d.ts} +6 -1
  10. package/dist/{Base-DhY9oMxr.d.ts → Base-DnRAxroQ.d.cts} +6 -1
  11. package/dist/Composition-63EXHNJJ.cjs +13 -0
  12. package/dist/{Composition-D7RCAGEA.cjs.map → Composition-63EXHNJJ.cjs.map} +1 -1
  13. package/dist/Composition-TUCRSMJU.js +4 -0
  14. package/dist/{Composition-RGTRZ2JX.js.map → Composition-TUCRSMJU.js.map} +1 -1
  15. package/dist/Controller.cjs +37 -13
  16. package/dist/Controller.cjs.map +1 -1
  17. package/dist/Controller.d.cts +4 -2
  18. package/dist/Controller.d.ts +4 -2
  19. package/dist/Controller.js +37 -13
  20. package/dist/Controller.js.map +1 -1
  21. package/dist/{Movie-DRZtIGU7.d.ts → Movie-CuU_aaCm.d.cts} +56 -2
  22. package/dist/{Movie-CghFXprP.d.cts → Movie-VyDiUYOj.d.ts} +56 -2
  23. package/dist/{chunk-QGBOC5YL.cjs → chunk-23M5QTWW.cjs} +33 -21
  24. package/dist/chunk-23M5QTWW.cjs.map +1 -0
  25. package/dist/{chunk-6BQ6IEW2.cjs → chunk-2RR43OIN.cjs} +143 -30
  26. package/dist/chunk-2RR43OIN.cjs.map +1 -0
  27. package/dist/{chunk-A574IA4F.js → chunk-5Q5XZG7Z.js} +18 -6
  28. package/dist/chunk-5Q5XZG7Z.js.map +1 -0
  29. package/dist/chunk-DKGG7WD4.js +61 -0
  30. package/dist/chunk-DKGG7WD4.js.map +1 -0
  31. package/dist/{chunk-ZQX7WIYL.js → chunk-UBPZLGLU.js} +143 -31
  32. package/dist/chunk-UBPZLGLU.js.map +1 -0
  33. package/dist/chunk-UQBGW5DO.cjs +64 -0
  34. package/dist/chunk-UQBGW5DO.cjs.map +1 -0
  35. package/dist/index.cjs +291 -86
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +54 -5
  38. package/dist/index.d.ts +54 -5
  39. package/dist/index.js +275 -72
  40. package/dist/index.js.map +1 -1
  41. package/dist/sfx-MZJZXYY3.cjs +446 -0
  42. package/dist/sfx-MZJZXYY3.cjs.map +1 -0
  43. package/dist/sfx-S37DDC66.js +434 -0
  44. package/dist/sfx-S37DDC66.js.map +1 -0
  45. package/dist/three.cjs +5 -5
  46. package/dist/three.d.cts +2 -2
  47. package/dist/three.d.ts +2 -2
  48. package/dist/three.js +1 -1
  49. package/dist/{types-CRE9WKd4.d.ts → types-7VtTEKUW.d.cts} +96 -6
  50. package/dist/{types-CRE9WKd4.d.cts → types-7VtTEKUW.d.ts} +96 -6
  51. package/docs/api.md +36 -0
  52. package/docs/dsl.md +44 -3
  53. package/llms-full.txt +234 -22
  54. package/llms.txt +1 -1
  55. package/package.json +2 -1
  56. package/dist/Composition-D7RCAGEA.cjs +0 -13
  57. package/dist/Composition-RGTRZ2JX.js +0 -4
  58. package/dist/chunk-6BQ6IEW2.cjs.map +0 -1
  59. package/dist/chunk-A574IA4F.js.map +0 -1
  60. package/dist/chunk-QGBOC5YL.cjs.map +0 -1
  61. package/dist/chunk-ZQX7WIYL.js.map +0 -1
package/llms-full.txt CHANGED
@@ -14,11 +14,13 @@ You describe a video as a tree of plain objects; the library plays it in a canva
14
14
 
15
15
  1. **Start from `template.html`** (copy it; it loads the library from a CDN, collects every console warning into `window.__logs`, and exposes `window.movie`). One HTML file, plain JS. Default canvas 1280×720 @ 30 fps.
16
16
  2. **Write the composition as data.** Anything repeated (letters, bars, particles, scenes) is a JS function or loop that returns specs — never copy-paste objects. Derive coordinates and times from constants/data, not magic numbers.
17
- 3. **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.
17
+ 3. **Run the check — one command does steps 3, 4 and the export:** `node ai/tools/check.mjs my-video.html` (Node ≥ 22 and Chrome installed; no npm install; in an installed package it is `npx pixi-effects-check my-video.html`). It starts its own private Chrome (nothing is shared with other sessions), waits for `window.__ready`, and prints: console warnings, `movie.inspect` over the WHOLE timeline (grouped), `movie.inspectAudio()`, then exports an mp4 and decodes it again (video size and length, audio length, loudness per second, silence). It writes `check-out/<name>/sheet.png` — **open that picture and look at it** — plus the mp4 and `report.json`. Exit 0 = nothing to fix; exit 1 = a list of problems; fix them and run it again. Text that overlaps text is listed under `review` (not a failure: ghost layers, glow copies and per-letter boxes are often intentional; look at the sheet; `--strict` makes them fail). The manual steps below are for looking at one frame closely.
18
+ **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.
18
19
  4. **Look at it** — you cannot judge motion from code. The library has the tools built in:
19
20
  - `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.
20
21
  - `await movie.inspect(frame)` → every layer's canvas bounds and visibility, plus `issues` (text off the canvas, cut off by an edge, empty, or overlapping other text). Run it at several frames; fix every issue it names.
21
22
  - `await movie.snapshot(frame, { as: 'dataURL' })` → one frame, canvas only (no player bar).
23
+ - `movie.inspectAudio()` → **hear with numbers**: for every sound its `start`, `sound.length`, `sound.peakDb`, `sound.loudestAt` (line this up with the picture) and `sound.brightnessHz` (< 500 dark, > 8000 airy), plus `issues` (limited mix, inaudible or cut-off sounds). Check it whenever the movie has audio; `render()` encodes this same mix.
22
24
  From a browser tool the result is a `data:` URL string: `agent-browser eval "movie.contactSheet({ count: 6, as: 'dataURL' })" | python3 ai/tools/save-image.py /absolute/sheet.png`, then open the PNG. Sheet tiles are small (~480 px): use `snapshot` for detail. `inspect` output is long on busy scenes: pass `{ layers: 'none' }` and read `issues` first. Give layers a `name` so inspect paths are readable. A moving ticker is not flagged as cut off.
23
25
  5. **Iterate** on what you saw (clipped text, collisions, wrong timing). Then export: `const blob = await movie.render({ format: 'mp4' })` (about real time; `movie.on('progress', …)` reports 0–100).
24
26
 
@@ -28,10 +30,10 @@ You describe a video as a tree of plain objects; the library plays it in a canva
28
30
  - **Layers are hidden, not removed, after their lifespan; z-order is array order.** Switch scenes by stacking layers with `at`/`duration` over an opaque background rect.
29
31
  - **Angles are degrees. `+z` is toward the viewer. Numbers can be expressions** (`'GW/2 - w/2'`; no `pi`; no per-frame variable).
30
32
  - **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`.
31
- - **`audio` shorter than its layer goes silent** — add `loop: true`.
33
+ - **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`.
32
34
  - **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.**
33
35
  - **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.
34
- - **Built in:** `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`. **Not in the DSL (hand-roll, recipes exist or loops are easy):** arc / progress-ring and stroke draw-on, text that changes over time (other than `{value}`), other per-frame curves and jitter, per-letter text animators (one layer per letter), particle emitters, group/parent layers.
36
+ - **Built in:** `splitText()` / `measureText()` (per-letter / per-word layers, exact positions), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`. **Not in the DSL (hand-roll, recipes exist or loops are easy):** stroke draw-on (trim path), text that changes over time (other than `{value}`), other per-frame curves and jitter, per-letter text animators (one layer per letter), particle emitters, group/parent layers.
35
37
  - **Blend modes:** `blendMode: 'add' | 'screen' | 'multiply'` on any layer (glows, light leaks).
36
38
  - **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.
37
39
 
@@ -44,6 +46,7 @@ Centred text must be positioned by `anchorX/anchorY: 0.5`; estimate text width a
44
46
  - `reference/cheatsheet.md` — every layer type, prop, default and rule on one page.
45
47
  - `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.
46
48
  - `reference/pitfalls.md` — the full list of real mistakes, with status.
49
+ - `tools/check.mjs` — the one-command review (see Workflow step 3).
47
50
  - `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/…`.)
48
51
  - `tools/save-image.py` — save a `dataURL` result (contact sheet / snapshot) to a PNG.
49
52
  - Full reference: `docs/dsl.md` and `docs/api.md` in the repository.
@@ -101,28 +104,30 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
101
104
 
102
105
  ## Layer types
103
106
 
104
- **text** — `text`, `style` (any PixiJS TextStyle field: `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding`; expressions OK for numbers), `colorSpace`. Default anchor is **top-left**; use `anchorX/anchorY: 0.5` to centre on `x,y`. A text layer has one animatable number, `value`, printed where the text contains `{value}` (`text: '{value} users'`, `initial: { value: 0 }`, keyframe `to: { value: 2480 }`; `format: { decimals, grouping }`) — that is how counters work. Other content cannot change over time. Multi-line text is centre-aligned by default (`style.align: 'left'` to change).
107
+ **text** — `text`, `style` (any PixiJS TextStyle field: `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding`; expressions OK for numbers), `colorSpace`. Default anchor is **top-left**; use `anchorX/anchorY: 0.5` to centre on `x,y`. A text layer has one animatable number, `value`, printed where the text contains `{value}` (`text: '{value} users'`, `initial: { value: 0 }`, keyframe `to: { value: 2480 }`; `format: { decimals, grouping, pad /* zero-pad the whole part: pad 2 → 05 */ }`) — that is how counters work. Other content cannot change over time. Multi-line text is centre-aligned by default (`style.align: 'left'` to change).
105
108
 
106
109
  **image** — `asset`, `tint`, `colorSpace: 'rgb'|'oklab'|'oklch'`. Default anchor top-left; natural size = `w`,`h`.
107
110
 
108
111
  **video** — `asset`, `loop`, `audio`, `volume`; `initial: { scale: 'cover' }`.
109
112
 
110
- **audio** — `asset`, `loop`, `volume`; fade with volume keyframes (`{ at: -2, to: { volume: 0 }, duration: 2 }` fades the last 2 s). **Omit `duration` for a one-shot sound effect: it lasts exactly as long as its clip.** With `loop: true` and no `duration` it lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true`.
113
+ **audio** — a file: `asset`, `loop`, `volume`; `volume` (top level, or in `initial` like every other layer's starting values); fade with volume keyframes (`{ at: -2, to: { volume: 0 }, duration: 2 }` fades the last 2 s). Omit `duration` for a one-shot clip: it lasts exactly as long as the clip. With `loop: true` and no `duration` it lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true`.
114
+ **Or a sound effect with no file — `sfx`:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` is the whole thing. Presets, default length in s: `click` 0.04 · `pop` 0.12 · `swoosh` 0.5 (loudest 0.16 s in, moves left → right) · `swipe` 0.22 · `hit` 0.7 · `riser` 1 (loudest at its END: `at = hit − duration`) · `chime` 1.4 · `beep` 0.16 · `coin` 0.4 · `glitch` 0.35 · `typewriter` 0.06. Knobs: `sfx: { preset, pitch /* semitones ±24 */, brightness /* −1 dark … 1 bright */, seed /* another take */ }`. **The layer's `duration` IS the sound's length** (omit it; set it to stretch, e.g. a riser); `volume` is its level (1 = the preset's standard level, peaking at −12 … −18 dBFS by preset). No `loop`: one layer per hit (a JS loop). Custom sound: `sfx: { voices: [{ wave: 'noise' | 'sine' | 'square' | 'saw' | 'triangle', freq: 440 | [from, to], filter: { type: 'lowpass' | 'highpass' | 'bandpass', freq: [from, to] }, envelope: 'fall' | 'bell' | 'swell' | 'hold', pan: [-1, 1] }] }`. The export (mp4 / webm / mov) encodes this same mix (lossy AAC / Opus), on time within a few ms.
111
115
 
112
- **shape** — `shape: 'rect' | 'circle' | 'ellipse' | 'line' | 'polygon' | 'path'`:
116
+ **shape** — `shape: 'rect' | 'circle' | 'ellipse' | 'arc' | 'line' | 'polygon' | 'path'`:
113
117
 
114
118
  | shape | geometry (top level **or** in `initial`) |
115
119
  |---|---|
116
120
  | rect | `width height cornerRadius anchorX anchorY` |
117
121
  | circle | `radius anchorX anchorY` |
118
122
  | ellipse | `radiusX radiusY anchorX anchorY` |
123
+ | arc | `radius innerRadius startAngle endAngle` — degrees, **0° = 3 o'clock, clockwise**; centred on `x, y`; stroke only = an open arc line (`strokeCap: 'round'`), with a fill = a sector, with `innerRadius` = a donut slice; a sweep ≥ 360° = a full circle. A progress ring: `startAngle: -90` and animate `endAngle` from −90 to 270 |
119
124
  | line | `from: [x,y] to: [x,y]` — plain canvas coordinates (omit `x,y`; if you give them they place the line's midpoint). Stroke in `initial`: `strokeColor`, `strokeWidth` |
120
125
  | polygon | `points: [[x,y],…] open` — canvas coordinates like `line` |
121
126
  | path | `d` (SVG path data) — canvas coordinates like `line` |
122
127
 
123
- Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha strokeWidth`; `colorSpace: 'oklab'|'oklch'` for clean colour tweens. rect/circle/ellipse are **centred on `x,y` by default** (`anchorX/anchorY` default 0.5): for a bar growing from its base use `anchorY: 1` (or `anchorX: 0` for left-to-right). **`fillGradient`** (top level or `initial`; instead of `fillColor`): `{ type?: 'linear'|'radial', stops: [[0, '#000'], [1, 'rgba(0,0,0,.6)']], angle? /* linear, deg, 90 = top→bottom */, center?, innerRadius?, radius? /* radial, 0–1 of bounds */ }` — stops may have alpha, so a radial transparent→dark is a vignette.
128
+ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha strokeWidth`; `colorSpace: 'oklab'|'oklch'` for clean colour tweens. rect/circle/ellipse are **centred on `x,y` by default** (`anchorX/anchorY` default 0.5): for a bar growing from its base use `anchorY: 1` (or `anchorX: 0` for left-to-right). **`fillGradient`** (top level or `initial`; instead of `fillColor`): `{ type?: 'linear'|'radial', stops: [[0, '#000'], [1, 'rgba(0,0,0,.6)']], angle? /* linear, deg, 90 = top→bottom */, center?, innerRadius?, radius? /* radial, 0–1 of bounds */ }` — stops may have alpha, so a radial transparent→dark is a vignette. Stroke ends and corners: `strokeCap: 'butt'|'round'|'square'`, `strokeJoin: 'miter'|'round'|'bevel'` (top level or in `initial`; not animated — `round` keeps sharp corners like an M from poking out).
124
129
 
125
- **composition** — `width height duration sequences transitions`. With `threeD: true` it is a **card**: its children are drawn into one texture (content outside its `width × height` is clipped: size it for a soft shadow too) 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.
130
+ **composition** — `width height duration sequences transitions`. With `threeD: true` it is a **card**: its children are drawn into one texture (content outside its `width × height` is clipped: size it for a soft shadow too; a plain 2D composition does not clip, but is skipped when its whole rectangle is off screen) and move / rotate in depth together. Children use the composition's local coordinates and times. Position/rotate/scale it as a unit; to rotate/scale about its centre set `pivotX/pivotY` to the centre and `x/y` to where that point should sit.
126
131
 
127
132
  **camera** (2.5D) — props in `initial`/keyframes: `x y z lookAtX lookAtY lookAtZ fov` (defaults: centred, looking at the z = 0 plane, `fov` 40, `z` auto = `(H/2)/tan(fov/2)` ≈ 989 at 720p). 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`.
128
133
 
@@ -153,6 +158,14 @@ orbit({ duration: 6, degrees: 40 /* , radius, center, start, fov, ease, at */ })
153
158
  ```
154
159
  `kenBurns` covers the canvas, so source images should be at least canvas-sized (1920×1080 for 1280×720).
155
160
 
161
+ ```js
162
+ import { measureText, splitText } from 'pixi-effects'; // text layout helpers (measured with the renderer's own fonts)
163
+ measureText('Hello', style) // → { width, height, lines }
164
+ splitText('Hello world', style, { by: 'chars' | 'words' | 'lines', x, y, align: 'left' | 'center' | 'right' })
165
+ // → [{ text, x, y, width, index, line }] — one text layer per piece, same style, anchor top-left
166
+ ```
167
+ Per-letter / per-word animation = `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
168
+
156
169
  ## Assets
157
170
 
158
171
  `assets: [{ name, src }]` in `movie.init` (images, audio, video). `src` may be a URL or a `data:`/`blob:` URL (e.g. `canvas.toDataURL()`).
@@ -170,6 +183,7 @@ movie.audioBuffer // mixed audio (after init), if
170
183
  await movie.contactSheet({ count: 6, as: 'dataURL' }) // ONE image of several labelled frames — look at it. Options: frames | times | count, columns, cellWidth, as
171
184
  await movie.snapshot(60, { as: 'dataURL' }) // one frame, canvas only (no player bar) — use it for detail; sheet tiles are small
172
185
  await movie.inspect(60, { layers: 'none' }) // issues only (default lists the visible layers); text off-canvas / cut off / empty / overlapping text. Name your layers so paths are readable
186
+ movie.inspectAudio() // the soundtrack as numbers — you cannot listen: sources[i] = { layer, start, end, peakDb, sound: { length, peakDb, loudestAt, brightnessHz } }, windows (rms / peak / brightness per 0.1 s), issues (limited mix, inaudible / cut-off sounds)
173
187
  new Controller(movie, { canvas }) // optional player bar (overlays the canvas bottom ~60px; not in the export)
174
188
  ```
175
189
 
@@ -188,7 +202,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
188
202
  2. **A layer's own `at` and a transition's `at` are composition time** (seconds from the parent composition's start). Only keyframes are layer-local.
189
203
  3. **Layers are hidden, not removed, after `[at, at+duration)`** and keep their last values. Z-order is array order (later = on top). To switch scenes, stack scenes with `at`/`duration`, each starting with an opaque full-screen rect. *(docs)*
190
204
  4. **Transitions need overlapping lifespans.** Both layers must be alive for the whole window: scene `i` starts at `i·(L−T)`, the transition's `at` is the next scene's start, total = `n·(L−T)+T`. A scene that ends while the transition is still running leaves a gap. *(docs)*
191
- 5. **Audio length.** Omit `duration` on a one-shot sound effect: the layer lasts exactly as long as its clip (pop 0.13 s, click 0.1 s, chime 0.6 s, swoosh 0.55 s; nothing to measure). `loop: true` with no `duration` lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true` (`bgm.mp3` is 6 s) and warns. Volume fades work like any keyframe: `volume: 0` + `{ at: 0, to: { volume: v }, duration: 2 }` + `{ at: -2, to: { volume: 0 }, duration: 2 }` (the last 2 s only; `set` is a jump). *(fixed: volume keyframes used to ramp from the previous keyframe's end, so the music faded across the whole piece)*
205
+ 5. **Sound effects need no file at all:** `{ type: 'audio', sfx: 'pop', at }` — the layer lasts exactly as long as the sound. **Audio length of a FILE.** Omit `duration` on a one-shot clip: the layer lasts exactly as long as the file (nothing to measure; the only sample file is `examples/_assets/bgm.mp3`, 6 s). `loop: true` with no `duration` lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true` (`bgm.mp3` is 6 s) and warns. Volume fades work like any keyframe: `volume: 0` + `{ at: 0, to: { volume: v }, duration: 2 }` + `{ at: -2, to: { volume: 0 }, duration: 2 }` (the last 2 s only; `set` is a jump). *(fixed: volume keyframes used to ramp from the previous keyframe's end, so the music faded across the whole piece)*
192
206
  6. **Snap times to the frame grid (`Math.round(t*30)/30`) when two things must stay locked** (a bar and its label). Drive both from one JS easing function with per-frame `set` keyframes rather than mixing a tween with computed positions. *(docs)*
193
207
  7. **Pick eases on purpose.** `expo.in` stays tiny until the very end (a "flood" circle never reaches the corners — use a bigger radius and `power2.in`). A zoom transition with `power2.out` looks like a cut because the incoming scene is nearly opaque after 40 %. Default is linear. *(docs)*
194
208
  8. **`render()` runs in roughly real time** (6 s of 720p ≈ 6 s). Listen to `movie.on('progress', e => e.progress)` (0–100). *(docs)*
@@ -211,14 +225,14 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
211
225
  18. **Counters**: `text: '{value}'` + animate `value` (with `format`). Other text content cannot change over time. Earlier sessions generated one text layer per number (40+ per bar). *(fixed)*
212
226
  19. **Loops**: `repeat` (finite) / `yoyo` / `repeatDelay` on any keyframe; infinite repeats are rejected with a warning. *(fixed)*
213
227
  20. **No per-frame expression time.** Expressions are evaluated once at build. A camera orbit is `orbit()`; any other curve (waves, spirals) must be sampled into short linear keyframes (~0.1 s) in a JS loop. *(partly fixed)*
214
- 21. **No text split / per-letter animator, no group/null parent, no particle emitter, no blend modes, no caption-linked-to-slide.** One layer per letter; many layers; seeded random loops; plain alpha. *(hand-roll)*
215
- 22. **Colour tweens look muddy in RGB** (red→green passes through brown). Use `colorSpace: 'oklab'` or `'oklch'` on the layer. *(docs)*
228
+ 21. **Per-letter / per-word text is `splitText()` (one layer per piece, positions measured for you); no group/null parent, no particle emitter, no blend modes, no caption-linked-to-slide.** One layer per letter; many layers; seeded random loops; plain alpha. *(hand-roll)*
229
+ 22. **Colour tweens look muddy in RGB** (red→green passes through brown). Use `colorSpace: 'oklab'` or `'oklch'` on the layer. In `oklch`, a nearly grey end (chroma < ~0.08, e.g. `#9a9ab4`) borrows the other end's hue, so grey → yellow no longer flashes through pink. *(docs / fixed)*
216
230
  23. **Generated images**: `canvas.toDataURL()` as an asset `src` works. Make them ≥ canvas size or `kenBurns` zooms look soft. *(docs)*
217
231
 
218
232
  ## 2.5D / three.js
219
233
 
220
234
  24. **`threeD` is required** for `z`, `rotationX`, `rotationY` (they warn otherwise). Camera props go in `initial`/keyframes, never on the camera object. `+z` is toward the viewer. *(fixed: warns)*
221
- 25. **A layer at/behind the camera plane is hidden** — the default camera sits at `z = (H/2)/tan(fov/2)` ≈ 989 at 720p, fov 40 (≈ 808 at fov 48, lower at higher fov). Keep every layer's `z` below it. *(fixed: warns once)*
235
+ 25. **A layer at/behind the camera plane is hidden** — the default camera sits at `z = (H/2)/tan(fov/2)` ≈ 989 at 720p, fov 40 (≈ 808 at fov 48, lower at higher fov). Keep every layer's `z` below it. *(fixed: warns once)* **It is hidden whole when ANY corner of it passes the camera plane**: a big plane tilted with `rotationX` / `rotationY` (a ground plane, an orbit ring) vanishes as soon as one corner swings behind the camera (the warning then says a corner reaches the plane). Tilt it less, shrink it, or keep the camera clear of its depth range. *(docs / fixed: warns)*
222
236
  26. **Under a dolly zoom only the `z = 0` plane stays put**; everything nearer balloons and clips. Headline at `z: 0`, near cards `z ≤ ~70`. Perspective also pulls far layers toward the centre: put far cards at the outer x, near cards inward, and keep ~10 % margin for an orbit sweep. `orbit({ dollyZoom: { from, to } })` does both together. *(docs)*
223
237
  27. **Combining orbit and dolly zoom**: `orbit()` specifies `z`, so it stops following `fov` — write your own `fov` and `z = (H/2)/tan(fov/2)` keyframes for the zoom. *(docs)*
224
238
  28. **Nearer `threeD` layers sort on top of text** — lay out around them; equal `z` keeps array order. A card (rect + icon + label that move together) is a `composition` with `threeD: true`. A final call-to-action or overlay should be a plain 2D layer (last in the array): it ignores the camera, and fade the 3D scene and headline out before it appears (a dim rect alone leaves them visible). *(docs)*
@@ -229,7 +243,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
229
243
  30. **A scene change must keep the outgoing scene alive until the covering flood / wipe has finished**, and the next scene starts when it is complete; the flood circle must reach the corners (radius ≥ half the diagonal) and use `power2.in`, not `expo.in`. *(docs)*
230
244
  30b. **Unknown option names now warn** (`contactSheet({ cols: 2 })` → did you mean `columns`; `init({ fps })` → `frameRate`). Layer names show up in `inspect` paths, so give layers a `name`. `inspect` ignores faint (alpha < 0.3) layers and text whose x / y is animated (tickers), so a scrolling marquee is not reported as cut off. Pixi may print `[BindGroup] … destroyed while still bound` warnings at `movie.destroy()`: harmless (playback and seeking never warn). *(docs)*
231
245
  30c. **Look with the built-in tools, not OS screenshots**: `await movie.contactSheet({ count: 6, as: 'dataURL' })` returns one labelled image of the whole animation; `await movie.inspect(frame)` returns every layer's canvas bounds and `issues`. If you do use a browser tool, `screenshot` needs an **absolute path**, macOS `sed -i` needs `''`, and a static server may redirect `/x.html` → `/x` and drop `?query`. Keep the template's `try/catch` around `init` so failures land in `window.__logs`. Check ≥ 5 timestamps including mid-transition. *(fixed: tools built in)*
232
- 31. **Check audio without rendering by decoding the render**: `const b = await movie.render({format:'mp4'})` then `new OfflineAudioContext(...).decodeAudioData(await b.arrayBuffer())` and measure RMS over time. `movie.audioBuffer` holds the mix. *(docs)*
246
+ 31. **Check audio with `movie.inspectAudio()`**: every sound measured on its own (`sound.length`, `peakDb`, `loudestAt`, `brightnessHz`), when it plays, loudness and brightness per 0.1 s window, and `issues` (the mix was limited, a layer is inaudible, an sfx is cut off by the end). Decoding a render by hand is no longer needed: `render()` encodes the same mix, on time in mp4 / mov / webm / mkv. *(fixed)*
233
247
 
234
248
  ## Round 2 — found by building 16 portfolio pieces (examples/gallery)
235
249
 
@@ -238,19 +252,23 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
238
252
  34. **`from` and `from+to` show their start value from the LAYER's start**, not from the keyframe's `at` (GSAP behaviour): a delayed fade-in `{ at: 2, from: { alpha: 0 }, to: { alpha: 1 } }` needs no `initial.alpha`. To keep the current value until `at` and then jump, use `set`. *(docs)*
239
253
  35. **One `Movie` per page.** Creating a second Movie (for an isolated repro) and destroying it breaks the first (`null.clear`). Reload instead. *(docs)*
240
254
  36. **Multi-line text is centre-aligned by default** (`style.align: 'left'` for left-aligned paragraphs). *(docs)*
241
- 37. **A `threeD` composition (a card) clips its children at its own width × height.** Size the card for its soft shadow too. A growing `threeD` shape or text is fine: its texture is resized in place (it used to warn "destroyed while still bound"). *(docs / fixed)*
255
+ 37. **Composition size.** A 2D composition does not clip its children (a child outside `width × height` still draws), but its rectangle is the culling area: if the whole rectangle is off the canvas the group is skipped even when a child is on screen — keep it near what it draws, or canvas-sized. A `threeD` composition (a card) IS clipped at its `width × height` (it is rendered into a texture that size): size it for the soft shadow too. A growing `threeD` shape or text is fine: its texture is resized in place (it used to warn "destroyed while still bound"). *(measured; docs / fixed)*
242
256
  38. **Under an orbit the side the camera swings toward becomes the NEAR side**: a "far" card at `z: −200` can be drawn bigger than 1× on that side and run off the frame (`inspect` only checks text). Pull those cards inward / lower `z`, and check a frame at the extreme of the sweep. After `orbit()` ends the default camera returns, so to hold the final pose add a camera layer that starts there. Near particles balloon under a dolly zoom: cap particle `z` at about +150, or one becomes a fake full stop. *(docs)*
243
257
  39. **`fillGradient` stops span the whole unmasked shape**, not the visible part: under a mask, place the stops for the visible fraction (e.g. 0.64→1). A translucent shape over near-black reads as grey: put the alpha in the gradient stops. *(docs)*
244
- 40. **Rotate a polygon / path about a chosen point** by wrapping it in a `composition` with `pivotX/pivotY` (its centre is otherwise the bounds centre). *(docs)*
258
+ 40. **Rotate a polygon / path / line about a chosen point with `pivotX` / `pivotY`** (in the shape's own canvas coordinates; put the same point in `x, y`): `{ points, initial: { pivotX: 600, pivotY: 300, x: 600, y: 300, rotation: 180 } }` turns about the vertex (600, 300) — measured. Without a pivot the shape turns about the centre of its bounds. `anchorX` / `anchorY` do nothing on these shapes (they are for rect / circle / ellipse). *(docs; the gallery wrapped every polygon in a composition unnecessarily)*
245
259
  41. **Glow a whole set of layers** by putting them in one `composition` with a blur filter and `filterArea` (only a composition carries a filter for its children): wide enough for the blur, and the set is built once, so the extra copy is just a second group. *(docs)*
246
260
  42. **Transition looks:** the default `dissolve` reads as a hard black-and-white camouflage and `scale: 220` as static; `scale: 90, smoothing: 0.5` is a soft film dissolve. The default edge softness of `iris` (0.02) and `wipe` blurs flat graphics at 720p: `smoothing: 0.004`–`0.01` gives a clean edge. *(docs)*
247
261
  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)*
248
- 44. **`{value}` has no number padding**: a clock or timecode cannot show `:05`. Keep the value in a range that always has the same digit count, or split digits into two counters. *(hand-roll)*
262
+ 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)*
249
263
  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)*
250
- 46. **Still not in the DSL (hand-roll; round-two notes have workarounds):** arc / progress-ring primitive and stroke draw-on / trim path (use a growing rect mask for a one-direction reveal, tick marks, or pre-drawn arcs shown one per frame), text content that changes over time (only `{value}`), per-frame jitter (film grain = seeded `set` keyframes on an oversized image), particle emitters, per-letter / per-line text split (measure with `canvas.measureText` using Pixi's font string), shared masks, `zIndex`.
251
- 46b. **Blend modes exist now** (`blendMode: 'add' | 'screen' | 'multiply'` 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. *(fixed)*
252
- 47. **Browser tooling:** 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 and ignores the two scenes of a running transition; it still does not compare text with picture content or shapes.
264
+ 46. **Still not in the DSL (hand-roll; round-two notes have workarounds):** stroke draw-on / trim path on a line, polygon or path (use a growing rect mask for a one-direction reveal; a circular progress ring is `shape: 'arc'`), text content that changes over time (only `{value}`), per-frame jitter (film grain = seeded `set` keyframes on an oversized image), particle emitters, shared masks, `zIndex`.
265
+ 46b. **Blend modes exist now** (`blendMode: 'add' | 'screen' | 'multiply'` 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)*
266
+ 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.
253
267
  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)*
268
+ 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)*
269
+ 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)*
270
+ 51. **Arcs and rings are `shape: 'arc'`** (`radius`, `innerRadius`, `startAngle`, `endAngle`, degrees; 0° = 3 o'clock, clockwise). A progress ring is an arc with `startAngle: -90` and an animated `endAngle` (−90 → 270); `strokeCap: 'round'` rounds BOTH ends. Fill it for a pie slice, add `innerRadius` for a donut slice (a 120-polygon donut is no longer needed). Style (`strokeColor`, `strokeWidth`, `fillColor`) goes in `initial`; `strokeCap` / `strokeJoin` may be top level. *(fixed)*
271
+ 52. **Measure and split text with `measureText` / `splitText`, not by hand.** `splitText(text, style, { by, x, y, align })` returns each piece's x / y / width in the renderer's own font metrics (kerning kept; verified: a whole line vs the same line in per-letter layers differs only by anti-aliasing). The pieces are for text layers with the default top-left anchor and the same `style`. Load a web font before measuring (`await document.fonts.load('24px "Name"')`) or the fallback font's widths are used. Size a pill from `measureText(...).width + padding` instead of `0.58 × fontSize × length`. *(fixed)*
254
272
 
255
273
 
256
274
  <!-- ===== ai/reference/recipes.md ===== -->
@@ -529,6 +547,7 @@ photos.forEach((_, i) => sequences.push(withFade({
529
547
  }, { in: 0.4, out: 0.4 })));
530
548
  sequences.push({ type: 'audio', asset: 'bgm', loop: true, volume: 0, duration: total,
531
549
  keyframes: [{ at: 0, to: { volume: 0.8 }, duration: 2 }, { at: -2, to: { volume: 0 }, duration: 2 }] });
550
+ transitions.forEach(tr => sequences.push({ type: 'audio', sfx: 'swoosh', at: tr.at + tr.duration / 2 - 0.16 })); // a swoosh is loudest 0.16 s in: centre it on the transition
532
551
  return { sequences, transitions, duration: total };
533
552
  ```
534
553
 
@@ -536,6 +555,122 @@ Source images should be at least canvas-sized (`kenBurns` zooms in): draw genera
536
555
 
537
556
  ---
538
557
 
558
+ ## Sound effects without files
559
+
560
+ No `assets`, no files: a sound effect is one audio layer with `sfx`. It lasts as long as the sound.
561
+
562
+ ```js
563
+ // @recipe sfx-minimal
564
+ return [
565
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0f1424' } },
566
+ { type: 'text', name: 'word', text: 'HELLO', at: 2, duration: 3,
567
+ style: { fontSize: 120, fontWeight: '900', fill: '#ffffff' },
568
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
569
+ keyframes: [{ at: 0, from: { x: -400 }, to: { x: 'GW/2' }, duration: 0.4, ease: 'power3.out' }] },
570
+ { type: 'audio', sfx: 'swoosh', at: 2 }, // flies in with a swoosh…
571
+ { type: 'audio', sfx: 'hit', at: 2.4 }, // …and lands (0.4 s later) with a hit
572
+ ];
573
+ ```
574
+
575
+ Check it without listening, then export — the file encodes this same mix (mp4 / mov: AAC, webm / mkv: Opus; lossy):
576
+
577
+ ```js
578
+ // @docs-only render-and-verify
579
+ await movie.init({ canvas, width: 1280, height: 720, duration: 5, composition: { sequences } });
580
+ const a = movie.inspectAudio();
581
+ console.log(a.issues); // must be []
582
+ console.log(a.sources.map(s => [s.layer, s.start, s.sound.loudestAt, s.sound.brightnessHz]));
583
+ const blob = await movie.render({ format: 'mp4' });
584
+ ```
585
+
586
+ ---
587
+
588
+ ## Sound effects in sync with the picture
589
+
590
+ Line up the LOUD point, not the start: a `riser` is loudest at its end (`at = hit − duration`), a `swoosh` 0.16 s in, everything else at its start (`inspectAudio().sources[i].sound.loudestAt` tells you). Vary `pitch` for a series (a rising pitch reads as progress) and `seed` for repeats (no two typewriter keys alike).
591
+
592
+ ```js
593
+ // @recipe sfx-cues
594
+ const BG = { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0f1424' } };
595
+ const HIT = 1.5, RISE = 1.2; // the title slams in at 1.5 s; the riser leads into it
596
+ const sequences = [BG,
597
+ { type: 'audio', name: 'riser', sfx: 'riser', at: HIT - RISE, duration: RISE, volume: 0.7 }, // a riser is loudest at its END
598
+ { type: 'audio', name: 'hit', sfx: 'hit', at: HIT },
599
+ { type: 'text', name: 'title', text: 'LAUNCH', at: HIT, duration: 8 - HIT,
600
+ style: { fontSize: 140, fontWeight: '900', fill: '#ffffff', fontFamily: 'Arial Black, Arial, sans-serif' },
601
+ initial: { x: 'GW/2', y: 260, anchorX: 0.5, anchorY: 0.5 },
602
+ keyframes: [{ at: 0, from: { scale: 2.4, alpha: 0 }, to: { scale: 1, alpha: 1 }, duration: 0.18, ease: 'expo.out' }] },
603
+ ];
604
+ ['PLAN', 'BUILD', 'SHIP'].forEach((label, i) => { // each item pops in with a pop; pitch climbs a step per item
605
+ const at = 2.4 + i * 0.5;
606
+ sequences.push({ type: 'text', name: 'item' + i, text: label, at, duration: 8 - at,
607
+ style: { fontSize: 44, fontWeight: 'bold', fill: '#ffd166' },
608
+ initial: { x: 360 + i * 280, y: 430, anchorX: 0.5, anchorY: 0.5 },
609
+ keyframes: [{ at: 0, from: { scale: 0, alpha: 0 }, to: { scale: 1, alpha: 1 }, duration: 0.25, ease: 'back.out(2)' }] });
610
+ sequences.push({ type: 'audio', name: 'pop' + i, sfx: { preset: 'pop', pitch: i * 2 }, at });
611
+ });
612
+ const CAPTION = 'ships friday', T0 = 4.4, STEP = 0.07; // typed caption: one letter + one key per step
613
+ [...CAPTION].forEach((ch, i) => {
614
+ const at = T0 + i * STEP;
615
+ sequences.push({ type: 'text', name: 'cap' + i, text: ch, at, duration: 8 - at,
616
+ style: { fontSize: 36, fill: '#9fb3d9', fontFamily: 'ui-monospace, Menlo, monospace' },
617
+ initial: { x: 640 - (CAPTION.length * 22) / 2 + i * 22, y: 540, anchorY: 0.5 } });
618
+ if (ch !== ' ') sequences.push({ type: 'audio', sfx: { preset: 'typewriter', seed: i }, at, volume: 0.6 }); // seed: every key sounds a little different
619
+ });
620
+ sequences.push({ type: 'audio', name: 'done', sfx: 'chime', at: T0 + CAPTION.length * STEP + 0.3 });
621
+ return sequences;
622
+ ```
623
+
624
+ ---
625
+
626
+ ## Progress ring (an arc) with a counter
627
+
628
+ `shape: 'arc'` is a ring when it has only a stroke: animate `endAngle` from the start angle (−90 = 12 o'clock) round to +270 for a full circle. A second arc under it is the track; `{value}` in the middle counts in step with the same ease.
629
+
630
+ ```js
631
+ // @recipe progress-ring
632
+ const CX = 640, CY = 360, R = 120, TO = 0.82; // 82 % filled
633
+ const ease = 'power3.out', AT = 0.3, DUR = 2;
634
+ return [
635
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0f1424' } },
636
+ { type: 'shape', shape: 'arc', name: 'track', radius: R, startAngle: 0, endAngle: 360,
637
+ initial: { x: CX, y: CY, strokeColor: '#27304d', strokeWidth: 24 } },
638
+ { type: 'shape', shape: 'arc', name: 'progress', radius: R, startAngle: -90, endAngle: -90, strokeCap: 'round',
639
+ initial: { x: CX, y: CY, strokeColor: '#ffd166', strokeWidth: 24 },
640
+ keyframes: [{ at: AT, to: { endAngle: -90 + 360 * TO }, duration: DUR, ease }] },
641
+ { type: 'text', name: 'percent', text: '{value}%', format: { decimals: 0 },
642
+ style: { fontSize: 72, fontWeight: 'bold', fill: '#ffffff' },
643
+ initial: { x: CX, y: CY, anchorX: 0.5, anchorY: 0.5, value: 0 },
644
+ keyframes: [{ at: AT, to: { value: TO * 100 }, duration: DUR, ease }] },
645
+ ];
646
+ ```
647
+
648
+ ---
649
+
650
+ ## Letters and words that animate one by one (`splitText`)
651
+
652
+ Text content cannot change, so a per-letter or per-word effect is one text layer per piece. `splitText(text, style, { by: 'chars' | 'words' | 'lines', x, y, align })` measures the text with the renderer's own font metrics (kerning kept) and returns each piece's `text`, `x`, `y`, `width`, `index` and `line`: put them straight into the layers. Use the same `style` for the whole text and each piece, and a web font must be loaded first. `measureText(text, style)` gives `{ width, height, lines }` for sizing pills and underlines.
653
+
654
+ ```js
655
+ // @recipe split-text
656
+ const style = { fontFamily: 'Arial, sans-serif', fontSize: 110, fontWeight: 'bold', fill: '#ffffff' };
657
+ const sub = { fontFamily: 'Arial, sans-serif', fontSize: 44, fill: '#ffd166' };
658
+ const sequences = [{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0f1424' } }];
659
+ splitText('MAKE THINGS', style, { x: 640, y: 200, align: 'center' }).forEach(p => sequences.push({
660
+ type: 'text', name: 'letter-' + p.index, text: p.text, style, at: 0.3 + p.index * 0.06,
661
+ initial: { x: p.x, y: p.y, alpha: 0 }, // x / y are the top-left of the letter: anchorX / anchorY stay 0
662
+ keyframes: [{ at: 0, from: { y: p.y + 40, alpha: 0 }, to: { y: p.y, alpha: 1 }, duration: 0.35, ease: 'back.out(2)' }],
663
+ }));
664
+ splitText('one word at a time', sub, { by: 'words', x: 640, y: 380, align: 'center' }).forEach(p => sequences.push({
665
+ type: 'text', name: 'word-' + p.index, text: p.text, style: sub, at: 1.4 + p.index * 0.25,
666
+ initial: { x: p.x, y: p.y, alpha: 0 },
667
+ keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }],
668
+ }));
669
+ return sequences;
670
+ ```
671
+
672
+ ---
673
+
539
674
  ## Looping motion
540
675
 
541
676
  `repeat` (a finite count) and `yoyo` go on any keyframe. Total time = `duration × (repeat + 1)`.
@@ -695,7 +830,7 @@ interface SequenceCommon {
695
830
  | Layer | Where `x, y` land by default | To change it |
696
831
  |---|---|---|
697
832
  | `shape` rect / circle / ellipse | the **centre** of the shape | `anchorX` / `anchorY` (0–1). A bar growing from its base: `anchorY: 1`; left to right: `anchorX: 0` |
698
- | `shape` line / polygon / path | the centre of the shape's bounds | — |
833
+ | `shape` line / polygon / path | the centre of the shape's bounds | `pivotX` / `pivotY` (in the shape's own canvas coordinates): the point that sits at `x, y` and that rotation / scale turn about. **`anchorX` / `anchorY` do nothing on these.** |
699
834
  | `text`, `image`, `video` | the **top-left** corner | `anchorX` / `anchorY: 0.5` to centre |
700
835
  | `composition` | its top-left corner | `pivotX` / `pivotY` = the point that sits at `x, y` and that rotation / scale turn about |
701
836
 
@@ -752,7 +887,7 @@ A text layer has one animatable number, `value`, printed wherever the text conta
752
887
  }
753
888
  ```
754
889
 
755
- `format`: `decimals` (default 0) and thousands `grouping` (default false). Put prefixes and suffixes in the text (`'${value}'`, `'{value}%'`). If `value` is animated but the text has no `{value}`, a warning says so.
890
+ `format`: `decimals` (default 0), thousands `grouping` (default false) and `pad` (zero-pad the whole part to that many digits: `pad: 2` gives `05`, so a clock is `text: '18:42:{value}'` with `format: { pad: 2 }`). Put prefixes and suffixes in the text (`'${value}'`, `'{value}%'`). If `value` is animated but the text has no `{value}`, a warning says so.
756
891
 
757
892
  ### `image`
758
893
 
@@ -815,10 +950,40 @@ Audio-only sequence. No visual. Volume is animatable via keyframes.
815
950
 
816
951
  Audio is mixed during `Movie.init()` and during `Movie.render()`. Volume keyframes interpolate linearly.
817
952
 
953
+ #### Sound effects without files: `sfx`
954
+
955
+ ```ts
956
+ { type: 'audio', sfx: 'swoosh', at: 2 } // a preset at its own length
957
+ { type: 'audio', sfx: { preset: 'hit', pitch: -3, brightness: -0.5 }, at: 4 } // lower and darker
958
+ { type: 'audio', sfx: 'riser', at: 3, duration: 1.5, volume: 0.7 } // duration IS the sound's length (stretched)
959
+ { type: 'audio', at: 6, duration: 0.3, // a custom sound
960
+ sfx: { voices: [{ wave: 'square', freq: [1600, 200], filter: { type: 'lowpass', freq: 3000 }, envelope: 'fall' }] } }
961
+ ```
962
+
963
+ The sound is synthesised from data at init (no asset, no network), deterministic, and the same mix in playback and in the exported file (encoded there: AAC / Opus are lossy). `at` is where it starts; `volume` and volume keyframes work as for files; `loop` is not available (one layer per hit). Knobs: `pitch` (semitones, ±24), `brightness` (−1 dark … 1 bright), `seed` (another take of the random parts). `volume: 1` is the preset's standard level, peaking at −12 … −18 dBFS depending on the preset; the finished mix is soft-limited above 0.9 with a warning naming the layers. A custom sound has up to 16 `voices`: `wave` (`sine` `triangle` `square` `saw` `noise`), `freq` (Hz or `[from, to]`), `filter` (`{ type: 'lowpass' | 'highpass' | 'bandpass', freq, q }`), `envelope` (`fall` `bell` `swell` `hold`), `pan`, `from` / `to` (part of the sound, 0–1), `gain`; its default length is 0.5 s.
964
+
965
+ | Preset | Default length | Loudest at | Brightness | Use for |
966
+ |---|---|---|---|---|
967
+ | `click` | 0.04 s | start | ≈ 12780 Hz | UI click, tick |
968
+ | `pop` | 0.12 s | start | ≈ 390 Hz | an element appearing |
969
+ | `swoosh` | 0.5 s | 0.16 s | ≈ 3070 Hz | transitions, fly-pasts (moves left → right) |
970
+ | `swipe` | 0.22 s | start | ≈ 13190 Hz | quick UI swipe |
971
+ | `hit` | 0.7 s | start | ≈ 150 Hz | impact, slam |
972
+ | `riser` | 1 s | its end | ≈ 11850 Hz | build-up into a hit |
973
+ | `chime` | 1.4 s | start | ≈ 1400 Hz | success, reveal |
974
+ | `beep` | 0.16 s | flat | ≈ 1030 Hz | countdown, alert |
975
+ | `coin` | 0.4 s | 0.07 s | ≈ 1870 Hz | reward, collect |
976
+ | `glitch` | 0.35 s | 0.22 s | ≈ 3970 Hz | digital error |
977
+ | `typewriter` | 0.06 s | start | ≈ 3260 Hz | one key (vary `seed` per key) |
978
+
979
+ Brightness is the spectral centroid that `movie.inspectAudio()` reports (`sound.brightnessHz`), at 48 kHz with no knobs.
980
+
818
981
  ### `composition`
819
982
 
820
983
  Nested composition. Same shape as the root spec but with `type: 'composition'` and an explicit `width`/`height`. Children animate within the local coordinate system; the composition itself can be positioned, scaled, and rotated as a unit.
821
984
 
985
+ **Size, clipping and culling** (measured): a 2D composition does **not** clip its children — a child far outside `width × height` is still drawn. But the rectangle `width × height` is what the renderer uses to decide the group is on screen: when that whole rectangle is off the canvas the group is skipped even if a child is placed back on screen. So keep the rectangle around what it draws (or make it canvas-sized). A `threeD: true` composition (a card) is rendered into a texture of exactly `width × height`, so it **does** clip its children there; size it for soft shadows too.
986
+
822
987
  ```ts
823
988
  {
824
989
  type: 'composition',
@@ -859,10 +1024,21 @@ Every primitive draws centred on its local origin (so `anchorX`/`anchorY` and `p
859
1024
  | `rect` | `width`, `height` | `cornerRadius` |
860
1025
  | `circle` | `radius` | — |
861
1026
  | `ellipse` | `radiusX`, `radiusY` | — |
1027
+ | `arc` | `radius` | `innerRadius`, `startAngle` (0), `endAngle` (360) |
862
1028
  | `line` | `from: [x,y]`, `to: [x,y]` (canvas coordinates; stroke in `initial`) | — |
863
1029
  | `polygon` | `points: [[x,y], …]` | `open` (default false) |
864
1030
  | `path` | `d` (SVG path data) | — |
865
1031
 
1032
+ **`arc`** is an arc around its local origin (the centre). Angles are in **degrees: 0° is 3 o'clock and positive is clockwise**, so a progress ring starts at `startAngle: -90` (12 o'clock) and animates `endAngle` from `-90` to `270`. With a stroke and no fill it is an open arc line (`strokeCap: 'round'` for rounded ends); with a fill it is a sector (a pie slice), and with `innerRadius` a ring segment (a donut slice). A sweep of 360° or more is a full circle; `endAngle < startAngle` sweeps counter-clockwise. `radius`, `innerRadius`, `startAngle` and `endAngle` are all animatable.
1033
+
1034
+ ```ts
1035
+ { type: 'shape', shape: 'arc', radius: 120, startAngle: -90, endAngle: -90, strokeCap: 'round',
1036
+ initial: { x: 'GW/2', y: 'GH/2', strokeColor: '#ffd166', strokeWidth: 24 },
1037
+ keyframes: [{ at: 0, to: { endAngle: 270 }, duration: 2, ease: 'power3.out' }] } // a progress ring filling clockwise from the top
1038
+ ```
1039
+
1040
+ `strokeCap` (`'butt'`, `'round'`, `'square'`) and `strokeJoin` (`'miter'`, `'round'`, `'bevel'`) work on every shape, at the top level or in `initial`; they are not animated. Sharp corners (the apex of an M in outlined text, a thin polygon) poke out with the default `miter` join: use `'round'`.
1041
+
866
1042
  **Style** is set on `initial` and animatable via keyframes:
867
1043
 
868
1044
  | Key | Notes |
@@ -945,7 +1121,7 @@ Per-shape choice of how colour keyframes are interpolated. Default `'rgb'` (line
945
1121
 
946
1122
  **Scalar geometry is animatable.** `width`, `height`, `cornerRadius` (rect), `radius` (circle), `radiusX` / `radiusY` (ellipse) all flow through the same per-frame redraw and can be tweened via keyframes — useful for progress bars (animate `width`), pulsing icons (animate `radius`), or shape-morph callouts. Array geometry (polygon `points`, line endpoints, path `d`) is baked at build time; use `scale` / `scaleX` / `scaleY` for those.
947
1123
 
948
- `anchorX` / `anchorY` (default `0.5` each) control which point on the bbox sits at the local origin — `0` is left/top, `1` is right/bottom. They're animatable too. Critical for "grow from one edge" effects:
1124
+ For rect / circle / ellipse, `anchorX` / `anchorY` (default `0.5` each; not available on line / polygon / path, which use `pivotX` / `pivotY`) control which point on the bbox sits at the local origin — `0` is left/top, `1` is right/bottom. They're animatable too. Critical for "grow from one edge" effects:
949
1125
 
950
1126
  ```ts
951
1127
  // Left-anchored progress bar — width animates 0 → W, left edge stays at x
@@ -1757,6 +1933,21 @@ contactSheet(options?: {
1757
1933
 
1758
1934
  Many frames on **one** PNG, each labelled `frame N · T s`. The cheapest way to check a whole animation by eye (include the middle of every transition and the last second). Restores the current frame afterwards.
1759
1935
 
1936
+ ### `measureText(text, style?): { width, height, lines }` and `splitText(text, style?, options?): TextPiece[]`
1937
+
1938
+ Text layout helpers, exported from `pixi-effects`. They measure with the renderer's own font metrics (the same numbers a text layer with that `style` has), so a layout built from them matches what is drawn. `style` is a text layer's `style` with numbers (no expressions). A web font must be loaded first.
1939
+
1940
+ ```ts
1941
+ measureText('Hello', { fontSize: 72 }); // { width: 187.4, height: 81.3, lines: 1 }
1942
+
1943
+ interface SplitOptions { by?: 'chars' | 'words' | 'lines'; x?: number; y?: number; align?: 'left' | 'center' | 'right' }
1944
+ interface TextPiece { text: string; x: number; y: number; width: number; index: number; line: number }
1945
+ splitText('Hello world', style, { by: 'words', x: 640, y: 300, align: 'center' });
1946
+ // → [{ text: 'Hello', x, y, width, index: 0, line: 0 }, { text: 'world', ... }]
1947
+ ```
1948
+
1949
+ `x` is the line's left edge, or its centre / right edge with `align` (each line of a `\n` text is aligned on its own width); `y` is the top of the first line, and further lines go down by the line height. Whitespace takes its room but is not returned as a piece. Use one text layer per piece with the same `style` and the default top-left anchor: `{ type: 'text', text: p.text, style, initial: { x: p.x, y: p.y } }`. Kerning is kept (a piece's x is the measured width of everything before it).
1950
+
1760
1951
  ### `movie.inspect(frame?, options?): Promise<InspectReport>`
1761
1952
 
1762
1953
  Where every layer is drawn at `frame`, as JSON — for checking layout without eyes. Seeks there and stays there. `options.layers`: `'visible'` (default, only layers drawn at this frame), `'all'`, or `'none'` (just `summary` and `issues`). Faint layers (alpha < 0.3) and text whose x / y is animated (tickers) are not reported.
@@ -1777,6 +1968,27 @@ interface InspectReport {
1777
1968
  }
1778
1969
  ```
1779
1970
 
1971
+ ### `movie.inspectAudio(options?): AudioReport`
1972
+
1973
+ The soundtrack as numbers — for checking sound without listening. Every sound is also measured on its own, so overlaps do not blur it. `options.window`: seconds per row of `windows` (default 0.1).
1974
+
1975
+ ```ts
1976
+ interface AudioReport {
1977
+ duration: number; sampleRate: number;
1978
+ peakDb: number; peakAt: number; // the mix before limiting; > 0 dBFS means it was limited
1979
+ sources: Array<{
1980
+ layer: string; source: string; // 'layer "hit"', 'sfx "hit"' | 'asset "bgm"' | 'video "clip"'
1981
+ start: number; end: number; // movie time
1982
+ peakDb: number; // the MIX while it plays
1983
+ sound: { length: number; peakDb: number; loudestAt: number; brightnessHz: number }; // the sound alone
1984
+ }>;
1985
+ windows: Array<{ t: number; rmsDb: number; peakDb: number; brightnessHz: number }>;
1986
+ issues: string[]; // limited mix, inaudible layer, sfx cut off by the end, no audio — read first
1987
+ }
1988
+ ```
1989
+
1990
+ `render()` encodes this same mix: AAC in mp4 / mov (the encoder delay is trimmed with an edit list), Opus in webm / mkv.
1991
+
1780
1992
  ### `movie.render(options?): Promise<Blob>`
1781
1993
 
1782
1994
  Renders the entire timeline to a single video file. Pauses playback first.
package/llms.txt CHANGED
@@ -14,7 +14,7 @@ Rules that cause most failures (details in the skill and pitfalls):
14
14
 
15
15
  - [Skill: workflow and rules](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/SKILL.md): how to build, check and export a video; read first
16
16
  - [Cheatsheet](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/reference/cheatsheet.md): every layer type, prop, default and convention on one page
17
- - [Recipes](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/reference/recipes.md): tested building blocks (kinetic type, lower-third, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions, three.js)
17
+ - [Recipes](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/reference/recipes.md): tested building blocks (kinetic type, lower-third, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions, sound effects without files, three.js)
18
18
  - [Pitfalls](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/reference/pitfalls.md): real mistakes made by AI authors, with fixes
19
19
  - [Starter template](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/template.html): copy this file to begin
20
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixi-effects",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Declarative composition and video rendering for the web. Built on PixiJS v8.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,6 +20,7 @@
20
20
  "main": "./dist/index.cjs",
21
21
  "module": "./dist/index.js",
22
22
  "types": "./dist/index.d.ts",
23
+ "bin": { "pixi-effects-check": "ai/tools/check.mjs" },
23
24
  "files": ["dist", "ai", "docs/dsl.md", "docs/api.md", "llms.txt", "llms-full.txt", "CHANGELOG.md", "README.md", "LICENSE"],
24
25
  "keywords": [
25
26
  "pixi", "pixijs", "video", "composition", "compositing", "rendering",
@@ -1,13 +0,0 @@
1
- 'use strict';
2
-
3
- var chunkQGBOC5YL_cjs = require('./chunk-QGBOC5YL.cjs');
4
- require('./chunk-6BQ6IEW2.cjs');
5
-
6
-
7
-
8
- Object.defineProperty(exports, "CompositionSequence", {
9
- enumerable: true,
10
- get: function () { return chunkQGBOC5YL_cjs.CompositionSequence; }
11
- });
12
- //# sourceMappingURL=Composition-D7RCAGEA.cjs.map
13
- //# sourceMappingURL=Composition-D7RCAGEA.cjs.map
@@ -1,4 +0,0 @@
1
- export { CompositionSequence } from './chunk-A574IA4F.js';
2
- import './chunk-ZQX7WIYL.js';
3
- //# sourceMappingURL=Composition-RGTRZ2JX.js.map
4
- //# sourceMappingURL=Composition-RGTRZ2JX.js.map