pixi-effects 0.9.0 → 0.11.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 (53) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +6 -6
  3. package/ai/SKILL.md +4 -3
  4. package/ai/reference/cheatsheet.md +17 -6
  5. package/ai/reference/pitfalls.md +3 -3
  6. package/ai/reference/recipes.md +141 -11
  7. package/ai/template.html +11 -8
  8. package/ai/tools/render.mjs +14 -5
  9. package/dist/{Base-CSIulyIq.d.cts → Base-CwUxTfja.d.cts} +1 -1
  10. package/dist/{Base-DVgwyIUj.d.ts → Base-CyvSLoCP.d.ts} +1 -1
  11. package/dist/Composition-7TEKHZNQ.cjs +14 -0
  12. package/dist/{Composition-CM62XJKT.cjs.map → Composition-7TEKHZNQ.cjs.map} +1 -1
  13. package/dist/Composition-HIISJAX2.js +5 -0
  14. package/dist/{Composition-RJDSQYTV.js.map → Composition-HIISJAX2.js.map} +1 -1
  15. package/dist/Controller.cjs +34 -0
  16. package/dist/Controller.cjs.map +1 -1
  17. package/dist/Controller.d.cts +12 -2
  18. package/dist/Controller.d.ts +12 -2
  19. package/dist/Controller.js +34 -0
  20. package/dist/Controller.js.map +1 -1
  21. package/dist/{Movie-qGHp15c_.d.cts → Movie-DSSNOF3T.d.cts} +61 -2
  22. package/dist/{Movie-QMlFyHkf.d.ts → Movie-nzmWsxWX.d.ts} +61 -2
  23. package/dist/{chunk-O4AJYUPK.js → chunk-3IPDNC3E.js} +90 -14
  24. package/dist/chunk-3IPDNC3E.js.map +1 -0
  25. package/dist/{chunk-AGMNSJJH.cjs → chunk-6NWJLVCA.cjs} +196 -12
  26. package/dist/chunk-6NWJLVCA.cjs.map +1 -0
  27. package/dist/{chunk-FNKSXBKB.js → chunk-C6QHKYC5.js} +193 -14
  28. package/dist/chunk-C6QHKYC5.js.map +1 -0
  29. package/dist/{chunk-PDBDDGG5.cjs → chunk-F7NCHS6Q.cjs} +108 -32
  30. package/dist/chunk-F7NCHS6Q.cjs.map +1 -0
  31. package/dist/index.cjs +542 -38
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +303 -5
  34. package/dist/index.d.ts +303 -5
  35. package/dist/index.js +511 -27
  36. package/dist/index.js.map +1 -1
  37. package/dist/loader.css +110 -0
  38. package/dist/three.cjs +5 -5
  39. package/dist/three.d.cts +2 -2
  40. package/dist/three.d.ts +2 -2
  41. package/dist/three.js +1 -1
  42. package/dist/{types-oJijbn-K.d.ts → types-BXu28nJj.d.cts} +26 -2
  43. package/dist/{types-oJijbn-K.d.cts → types-BXu28nJj.d.ts} +26 -2
  44. package/docs/api.md +16 -1
  45. package/docs/dsl.md +137 -2
  46. package/llms-full.txt +318 -26
  47. package/package.json +5 -2
  48. package/dist/Composition-CM62XJKT.cjs +0 -14
  49. package/dist/Composition-RJDSQYTV.js +0 -5
  50. package/dist/chunk-AGMNSJJH.cjs.map +0 -1
  51. package/dist/chunk-FNKSXBKB.js.map +0 -1
  52. package/dist/chunk-O4AJYUPK.js.map +0 -1
  53. package/dist/chunk-PDBDDGG5.cjs.map +0 -1
package/llms-full.txt CHANGED
@@ -12,7 +12,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
12
12
 
13
13
  ## Workflow
14
14
 
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.
15
+ 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
16
  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
17
  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
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. 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.
@@ -23,7 +23,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
23
23
  - `await movie.snapshot(frame, { as: 'dataURL' })` → one frame, canvas only (no player bar).
24
24
  - `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
25
  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.
26
- 6. **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).
26
+ 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).
27
27
 
28
28
  ## The rules that cause most failures
29
29
 
@@ -34,7 +34,8 @@ You describe a video as a tree of plain objects; the library plays it in a canva
34
34
  - **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`.
35
35
  - **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.**
36
36
  - **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.
37
- - **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.
37
+ - **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.
38
+ - **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) `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.)
38
39
  - **Blend modes:** `blendMode: 'add' | 'screen' | 'multiply'` on any layer (glows, light leaks).
39
40
  - **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.
40
41
 
@@ -66,7 +67,7 @@ A video is **data**: a tree of plain objects. Everything below is a JS object yo
66
67
  | Canvas | pixels, origin top-left, `+x` right, `+y` down. Typical: 1280×720 @ 30 fps |
67
68
  | Angles | **degrees**: `rotation`, `skew*`, `rotationX`, `rotationY` |
68
69
  | Depth (`z`) | `+z` toward the viewer (bigger, nearer) |
69
- | 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) |
70
+ | 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()` |
70
71
  | Colours | `'#rrggbb'` strings or numbers; text colour is `style.fill`, shape colour is `fillColor`/`strokeColor`, image colour is `tint` |
71
72
  | Z-order | **array order**, later = on top. A layer is hidden (not removed) outside `[at, at+duration)` and keeps its last values |
72
73
  | Time of a sequence's `at` | seconds from the start of its **parent composition** (default 0) |
@@ -124,7 +125,7 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
124
125
  | 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 |
125
126
  | 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` |
126
127
  | polygon | `points: [[x,y],…] open` — canvas coordinates like `line` |
127
- | path | `d` (SVG path data) — canvas coordinates like `line` |
128
+ | path | `d` (SVG path data) — canvas coordinates like `line`. **Morph:** `morphTo: 'M…Z'` + animate `morph` 0 → 1 (points half way between, closed outlines lined up so they do not twist; `morphPoints` 8–2048) |
128
129
 
129
130
  **Draw-on:** every shape but `arc` has `trimStart` / `trimEnd` (0–1 of the outline, default 0 / 1, animatable): `trimEnd: 0` + keyframe `to: { trimEnd: 1 }` draws a line / border / path on; `trimStart` 0 → 1 after it wipes it off; both = a travelling dash. The fill is NOT trimmed. Rect outline starts top-left, circle / ellipse at 12 o'clock, both clockwise. Use `strokeCap: 'round'`.
130
131
 
@@ -132,6 +133,8 @@ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha stro
132
133
 
133
134
  **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.
134
135
 
136
+ **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.
137
+
135
138
  **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`.
136
139
 
137
140
  **three** (optional entry `pixi-effects/three`) — `three({ type:'three', width, height, setup(ctx) { …; return { objects: { knot } } }, update?, dispose? })`; drive with `'three.knot.rotation.y'` keyframes. Call `registerThree()` before `init`.
@@ -165,21 +168,30 @@ transitions: [{ kind: 'crossfade' | 'wipe' | 'iris' | 'slide' | 'dip' | 'zoom' |
165
168
  ## Presets
166
169
 
167
170
  ```js
168
- import { kenBurns, withFade } from 'pixi-effects';
171
+ import { kenBurns, withFade, wiggle, stagger, followPath, particles, random } from 'pixi-effects';
169
172
  kenBurns({ asset, name, at, duration, motion: 'still'|'scale'|'rotation'|'position',
170
173
  fit: 'cover'|'contain', ease, /* scale */ origin, zoom, direction, /* rotation */ angle, /* position */ from, to })
171
174
  withFade(spec, { in: 0.5, out: 0.5 }) // alpha fade; `out` needs spec.duration
172
175
  orbit({ duration: 6, degrees: 40 /* , radius, center, start, fov, ease, at */ }) // a camera layer that circles a point
176
+ // a seeded shake as keyframes (spread into a layer's keyframes); each prop moves on its own; ends back at `around`
177
+ keyframes: [...wiggle({ duration: 6, freq: 4, seed: 3, props: { x: { around: 'GW/2', amp: 6 }, rotation: { around: 0, amp: 1.5 } } })]
178
+ stagger(8, { each: 0.08 /* or amount: total s */, from: 'start'|'end'|'center'|'edges'|'random'|index, grid: [cols, rows], ease, seed }) // → delays [0, 0.08, …]: `at: 1 + d[i]`
179
+ stagger(layers, { each: 0.05, from: 'center' }) // → the same layers with `at` pushed back (originals untouched)
180
+ keyframes: followPath({ d: 'M 120 560 C 360 120 920 120 1160 560', duration: 4, ease, orient: true /* faces the way it goes; rotate: -90 for an up-pointing image */, from: 0, to: 1, frameRate: 30 /* = the movie's */ }) // x / y (and rotation) along an SVG path; give the layer its own `at`
181
+ ...particles({ count: 90, at: 2, emit: 0 /* 0 = burst; or seconds to spread births */, life: [1, 1.8], area: { x: 640, y: 330 /*, width, height */ }, angle: [0, 360] /* 0 right, 90 down, −90 up */,
182
+ speed: [120, 420], gravity: 240, wind: 0, drag: 1.2, sway: { amp: 30, freq: 0.6 }, size: [2, 4.5], scale: [1, 0], fade: { in: 0.2, out: 0.6 },
183
+ colors: ['#ffd166', '#ff6b9d'], shape: 'circle'|'rect'|'star', spin: [-200, 200], blendMode: 'add', seed: 1, name: 'burst', template: { type: 'image', asset } }) // seeded layers, paths baked into keyframes
184
+ const r = random(7); r(); r(); // a repeatable stream in 0…1 for JS loops (never Math.random())
173
185
  ```
174
186
  `kenBurns` covers the canvas, so source images should be at least canvas-sized (1920×1080 for 1280×720).
175
187
 
176
188
  ```js
177
- import { measureText, splitText } from 'pixi-effects'; // text layout helpers (measured with the renderer's own fonts)
189
+ import { measureText, splitText, animateText } from 'pixi-effects'; // text layout helpers (measured with the renderer's own fonts)
178
190
  measureText('Hello', style) // → { width, height, lines }
179
191
  splitText('Hello world', style, { by: 'chars' | 'words' | 'lines', x, y, align: 'left' | 'center' | 'right' })
180
192
  // → [{ text, x, y, width, index, line }] — one text layer per piece, same style, anchor top-left
181
193
  ```
182
- Per-letter / per-word animation = `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
194
+ Per-letter / per-word / per-line animation in one call: `...animateText(text, style, { x, y, align, by: 'chars'|'words'|'lines', at, duration /* required: until the last piece is gone */, in: 'rise'|'drop'|'fade'|'pop'|'zoom'|'slide'|'spin'|{ from, to, duration, ease }|false, out: <same, runs in the same wave>, stagger: { each, from, … }, idle: { y: 4, rotation: 1 }, styleFor: p => ({ fill }) })` → text layers `<name>-0…` (anchor centre; `x`/`y` in `from` are offsets) to spread into `sequences` (recipe `animate-text`). By hand: `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
183
195
 
184
196
  ## Assets
185
197
 
@@ -192,7 +204,7 @@ const movie = new Movie();
192
204
  await movie.init({ canvas, width, height, duration, frameRate, background, assets, composition });
193
205
  movie.play(); movie.pause();
194
206
  await movie.gotoFrame(n, true); // seek (frames; 30 fps => t = n/30)
195
- const blob = await movie.render({ format: 'mp4' }); // 'mp4' | 'webm' | 'mov' | 'mkv'; ~real-time
207
+ const blob = await movie.render({ format: 'mp4' }); // 'mp4' | 'webm' | 'mov' | 'mkv'; ~real-time. Fast motion: `movie.init({ motionBlur: true })` (8 samples, 180° shutter; or a number / { samples: 2–64, shutter: 0–1 }) blurs render / snapshot / contactSheet, not live playback; a render takes `samples`× as long
196
208
  movie.on('progress', e => e.progress /* 0–100 */);
197
209
  movie.audioBuffer // mixed audio (after init), if any audio layers
198
210
  await movie.contactSheet({ count: 6, as: 'dataURL' }) // ONE image of several labelled frames — look at it. Options: frames | times | count, columns, cellWidth, as
@@ -239,8 +251,8 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
239
251
  17. **Gradients / vignettes**: use shape `fillGradient` (linear or radial, stops may have alpha). Earlier sessions hand-rolled canvas images or dozens of stacked bands. *(fixed)*
240
252
  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)*
241
253
  19. **Loops**: `repeat` (finite) / `yoyo` / `repeatDelay` on any keyframe; infinite repeats are rejected with a warning. *(fixed)*
242
- 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)*
243
- 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)*
254
+ 20. **No per-frame expression time.** Expressions are evaluated once at build. A camera orbit is `orbit()`; a shake or drift is `wiggle()`, travel along a path is `followPath()`, and any other curve (waves, spirals) must be sampled into short linear keyframes (~0.1 s) in a JS loop. **Randomness must be seeded** (`rand(seed)` / `noise(x, seed)` in expressions, `random(seed)` in JS): `Math.random()` makes playback and the exported file disagree. *(partly fixed)*
255
+ 21. **Per-letter / per-word text is `animateText()` (one call: a layer per piece, a wave in and out) or `splitText()` by hand (one layer per piece, positions measured for you); a group / parent is a `{ type: 'null' }` layer + `parent: 'name'`, no caption-linked-to-slide.** One layer per letter; many layers; seeded random loops (`random(seed)`). *(hand-roll)*
244
256
  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)*
245
257
  23. **Generated images**: `canvas.toDataURL()` as an asset `src` works. Make them ≥ canvas size or `kenBurns` zooms look soft. *(docs)*
246
258
 
@@ -276,7 +288,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
276
288
  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)*
277
289
  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)*
278
290
  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)*
279
- 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`.
291
+ 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()`.)*
280
292
  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)*
281
293
  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.
282
294
  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)*
@@ -722,6 +734,82 @@ return sequences;
722
734
 
723
735
  ---
724
736
 
737
+ ## A wave of delays (`stagger`)
738
+
739
+ `stagger()` gives the delays for a wave of items, GSAP style: `each` (gap between neighbours) or `amount` (total), `from` (`'start'` `'end'` `'center'` `'edges'` `'random'` or an index), `grid: [cols, rows]`, `ease`, `seed`. Give it a count and it returns numbers; give it layers and it returns them with `at` pushed back.
740
+
741
+ ```js
742
+ // @recipe stagger
743
+ const style = { fontFamily: 'Arial, sans-serif', fontSize: 96, fontWeight: 'bold', fill: '#ffffff' };
744
+ // letters rising from the middle outwards: the same layers, each starting a little later
745
+ const letters = stagger(
746
+ splitText('RIPPLE', style, { x: 640, y: 120, align: 'center' }).map(p => ({
747
+ type: 'text', name: 'letter-' + p.index, text: p.text, style, at: 0.2, duration: 4,
748
+ initial: { x: p.x, y: p.y, alpha: 0 },
749
+ keyframes: [{ at: 0, from: { y: p.y + 40, alpha: 0 }, to: { y: p.y, alpha: 1 }, duration: 0.4, ease: 'back.out(2)' }],
750
+ })),
751
+ { each: 0.07, from: 'center' });
752
+ // a 6 x 3 grid of tiles that pop in as a ripple from the middle (numbers: use them in `at`)
753
+ const COLS = 6, ROWS = 3, delays = stagger(COLS * ROWS, { each: 0.12, grid: [COLS, ROWS], from: 'center', ease: 'sine.out' });
754
+ const tiles = delays.map((d, i) => ({
755
+ type: 'shape', shape: 'rect', width: 120, height: 120, cornerRadius: 18, name: 'tile-' + i, at: 1 + d, duration: 3 - d,
756
+ initial: { x: 190 + (i % COLS) * 180, y: 330 + Math.floor(i / COLS) * 150, fillColor: '#4cc9f0', scale: 0 },
757
+ keyframes: [{ at: 0, to: { scale: 1 }, duration: 0.4, ease: 'back.out(2)' }],
758
+ }));
759
+ return [...letters, ...tiles];
760
+ ```
761
+
762
+ ---
763
+
764
+ ## Per-letter text in one call (`animateText`)
765
+
766
+ `animateText(text, style, options)` returns one text layer per character / word / line, laid out like the whole text, each arriving (and leaving) in a wave. `duration` is required: seconds from `at` until the last piece is gone (it throws if the wave does not fit). Letters turn and scale about their own centre.
767
+
768
+ ```js
769
+ // @recipe animate-text
770
+ const style = { fontFamily: 'Arial, sans-serif', fontSize: 110, fontWeight: 'bold', fill: '#ffffff' };
771
+ return [
772
+ ...animateText('Kinetic type', style, { x: 640, y: 230, align: 'center', at: 0.3, duration: 5,
773
+ in: 'rise', out: 'fade', stagger: { each: 0.05, from: 'center' }, idle: { y: 5, rotation: 1 },
774
+ styleFor: p => (p.index === 0 ? { fill: '#ffd166' } : {}) }),
775
+ ...animateText('one word at a time', { fontFamily: 'Arial, sans-serif', fontSize: 44, fill: '#8fa0bf' },
776
+ { by: 'words', x: 640, y: 420, align: 'center', at: 1.6, duration: 3.7, in: 'pop', stagger: { each: 0.25 } }),
777
+ ];
778
+ ```
779
+
780
+ Presets for `in` / `out`: `rise` (default) `drop` `fade` `pop` `zoom` `slide` `spin`; or `{ from: { x: -30, scale: 0.5, rotation: 20 }, duration: 0.8, ease: 'power3.out' }` (`x` / `y` in `from` are offsets from the resting place, the rest are absolute values). `idle` drifts each piece between the entrance and the exit.
781
+
782
+ ---
783
+
784
+ ## Travel along a path, and morph one shape into another
785
+
786
+ `followPath()` gives a layer's `x` / `y` (and `rotation` with `orient`) along an SVG path at an even speed. `morphTo` + `morph` turn one path outline into another.
787
+
788
+ ```js
789
+ // @recipe follow-path
790
+ const ROUTE = 'M 120 560 C 360 120 920 120 1160 560';
791
+ return [
792
+ { type: 'shape', shape: 'path', d: ROUTE, duration: 5, initial: { strokeColor: '#ffffff', strokeWidth: 3, strokeAlpha: 0.25 } }, // the route, faint
793
+ { type: 'shape', shape: 'polygon', points: [[-18, -12], [18, 0], [-18, 12]], at: 0.5, duration: 4.5, initial: { fillColor: '#ffd166' },
794
+ keyframes: followPath({ d: ROUTE, duration: 4, ease: 'power2.inOut', orient: true, frameRate: 30 }) }, // a pointer that faces the way it goes
795
+ ];
796
+ ```
797
+
798
+ ```js
799
+ // @recipe morph
800
+ const BLOB = 'M 640 160 C 780 160 860 260 860 360 C 860 470 770 560 640 560 C 510 560 420 470 420 360 C 420 250 500 160 640 160 Z';
801
+ const STAR = 'M 640 130 L 700 300 L 880 300 L 735 410 L 790 590 L 640 480 L 490 590 L 545 410 L 400 300 L 580 300 Z';
802
+ return [{
803
+ type: 'shape', shape: 'path', d: BLOB, morphTo: STAR, duration: 4,
804
+ initial: { fillColor: '#ffd166', strokeColor: '#ffffff', strokeWidth: 6, strokeJoin: 'round' },
805
+ keyframes: [{ at: 0.5, to: { morph: 1 }, duration: 1.5, ease: 'power2.inOut', repeat: 1, yoyo: true }], // there and back
806
+ }];
807
+ ```
808
+
809
+ Both outlines are in canvas coordinates. Closed outlines are lined up so they do not twist.
810
+
811
+ ---
812
+
725
813
  ## Looping motion
726
814
 
727
815
  `repeat` (a finite count) and `yoyo` go on any keyframe. Total time = `duration × (repeat + 1)`.
@@ -737,20 +825,74 @@ return [{
737
825
 
738
826
  ---
739
827
 
740
- ## Particles (seeded, so every run and export is identical)
828
+ ## Particles (`particles()`: seeded, so every run and export is identical)
829
+
830
+ `particles()` returns one layer per particle: seeded start, launch angle and speed, size, colour and life, with gravity / wind / drag / sway baked into the keyframes. `count` and `life` are required, and so is the emitter `area` (its centre; add `width` / `height` to spread over a box). `emit: 0` is a burst, `emit: 3` spreads the births over 3 s. `angle`: 0 right, 90 down, −90 up. `blendMode: 'add'` makes overlaps glow. It throws (with the fix) if it would bake too many keyframes: lower `count`, `life` or `sampleRate`.
741
831
 
742
832
  ```js
743
833
  // @recipe particles
744
- let seed = 7;
745
- const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
746
- return Array.from({ length: 40 }, () => {
747
- const x = rnd() * 1280, y = 100 + rnd() * 620;
748
- return {
749
- type: 'shape', shape: 'circle', radius: 2 + rnd() * 4, duration: 6,
750
- initial: { x, y, fillColor: '#ffffff', fillAlpha: 0.3 + rnd() * 0.5 },
751
- keyframes: [{ at: 0, to: { y: y - 80 }, duration: 6, ease: 'none' }],
752
- };
753
- });
834
+ const colors = ['#ffd166', '#ff6b9d', '#4cc9f0', '#ffffff'];
835
+ const burst = (at, x, y, seed) => particles({ count: 240, at, life: [1.2, 2], area: { x, y }, speed: [40, 520], gravity: 220, drag: 1.1,
836
+ size: [4, 8], scale: [1, 0.3], colors, fade: { out: 0.7 }, blendMode: 'add', seed, name: 'burst' + seed }); // bursts of sparks
837
+ return [
838
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0a0f1f' } },
839
+ ...burst(0.5, 400, 260, 1), ...burst(1.6, 880, 200, 2), ...burst(2.8, 640, 330, 3),
840
+ ];
841
+ ```
842
+
843
+ ```js
844
+ // @recipe particles-snow
845
+ return { duration: 10, sequences: [
846
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#16203d' } },
847
+ ...particles({ count: 120, emit: 4, life: [4, 6], area: { x: 640, y: -20, width: 1400 }, angle: 90, speed: [40, 90],
848
+ size: [2, 5], sway: { amp: 30, freq: 0.6 }, fade: { in: 0.5, out: 0.5 }, name: 'snow', seed: 4 }), // drifting down, swaying
849
+ ] };
850
+ ```
851
+
852
+ ---
853
+
854
+ ## Group and orbit with a null layer (`parent`)
855
+
856
+ A `null` layer draws nothing and only moves; layers with `parent: 'name'` are drawn inside it. Their `x` / `y` are measured from the null's origin, so turning the null makes them circle it. Nulls chain (a moon on a null that is itself on the planet's null).
857
+
858
+ ```js
859
+ // @recipe null-orbit
860
+ return [
861
+ { type: 'shape', shape: 'circle', radius: 50, duration: 8, initial: { x: 640, y: 360, fillColor: '#ffd166' } }, // the sun
862
+ { type: 'null', name: 'earth-orbit', initial: { x: 640, y: 360 }, // pivot at the sun
863
+ keyframes: [{ at: 0, to: { rotation: 360 }, duration: 8, ease: 'none' }] },
864
+ { type: 'shape', shape: 'circle', radius: 20, parent: 'earth-orbit', initial: { x: 240, y: 0, fillColor: '#4cc9f0' } }, // 240 px from the sun
865
+ { type: 'null', name: 'moon-orbit', parent: 'earth-orbit', initial: { x: 240, y: 0 }, // pivot at the earth
866
+ keyframes: [{ at: 0, to: { rotation: 1440 }, duration: 8, ease: 'none' }] },
867
+ { type: 'shape', shape: 'circle', radius: 7, parent: 'moon-orbit', initial: { x: 55, y: 0, fillColor: '#e8eefc' } }, // 55 px from the earth
868
+ ];
869
+ ```
870
+
871
+ ---
872
+
873
+ ## A hand-held shake and a flickering light (`wiggle`, seeded)
874
+
875
+ `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`.
876
+
877
+ ```js
878
+ // @recipe wiggle
879
+ return [
880
+ // a title that floats and tilts a little, like a hand-held camera
881
+ { type: 'text', text: 'NIGHT SHIFT', duration: 6,
882
+ style: { fontSize: 120, fill: '#ffffff', fontWeight: '800' },
883
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
884
+ keyframes: wiggle({ duration: 6, freq: 3, seed: 1, props: { x: { around: 'GW/2', amp: 8 }, y: { around: 'GH/2', amp: 6 }, rotation: { around: 0, amp: 1.2 } } }) },
885
+ // a neon sign whose light stutters (fast, straight-line steps)
886
+ { type: 'shape', shape: 'rect', width: 520, height: 8, duration: 6,
887
+ initial: { x: 'GW/2', y: 'GH/2 + 90', fillColor: '#ff4d6d' },
888
+ keyframes: wiggle({ duration: 6, freq: 12, seed: 5, ease: 'none', props: { alpha: { around: 0.8, amp: 0.2 } } }) },
889
+ // dust: every speck has its own seed, so its drift is its own and never changes between runs
890
+ ...Array.from({ length: 24 }, (_, i) => ({
891
+ type: 'shape', shape: 'circle', radius: 2 + rand(i) * 3, duration: 6,
892
+ initial: { x: 40 + rand(i + 100) * 1200, y: 40 + rand(i + 200) * 640, fillColor: '#ffffff', fillAlpha: 0.25 + rand(i + 300) * 0.4 },
893
+ keyframes: [...wiggle({ duration: 6, freq: 0.6, seed: i, props: { y: { around: 40 + rand(i + 200) * 640, amp: 30 } } })],
894
+ })),
895
+ ];
754
896
  ```
755
897
 
756
898
  ---
@@ -993,6 +1135,23 @@ Renders a video. Intrinsic `w`/`h` come from the video's natural size. `currentT
993
1135
 
994
1136
  Use `scale: 'cover'` or `scale: 'contain'` (these resolve via the [scope](#expressions)) to fit the video to the parent composition.
995
1137
 
1138
+ ### `null` and `parent`
1139
+
1140
+ A `null` layer draws nothing; it only moves. Any layer can name it as its `parent`, and is then drawn **inside** it: the null's position, rotation, scale, skew and alpha carry the layer along. Use it to move, turn, scale or fade a group at once, to turn something about a point (a planet and its moon, a clock hand, a hinged arm), or to build a rig.
1141
+
1142
+ ```js
1143
+ { type: 'null', name: 'planet', initial: { x: 640, y: 360 },
1144
+ keyframes: [{ at: 0, to: { rotation: 360 }, duration: 8, ease: 'none' }] },
1145
+ { type: 'shape', shape: 'circle', radius: 20, parent: 'planet', initial: { x: 200, y: 0, fillColor: '#ffffff' } }, // circles the point (640, 360)
1146
+ ```
1147
+
1148
+ - A child's `x` / `y` are measured **from the null's origin** (not from the composition's corner): with the null at (640, 360), a child at `x: 200` is drawn at 840. Rotation turns the child about the null's origin; give the null `pivotX` / `pivotY` to turn about another point.
1149
+ - Nulls **chain**: a null may have a null as its `parent` (shoulder → elbow → hand). A cycle is reported and broken.
1150
+ - The children are **drawn where the null stands in the list** (stack order), together, not where each is listed: to put another layer between two children, split them across two nulls. A child's own `at` / `duration` still apply, in composition time.
1151
+ - The null lives for the whole composition unless it has `at` / `duration`; when it is not alive its children are not drawn.
1152
+ - A **mask** on a child shares the child's space, so it follows the null as well.
1153
+ - Only `null` layers can be parents (not shapes, text or images), and a `threeD` layer or a camera cannot have a parent. These cases, an unknown name and a cycle are each warned about, and the layer is drawn without a parent.
1154
+
996
1155
  ### `audio`
997
1156
 
998
1157
  Audio-only sequence. No visual. Volume is animatable via keyframes.
@@ -1089,7 +1248,7 @@ Every primitive draws centred on its local origin (so `anchorX`/`anchorY` and `p
1089
1248
  | `arc` | `radius` | `innerRadius`, `startAngle` (0), `endAngle` (360) |
1090
1249
  | `line` | `from: [x,y]`, `to: [x,y]` (canvas coordinates; stroke in `initial`) | `trimStart`, `trimEnd` |
1091
1250
  | `polygon` | `points: [[x,y], …]` | `open` (default false), `trimStart`, `trimEnd` |
1092
- | `path` | `d` (SVG path data) | `trimStart`, `trimEnd` |
1251
+ | `path` | `d` (SVG path data) | `trimStart`, `trimEnd`, `morphTo`, `morph`, `morphPoints` |
1093
1252
 
1094
1253
  **`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.
1095
1254
 
@@ -1107,6 +1266,14 @@ Every primitive draws centred on its local origin (so `anchorX`/`anchorY` and `p
1107
1266
  keyframes: [{ at: 0, to: { trimEnd: 1 }, duration: 1, ease: 'power2.inOut' }] } // a check mark drawing itself
1108
1267
  ```
1109
1268
 
1269
+ **Morph (`morphTo` / `morph`).** A `path` can turn into another outline: give `morphTo` (SVG path data in the same canvas coordinates as `d`) and animate `morph` from 0 (the `d` outline) to 1 (the `morphTo` outline). In between it is drawn as a polygon of points half way from one to the other (`morphPoints`: 8–2048, default 128). Closed outlines are lined up (start point and direction) so they do not twist; sub-paths are paired in order, and an extra one grows from (or shrinks into) its own centre. Fill and stroke both follow, and `trimStart` / `trimEnd` walk the morphing outline. Write both outlines as `M … L … C … Z` in absolute coordinates (arcs and holes work too, but keep the same number of sub-paths on both sides).
1270
+
1271
+ ```ts
1272
+ { type: 'shape', shape: 'path', d: 'M 640 160 C 780 160 860 260 860 360 C 860 470 770 560 640 560 C 510 560 420 470 420 360 C 420 250 500 160 640 160 Z',
1273
+ morphTo: 'M 640 130 L 700 300 L 880 300 L 735 410 L 790 590 L 640 480 L 490 590 L 545 410 L 400 300 L 580 300 Z',
1274
+ initial: { fillColor: '#ffd166' }, keyframes: [{ at: 0.5, to: { morph: 1 }, duration: 1.5, ease: 'power2.inOut' }] } // a blob turns into a star
1275
+ ```
1276
+
1110
1277
  `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'`.
1111
1278
 
1112
1279
  **Style** is set on `initial` and animatable via keyframes:
@@ -1569,8 +1736,18 @@ The expression parser is in-tree (no eval, CSP-safe). See [`src/expr/Parser.ts`]
1569
1736
  | `sqrt(x)` | |
1570
1737
  | `pow(a, b)` | power |
1571
1738
  | `sin(x)`, `cos(x)`, `tan(x)` | radians |
1739
+ | `lerp(a, b, t)` | `a + (b - a) * t` |
1740
+ | `clamp(x, lo, hi)` | |
1741
+ | `smoothstep(a, b, x)` | 0 below `a`, 1 above `b`, smooth in between |
1742
+ | `mod(a, b)` | never negative (`mod(-1, 3)` is 2) |
1743
+ | `step(edge, x)` | 0 below `edge`, 1 from `edge` on: the way to write an "if" |
1744
+ | `rand(seed)` | a number in 0…1; the same seed always gives the same number |
1745
+ | `noise(x, seed)` | smooth noise in −1…1, continuous in `x`; `seed` is optional |
1746
+ | `PI` | a constant |
1572
1747
 
1573
- No comparison, conditional, bitwise, or string operators — keep it numeric.
1748
+ No comparison, conditional, bitwise, or string operators — keep it numeric (`step()` and `clamp()` cover most "if"s).
1749
+
1750
+ **Randomness is seeded.** `rand(seed)` and `noise(x, seed)` depend only on their arguments, so playback, seeking and export agree; never use `Math.random()` for anything that ends up in the picture. Expressions are still evaluated once, when the layer is built, so give each layer its own seed (in a JS loop: `x: `W/2 + rand(${i}) * 200``). For randomness in plain JS use `random(seed)` (`const r = random(7); r(); r()`), exported by the library. For a shake that changes over time use [`wiggle()`](#wiggle).
1574
1751
 
1575
1752
  ### Scope variables
1576
1753
 
@@ -1948,6 +2125,106 @@ sequences: [
1948
2125
 
1949
2126
  `z` is specified, so it stops following `fov`; for an orbit **plus** a dolly zoom write your own `fov` and `z = (H/2) / tan(fov/2)` keyframes.
1950
2127
 
2128
+ ### `wiggle`
2129
+
2130
+ A seeded shake as keyframes (an expression cannot change over time, so it is baked into ordinary keyframes): drift, hand-held camera, a flickering light.
2131
+
2132
+ ```ts
2133
+ import { wiggle } from 'pixi-effects';
2134
+
2135
+ { type: 'text', text: 'hi', at: 0, duration: 6, initial: { x: 'GW/2', y: 'GH/2' },
2136
+ keyframes: [
2137
+ ...wiggle({ duration: 6, freq: 4, seed: 3, props: { x: { around: 'GW/2', amp: 6 }, rotation: { around: 0, amp: 1.5 } } }),
2138
+ ] }
2139
+ ```
2140
+
2141
+ | Option | Type | Notes |
2142
+ | ------ | ---- | ----- |
2143
+ | `duration` | number | seconds; required |
2144
+ | `props` | `{ [prop]: { around, amp } }` | each property moves on its own (x and y are not in lockstep). `around` is the resting value: a number or an expression; the shake starts and ends there. `amp` is how far it strays. Any animatable name works (`rotation`, `scale`, `alpha`, `'filters.blur.blur'`). |
2145
+ | `at` | number | start, seconds from the start of the layer (default 0) |
2146
+ | `freq` | number | new random targets per second (default 3 drifts; 8 and up shakes) |
2147
+ | `seed` | number | another seed is another take (default 0) |
2148
+ | `ease` | string | between targets (default `'sine.inOut'`; `'none'` gives jittery straight lines) |
2149
+
2150
+ It returns `Keyframe[]`: spread it into `keyframes`, next to others. Do not put another keyframe on the same property during the wiggle.
2151
+
2152
+ ### `particles`
2153
+
2154
+ Particles as plain layers: a burst, a fountain, snow, confetti, sparks. Each particle is a layer with its own **seeded** start, launch angle and speed, size, colour and life, and a path baked into keyframes, so playback, seeking and export agree; it exists only while it is alive. Spread the result into `sequences`.
2155
+
2156
+ ```ts
2157
+ import { particles } from 'pixi-effects';
2158
+
2159
+ ...particles({ count: 240, at: 2, life: [1.2, 2], area: { x: 640, y: 330 }, speed: [40, 520], gravity: 220, drag: 1.1,
2160
+ size: [4, 8], scale: [1, 0.3], colors: ['#ffd166', '#ff6b9d', '#4cc9f0'], fade: { out: 0.7 }, blendMode: 'add' }) // a burst of sparks
2161
+ ```
2162
+
2163
+ | Option | Notes |
2164
+ | ------ | ----- |
2165
+ | `count`, `life` | required: how many, and how long each lives (a number or `[min, max]` seconds) |
2166
+ | `area` | required: `{ x, y }` is the centre they start from; add `width` / `height` to spread over a box (snow: `{ x: 640, y: -20, width: 1400 }`) |
2167
+ | `at`, `emit` | the first birth, and the seconds over which births are spread (`0`, the default, is a burst) |
2168
+ | `angle`, `speed` | launch direction (degrees: 0 right, 90 down, −90 up; default `[0, 360]`) and speed (px/s; default `[100, 300]`) |
2169
+ | `gravity`, `wind`, `drag` | accelerations (px/s², down and sideways) and air drag per second |
2170
+ | `sway` | `{ amp, freq }`: a smooth side-to-side wobble (snow, leaves, embers) |
2171
+ | `size`, `scale`, `fade` | radius (default `[3, 6]`), scale over the life (`[1, 0]` shrinks away), alpha ramps `{ in, out }` in seconds |
2172
+ | `colors`, `shape` | fill colours picked per particle; `'circle'` (default), `'rect'` (square) or `'star'` |
2173
+ | `spin` | degrees per second (they also start at a random angle) |
2174
+ | `blendMode` | `'add'` makes overlaps glow |
2175
+ | `template` | any layer (an image, a text) used for every particle instead of a shape; its keyframes are kept |
2176
+ | `seed`, `name` | another seed is another burst; layers are named `<name>-0`, `<name>-1`, … |
2177
+ | `sampleRate` | samples per second of a curved path (default 20); a path with no gravity, wind, drag or sway is one straight tween |
2178
+
2179
+ It throws, with the fix, when it would bake an absurd number of keyframes: lower `count`, `life` or `sampleRate`. Particles are real layers (names like `burst1-0`), so `inspect` and the timeline list them, grouped as one family.
2180
+
2181
+ ### `followPath`
2182
+
2183
+ A layer travelling along an SVG path, as keyframes for `x` and `y` (and `rotation`). The layer's own `x` / `y` are the points of the path (give shapes their centre there, and text `anchorX: 0.5, anchorY: 0.5`). The path is walked by arc length, so the speed is even whatever the curve, and sampled once per frame as short straight steps: set `frameRate` to the movie's and the layer is exactly on the path at every frame.
2184
+
2185
+ ```ts
2186
+ import { followPath } from 'pixi-effects';
2187
+
2188
+ { type: 'shape', shape: 'polygon', points: [[-18, -12], [18, 0], [-18, 12]], at: 0.5, duration: 4,
2189
+ initial: { fillColor: '#ffd166' },
2190
+ keyframes: followPath({ d: 'M 120 560 C 360 120 920 120 1160 560', duration: 4, ease: 'power2.inOut', orient: true, frameRate: 30 }) }
2191
+ ```
2192
+
2193
+ | Option | Notes |
2194
+ | ------ | ----- |
2195
+ | `d` | the route: SVG path data in canvas coordinates; several sub-paths are walked in order; a closed one comes back to its start |
2196
+ | `duration` | seconds; required |
2197
+ | `at` | start, seconds from the start of the layer (default 0): give the layer its own `at` so it is not drawn at the origin before the trip |
2198
+ | `ease` | GSAP ease for the progress along the path (default `'none'`: constant speed) |
2199
+ | `from` / `to` | fractions 0–1 of the path to start and stop at (default 0 → 1; `from: 1, to: 0` goes backwards) |
2200
+ | `orient` / `rotate` | `orient: true` also keyframes `rotation` (degrees) to face the way it is going, without ever jumping by a turn; `rotate` adds an offset (`-90` for an image that points up) |
2201
+ | `frameRate` | samples per second (default 30) |
2202
+
2203
+ It returns `Keyframe[]`: spread it next to other keyframes, but not another `x`, `y` or `rotation` during the trip.
2204
+
2205
+ ### `stagger`
2206
+
2207
+ Delays for a wave of items, in the style of GSAP's `stagger`. Pure and seeded, so playback and export agree.
2208
+
2209
+ ```ts
2210
+ import { stagger } from 'pixi-effects';
2211
+
2212
+ const d = stagger(6, { each: 0.08 }); // [0, 0.08, 0.16, 0.24, 0.32, 0.4] -> `at: 1 + d[i]`
2213
+ const letters = stagger(layers, { each: 0.05, from: 'center' }); // the same layers, `at` pushed back (the originals are not touched)
2214
+ const ripple = stagger(18, { each: 0.12, grid: [6, 3], from: 'center', ease: 'sine.out' });
2215
+ ```
2216
+
2217
+ | Option | Notes |
2218
+ | ------ | ----- |
2219
+ | `each` | seconds between neighbours (the farthest one waits `each` × its distance). Default 0.1. Not together with `amount`. |
2220
+ | `amount` | seconds from the first to the last, however many there are |
2221
+ | `from` | `'start'` (default), `'end'`, `'center'` (outward), `'edges'` (inward), `'random'` (a seeded shuffle of the same delays) or an item index |
2222
+ | `grid` | `[columns, rows]`: items fill the grid row by row, and the wave travels by straight-line distance (a ripple) |
2223
+ | `ease` | a GSAP ease that reshapes the spread (default `'none'`); the first stays at 0, the last at the full spread |
2224
+ | `seed` | for `from: 'random'`: another seed, another order |
2225
+
2226
+ With a layer array each layer's own `at` is kept and the delay is added to it. Keyframes inside the layers stay as they are (they count from the layer's start).
2227
+
1951
2228
  ### `withFade`
1952
2229
 
1953
2230
  Spec helper that adds alpha fade-in / fade-out to **any** sequence spec. Mutates and returns the spec, so it composes with other presets (`kenBurns`, …) and drops straight into `sequences[]`.
@@ -2016,6 +2293,9 @@ interface MovieOptions {
2016
2293
  canvas?: HTMLCanvasElement; // existing canvas to render into; otherwise PixiJS creates one
2017
2294
  assets?: AssetSpec[]; // [{ name, src }]
2018
2295
  composition?: CompositionSpec; // root composition (see DSL reference)
2296
+ poster?: number; // seconds: the frame shown before play (negative: from the end), see movie.poster
2297
+ motionBlur?: boolean | number | { samples?: number; shutter?: number }; // blur for render() / snapshot() / contactSheet(), not live playback; see Motion blur
2298
+ loader?: HTMLElement | string | false; // the page's loader (see pixi-effects/loader.css): faded out when ready, stopped if init fails; default: a .pe-loader next to the canvas
2019
2299
  }
2020
2300
  ```
2021
2301
 
@@ -2044,7 +2324,7 @@ Seeks to a specific frame. Pauses if currently playing? **No** — does not chan
2044
2324
  ### `movie.snapshot(frame?, options?): Promise<Blob | string>`
2045
2325
 
2046
2326
  ```ts
2047
- snapshot(frame?: number, options?: { scale?: number; type?: 'image/png' | 'image/jpeg'; as?: 'blob' | 'dataURL' }): Promise<Blob | string>
2327
+ snapshot(frame?: number, options?: { scale?: number; type?: 'image/png' | 'image/jpeg'; as?: 'blob' | 'dataURL'; motionBlur?: MotionBlurSpec }): Promise<Blob | string>
2048
2328
  ```
2049
2329
 
2050
2330
  A picture of one frame: **the canvas only** (the player bar is not in it). Seeks to `frame` (default: the current frame) and stays there. `as: 'dataURL'` returns a `data:` URL string, handy when a script can only return text. Use it to look at what you built.
@@ -2057,6 +2337,7 @@ contactSheet(options?: {
2057
2337
  columns?: number; // default 3
2058
2338
  cellWidth?: number; // picture width in px, default 480
2059
2339
  as?: 'blob' | 'dataURL';
2340
+ motionBlur?: MotionBlurSpec; // see Motion blur
2060
2341
  }): Promise<Blob | string>
2061
2342
  ```
2062
2343
 
@@ -2133,9 +2414,19 @@ interface RenderOptions {
2133
2414
  codec?: string; // default per format (mp4/mov→aac, webm/mkv→opus)
2134
2415
  bitrate?: 'very-low' | 'low' | 'medium' | 'high' | 'very-high'; // default 'high'
2135
2416
  };
2417
+ motionBlur?: boolean | number | { samples?: number; shutter?: number }; // overrides movie.init's; false turns it off
2136
2418
  }
2137
2419
  ```
2138
2420
 
2421
+ #### Motion blur
2422
+
2423
+ `movie.init({ motionBlur })` (or the same option on a single `render()`, `snapshot()` or `contactSheet()` call) exposes each frame the way a film camera does: the frame is drawn `samples` times at moments spread over the shutter interval around its time, and the pictures are averaged. Fast motion smears along its path; a still picture is unchanged. `true` is 8 samples at a 180° shutter, a number is the sample count (2–64), `{ samples, shutter }` sets both (`shutter` is the fraction of a frame the shutter stays open, above 0 and up to 1; `0.5` is the film look, `1` blurs more). `false` turns it off for one call.
2424
+
2425
+ - It applies to what is **made**: the exported file, snapshots, contact sheets (so `pixi-effects-check` and `inspect`-style reviews show what the file will have). Live playback is not blurred.
2426
+ - Cost: a render takes about `samples` times as long (video layers are decoded once per sample). Eight samples is enough for most motion; raise it for very fast or large movement, where fewer samples show as separate ghost images instead of a smear.
2427
+ - After a blurred snapshot, contact sheet or render the playhead and the stage are on an exact frame again, not a sample.
2428
+ - `pixi-effects-render --motion-blur 8 [--shutter 0.5]` sets it for one file.
2429
+
2139
2430
  **Audio codec fallback:** unless you name `audio.codec`, the first codec this browser can actually encode is used (mp4: `aac`, then `opus`, `mp3`, `flac`; webm: `opus`, `vorbis`). Chrome on Linux has no AAC encoder, so there an mp4 with sound carries Opus and a warning says so (before, the export failed with a message about encoder configurations). A codec you name is never swapped: if it cannot be encoded the error says which one would work.
2140
2431
 
2141
2432
  Returns a `Blob` whose `type` is the container's MIME (e.g. `video/mp4`). Emits `'progress'` repeatedly during the render.
@@ -2261,6 +2552,7 @@ interface ControllerOptions {
2261
2552
  canvas: HTMLCanvasElement; // required
2262
2553
  showExportButton?: boolean; // default true; hides ⬇ + popover
2263
2554
  enableKeyboardShortcuts?: boolean; // default true
2555
+ clickToPlay?: boolean; // default true: a click / tap on the picture plays / pauses (like <video>); false leaves the canvas alone
2264
2556
  className?: string; // default 'movie-controller'
2265
2557
  theme?: ControllerTheme; // colours / thickness / font of the bar, see Theme
2266
2558
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixi-effects",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Declarative composition and video rendering for the web. Built on PixiJS v8.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,6 +12,7 @@
12
12
  "import": { "types": "./dist/Controller.d.ts", "default": "./dist/Controller.js" },
13
13
  "require": { "types": "./dist/Controller.d.cts", "default": "./dist/Controller.cjs" }
14
14
  },
15
+ "./loader.css": "./dist/loader.css",
15
16
  "./three": {
16
17
  "import": { "types": "./dist/three.d.ts", "default": "./dist/three.js" },
17
18
  "require": { "types": "./dist/three.d.cts", "default": "./dist/three.cjs" }
@@ -32,7 +33,9 @@
32
33
  "repository": { "type": "git", "url": "git+https://github.com/yjmtmtk/pixi-effects.git" },
33
34
  "homepage": "https://github.com/yjmtmtk/pixi-effects#readme",
34
35
  "bugs": { "url": "https://github.com/yjmtmtk/pixi-effects/issues" },
35
- "sideEffects": false,
36
+ "sideEffects": [
37
+ "./dist/loader.css"
38
+ ],
36
39
  "scripts": {
37
40
  "build": "tsup",
38
41
  "dev": "tsup --watch",
@@ -1,14 +0,0 @@
1
- 'use strict';
2
-
3
- var chunkPDBDDGG5_cjs = require('./chunk-PDBDDGG5.cjs');
4
- require('./chunk-AGMNSJJH.cjs');
5
- require('./chunk-UQBGW5DO.cjs');
6
-
7
-
8
-
9
- Object.defineProperty(exports, "CompositionSequence", {
10
- enumerable: true,
11
- get: function () { return chunkPDBDDGG5_cjs.CompositionSequence; }
12
- });
13
- //# sourceMappingURL=Composition-CM62XJKT.cjs.map
14
- //# sourceMappingURL=Composition-CM62XJKT.cjs.map
@@ -1,5 +0,0 @@
1
- export { CompositionSequence } from './chunk-O4AJYUPK.js';
2
- import './chunk-FNKSXBKB.js';
3
- import './chunk-DKGG7WD4.js';
4
- //# sourceMappingURL=Composition-RJDSQYTV.js.map
5
- //# sourceMappingURL=Composition-RJDSQYTV.js.map