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/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0
4
+
5
+ **Performance**
6
+
7
+ - **A piece with many layers builds in a fraction of the time.** Every layer now builds its animation in a small timeline of its own, which is added to its parent once finished. Adding thousands of tweens one by one to a single GSAP timeline made GSAP re-measure the whole timeline at every add, so the cost grew with the square of the count: the 700-layer `hanabi-night` took 3.2 s to build with two 1.3–1.6 s freezes of the page, and now takes 0.5 s with none above 170 ms. Positions stay absolute, so every frame is the same.
8
+ - **A shared loader.** `pixi-effects/loader.css` (shipped as `dist/loader.css`) and one line of HTML, `<div class="pe-loader" data-label="LOADING"></div>` next to the canvas: a ring, `--pulse` (a breathing dot) or `--bar` (a sweeping line), or `--custom` for a drawing of your own, restyled with `--pe-loader-color` / `-bg` / `-track` / `-size`. It is HTML and CSS, so it is on screen before any script runs, and it animates only `transform` and `opacity`, which the browser runs off the main thread, so it keeps moving while the page builds the piece. `movie.init()` finds it, fades it out when the movie is ready and, if init fails, stops it and says so (`init({ loader })` names another element or turns it off). Every gallery piece has one (`hanabi-night` has a rocket of its own on the shared frame), `ai/template.html` has one, and the guide's player page says how to use it.
9
+
10
+ **Changed**
11
+
12
+ - The gallery pieces `orbit-rig` and `shape-shift` were redesigned as pieces first and feature showcases second: an orrery plate in ivory and brass ("Orrery") and a Bauhaus poster in motion ("Form Follows"). Same ids, so existing links keep working.
13
+
14
+ **Added**
15
+
16
+ - A gallery piece that uses particles and motion blur (`hanabi-night`: a fireworks festival title card, 36 pieces now), with its demo in the guide.
17
+ - **Motion blur.** `movie.init({ motionBlur: true })` (or a sample count, or `{ samples, shutter }`, or the same option on one `render()`, `snapshot()` or `contactSheet()`) exposes every frame over a shutter interval: the frame is drawn several times at moments around its time and averaged, so fast motion smears like on film. It applies to what is made (the export, snapshots, contact sheets, so `pixi-effects-check` shows what the file will have), not to live playback. `pixi-effects-render --motion-blur 8 --shutter 0.5` sets it for one file.
18
+ - **`particles()`**: bursts, fountains, snow, confetti and sparks as plain layers. Every particle has its own seeded start, launch angle and speed, size, colour and life, and a path baked into keyframes (gravity, wind, drag, sway), so playback, seeking and export agree. `emit` spreads births over time, `fade` / `scale` shape the life, `shape` is `circle` / `rect` / `star` or any `template` layer, `blendMode: 'add'` makes overlaps glow.
19
+
20
+ **Fixed**
21
+
22
+ - **The last frame of an export was empty.** The final frame sits at the exact end of the movie, where every layer's lifespan has just closed, so a layer that lasts to the end vanished in it (the exported video, and the last cell of a contact sheet, ended on a blank frame). That frame is now drawn a hair before the end, so the final composition stays on screen.
23
+
24
+ ## 0.10.0
25
+
26
+ **Added**
27
+
28
+ - **Seeded randomness.** Expressions gain `rand(seed)` (0…1) and `noise(x, seed)` (smooth, −1…1), plus `lerp`, `clamp`, `smoothstep`, `mod`, `step` (the way to write an "if") and `PI`. For plain JS, `random(seed)` returns a repeatable stream. The same seed always gives the same picture, in playback, seeking and export.
29
+ - **Null layers and `parent`.** `{ type: 'null', name: 'rig' }` draws nothing and only moves; layers with `parent: 'rig'` are drawn inside it and follow its position, rotation, scale and alpha (orbits, clock hands, a whole group fading). Nulls chain; a child's `x` / `y` are measured from the null's origin. A wrong parent (unknown name, not a null, a cycle, a `threeD` layer) warns and the layer is drawn without one.
30
+ - **`stagger()`**: delays for a wave of items, GSAP style (`each` or `amount`, `from: 'start' | 'end' | 'center' | 'edges' | 'random' | index`, `grid: [cols, rows]`, `ease`, seeded `random`). `stagger(8, { each: 0.08 })` returns the delays; `stagger(layers, …)` returns the layers with `at` pushed back.
31
+ - A gallery piece for the path and text tools (`shape-shift`, 35 pieces now: morphing outlines, a comet on a loop, a title that arrives letter by letter), with the demo in the guide.
32
+ - A guide page on these four (`Motion: groups, waves and randomness`, with tested recipes) and a gallery piece that uses all of them (`orbit-rig`, 34 pieces now).
33
+ - **Click the picture to play / pause** in the `Controller` (a tap on a phone too), like a `<video>`; the pointer cursor shows it. A tap that only closes the export popover does not also toggle. `new Controller(movie, { canvas, clickToPlay: false })` turns it off.
34
+ - **`animateText()`**: per-character, per-word or per-line text animation in one call (`...animateText('Hello', style, { x, y, align, at, duration, in: 'rise', out: 'fade', stagger: { each: 0.05, from: 'center' }, idle: { y: 4 } })`). One text layer per piece laid out like the whole text, entering and leaving in a wave; presets `rise drop fade pop zoom slide spin` or your own `from` / `to`; an optional seeded idle drift; a clear error when `duration` is too short for the wave.
35
+ - **`followPath()`**: a layer travelling along an SVG path as keyframes (`keyframes: followPath({ d, duration, ease, orient: true, frameRate: 30 })`), by arc length so the speed is even, one sample per frame, with optional heading (`orient` / `rotate`) that never jumps by a turn, and `from` / `to` fractions.
36
+ - **Path morphing.** A `shape: 'path'` with `morphTo` (another SVG path) and an animatable `morph` (0 → 1) turns into the other outline: closed outlines are lined up so they do not twist, sub-paths are paired in order, fill, stroke and a trim follow, and the ends are drawn exactly as the paths.
37
+ - **`wiggle()`**: a seeded shake or drift as keyframes (`keyframes: [...wiggle({ duration: 6, props: { x: { around: 'GW/2', amp: 6 } } })]`); every property moves on its own and ends back at its resting value.
38
+
3
39
  ## 0.9.0
4
40
 
5
41
  **Added**
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.9.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `0.9.0` adds a poster time (`movie.init({ poster })`) and a guide site, `0.8.0` adds events and a theme for building your own player, `0.7.0` adds filters by name, the `pixi-effects-render` and `pixi-effects-view` commands and the timeline chart, `0.6.0` adds draw-on strokes (`trimEnd`) and text that changes over time, `0.5.0` adds synthesised sound effects (`sfx`, no files) and the `check` tool, `0.4.0` makes a mask share its layer's lifetime, `0.3.0` made keyframe `at` relative to the layer).
3
+ > **Status**: experimental — current release `0.11.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `0.11.0` adds particles, motion blur, a shared loader and a much faster build for pieces with many layers, `0.10.0` adds seeded randomness, `wiggle` and `stagger`, null layers with `parent`, `animateText`, `followPath` and path morphing, and click-to-play in the player, `0.9.0` adds a poster time (`movie.init({ poster })`) and a guide site, `0.8.0` adds events and a theme for building your own player, `0.7.0` adds filters by name, the `pixi-effects-render` and `pixi-effects-view` commands and the timeline chart, `0.6.0` adds draw-on strokes (`trimEnd`) and text that changes over time, `0.5.0` adds synthesised sound effects (`sfx`, no files) and the `check` tool, `0.4.0` makes a mask share its layer's lifetime, `0.3.0` made keyframe `at` relative to the layer).
4
4
 
5
- **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 33 portfolio pieces written as plain data by AI models · **[Live demos →](https://yjmtmtk.github.io/pixi-effects/)** · 12 numbered examples + an in-browser playground.
5
+ **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 36 portfolio pieces written as plain data by AI models · **[Live demos →](https://yjmtmtk.github.io/pixi-effects/)** · 12 numbered examples + an in-browser playground.
6
6
 
7
7
  Declarative composition and video rendering for the web. After Effects-style timelines on top of [PixiJS v8](https://pixijs.com/) and [GSAP](https://gsap.com/), with strict TypeScript types. Render to MP4 / WebM / MOV via [mediabunny](https://mediabunny.dev/).
8
8
 
@@ -40,8 +40,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
40
40
  "gsap": "https://esm.sh/gsap@3.12.5",
41
41
  "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
42
42
  "mediabunny": "https://esm.sh/mediabunny",
43
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.9.0/dist/index.js",
44
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.9.0/dist/Controller.js"
43
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.11.0/dist/index.js",
44
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.11.0/dist/Controller.js"
45
45
  }
46
46
  }
47
47
  </script>
@@ -52,7 +52,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
52
52
  </script>
53
53
  ```
54
54
 
55
- Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.9.0/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
55
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.11.0/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
56
56
 
57
57
  > **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
58
58
  >
@@ -68,7 +68,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
68
68
  "imports": {
69
69
  "...": "(everything from the importmap above)",
70
70
  "three": "https://esm.sh/three@0.178.0",
71
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.9.0/dist/three.js"
71
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.11.0/dist/three.js"
72
72
  }
73
73
  }
74
74
  </script>
package/ai/SKILL.md CHANGED
@@ -9,7 +9,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
9
9
 
10
10
  ## Workflow
11
11
 
12
- 1. **Start from `template.html`** (copy it; it loads the library from a CDN, collects every console warning into `window.__logs`, and exposes `window.movie`). One HTML file, plain JS. Default canvas 1280×720 @ 30 fps.
12
+ 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.
13
13
  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`.
14
14
  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.
15
15
  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.
@@ -20,7 +20,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
20
20
  - `await movie.snapshot(frame, { as: 'dataURL' })` → one frame, canvas only (no player bar).
21
21
  - `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
22
  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
- 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).
23
+ 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).
24
24
 
25
25
  ## The rules that cause most failures
26
26
 
@@ -31,7 +31,8 @@ You describe a video as a tree of plain objects; the library plays it in a canva
31
31
  - **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
32
  - **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.**
33
33
  - **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:** `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.
34
+ - **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.
35
+ - **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.)
35
36
  - **Blend modes:** `blendMode: 'add' | 'screen' | 'multiply'` on any layer (glows, light leaks).
36
37
  - **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
38
 
@@ -9,7 +9,7 @@ A video is **data**: a tree of plain objects. Everything below is a JS object yo
9
9
  | Canvas | pixels, origin top-left, `+x` right, `+y` down. Typical: 1280×720 @ 30 fps |
10
10
  | Angles | **degrees**: `rotation`, `skew*`, `rotationX`, `rotationY` |
11
11
  | Depth (`z`) | `+z` toward the viewer (bigger, nearer) |
12
- | 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) |
12
+ | 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()` |
13
13
  | Colours | `'#rrggbb'` strings or numbers; text colour is `style.fill`, shape colour is `fillColor`/`strokeColor`, image colour is `tint` |
14
14
  | Z-order | **array order**, later = on top. A layer is hidden (not removed) outside `[at, at+duration)` and keeps its last values |
15
15
  | Time of a sequence's `at` | seconds from the start of its **parent composition** (default 0) |
@@ -67,7 +67,7 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
67
67
  | 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 |
68
68
  | 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` |
69
69
  | polygon | `points: [[x,y],…] open` — canvas coordinates like `line` |
70
- | path | `d` (SVG path data) — canvas coordinates like `line` |
70
+ | 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) |
71
71
 
72
72
  **Draw-on:** every shape but `arc` has `trimStart` / `trimEnd` (0–1 of the outline, default 0 / 1, animatable): `trimEnd: 0` + keyframe `to: { trimEnd: 1 }` draws a line / border / path on; `trimStart` 0 → 1 after it wipes it off; both = a travelling dash. The fill is NOT trimmed. Rect outline starts top-left, circle / ellipse at 12 o'clock, both clockwise. Use `strokeCap: 'round'`.
73
73
 
@@ -75,6 +75,8 @@ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha stro
75
75
 
76
76
  **composition** — `width height duration sequences transitions`. With `threeD: true` it is a **card**: its children are drawn into one texture (content outside its `width × height` is clipped: size it for a soft shadow too; a plain 2D composition does not clip, but is skipped when its whole rectangle is off screen) and move / rotate in depth together. Children use the composition's local coordinates and times. Position/rotate/scale it as a unit; to rotate/scale about its centre set `pivotX/pivotY` to the centre and `x/y` to where that point should sit.
77
77
 
78
+ **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.
79
+
78
80
  **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`.
79
81
 
80
82
  **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`.
@@ -108,21 +110,30 @@ transitions: [{ kind: 'crossfade' | 'wipe' | 'iris' | 'slide' | 'dip' | 'zoom' |
108
110
  ## Presets
109
111
 
110
112
  ```js
111
- import { kenBurns, withFade } from 'pixi-effects';
113
+ import { kenBurns, withFade, wiggle, stagger, followPath, particles, random } from 'pixi-effects';
112
114
  kenBurns({ asset, name, at, duration, motion: 'still'|'scale'|'rotation'|'position',
113
115
  fit: 'cover'|'contain', ease, /* scale */ origin, zoom, direction, /* rotation */ angle, /* position */ from, to })
114
116
  withFade(spec, { in: 0.5, out: 0.5 }) // alpha fade; `out` needs spec.duration
115
117
  orbit({ duration: 6, degrees: 40 /* , radius, center, start, fov, ease, at */ }) // a camera layer that circles a point
118
+ // a seeded shake as keyframes (spread into a layer's keyframes); each prop moves on its own; ends back at `around`
119
+ keyframes: [...wiggle({ duration: 6, freq: 4, seed: 3, props: { x: { around: 'GW/2', amp: 6 }, rotation: { around: 0, amp: 1.5 } } })]
120
+ 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]`
121
+ stagger(layers, { each: 0.05, from: 'center' }) // → the same layers with `at` pushed back (originals untouched)
122
+ 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`
123
+ ...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 */,
124
+ 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 },
125
+ 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
126
+ const r = random(7); r(); r(); // a repeatable stream in 0…1 for JS loops (never Math.random())
116
127
  ```
117
128
  `kenBurns` covers the canvas, so source images should be at least canvas-sized (1920×1080 for 1280×720).
118
129
 
119
130
  ```js
120
- import { measureText, splitText } from 'pixi-effects'; // text layout helpers (measured with the renderer's own fonts)
131
+ import { measureText, splitText, animateText } from 'pixi-effects'; // text layout helpers (measured with the renderer's own fonts)
121
132
  measureText('Hello', style) // → { width, height, lines }
122
133
  splitText('Hello world', style, { by: 'chars' | 'words' | 'lines', x, y, align: 'left' | 'center' | 'right' })
123
134
  // → [{ text, x, y, width, index, line }] — one text layer per piece, same style, anchor top-left
124
135
  ```
125
- Per-letter / per-word animation = `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
136
+ 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.
126
137
 
127
138
  ## Assets
128
139
 
@@ -135,7 +146,7 @@ const movie = new Movie();
135
146
  await movie.init({ canvas, width, height, duration, frameRate, background, assets, composition });
136
147
  movie.play(); movie.pause();
137
148
  await movie.gotoFrame(n, true); // seek (frames; 30 fps => t = n/30)
138
- const blob = await movie.render({ format: 'mp4' }); // 'mp4' | 'webm' | 'mov' | 'mkv'; ~real-time
149
+ 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
139
150
  movie.on('progress', e => e.progress /* 0–100 */);
140
151
  movie.audioBuffer // mixed audio (after init), if any audio layers
141
152
  await movie.contactSheet({ count: 6, as: 'dataURL' }) // ONE image of several labelled frames — look at it. Options: frames | times | count, columns, cellWidth, as
@@ -32,8 +32,8 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
32
32
  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)*
33
33
  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)*
34
34
  19. **Loops**: `repeat` (finite) / `yoyo` / `repeatDelay` on any keyframe; infinite repeats are rejected with a warning. *(fixed)*
35
- 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)*
36
- 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)*
35
+ 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)*
36
+ 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)*
37
37
  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)*
38
38
  23. **Generated images**: `canvas.toDataURL()` as an asset `src` works. Make them ≥ canvas size or `kenBurns` zooms look soft. *(docs)*
39
39
 
@@ -69,7 +69,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
69
69
  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)*
70
70
  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)*
71
71
  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)*
72
- 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`.
72
+ 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()`.)*
73
73
  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)*
74
74
  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.
75
75
  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)*
@@ -431,6 +431,82 @@ return sequences;
431
431
 
432
432
  ---
433
433
 
434
+ ## A wave of delays (`stagger`)
435
+
436
+ `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.
437
+
438
+ ```js
439
+ // @recipe stagger
440
+ const style = { fontFamily: 'Arial, sans-serif', fontSize: 96, fontWeight: 'bold', fill: '#ffffff' };
441
+ // letters rising from the middle outwards: the same layers, each starting a little later
442
+ const letters = stagger(
443
+ splitText('RIPPLE', style, { x: 640, y: 120, align: 'center' }).map(p => ({
444
+ type: 'text', name: 'letter-' + p.index, text: p.text, style, at: 0.2, duration: 4,
445
+ initial: { x: p.x, y: p.y, alpha: 0 },
446
+ keyframes: [{ at: 0, from: { y: p.y + 40, alpha: 0 }, to: { y: p.y, alpha: 1 }, duration: 0.4, ease: 'back.out(2)' }],
447
+ })),
448
+ { each: 0.07, from: 'center' });
449
+ // a 6 x 3 grid of tiles that pop in as a ripple from the middle (numbers: use them in `at`)
450
+ const COLS = 6, ROWS = 3, delays = stagger(COLS * ROWS, { each: 0.12, grid: [COLS, ROWS], from: 'center', ease: 'sine.out' });
451
+ const tiles = delays.map((d, i) => ({
452
+ type: 'shape', shape: 'rect', width: 120, height: 120, cornerRadius: 18, name: 'tile-' + i, at: 1 + d, duration: 3 - d,
453
+ initial: { x: 190 + (i % COLS) * 180, y: 330 + Math.floor(i / COLS) * 150, fillColor: '#4cc9f0', scale: 0 },
454
+ keyframes: [{ at: 0, to: { scale: 1 }, duration: 0.4, ease: 'back.out(2)' }],
455
+ }));
456
+ return [...letters, ...tiles];
457
+ ```
458
+
459
+ ---
460
+
461
+ ## Per-letter text in one call (`animateText`)
462
+
463
+ `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.
464
+
465
+ ```js
466
+ // @recipe animate-text
467
+ const style = { fontFamily: 'Arial, sans-serif', fontSize: 110, fontWeight: 'bold', fill: '#ffffff' };
468
+ return [
469
+ ...animateText('Kinetic type', style, { x: 640, y: 230, align: 'center', at: 0.3, duration: 5,
470
+ in: 'rise', out: 'fade', stagger: { each: 0.05, from: 'center' }, idle: { y: 5, rotation: 1 },
471
+ styleFor: p => (p.index === 0 ? { fill: '#ffd166' } : {}) }),
472
+ ...animateText('one word at a time', { fontFamily: 'Arial, sans-serif', fontSize: 44, fill: '#8fa0bf' },
473
+ { by: 'words', x: 640, y: 420, align: 'center', at: 1.6, duration: 3.7, in: 'pop', stagger: { each: 0.25 } }),
474
+ ];
475
+ ```
476
+
477
+ 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.
478
+
479
+ ---
480
+
481
+ ## Travel along a path, and morph one shape into another
482
+
483
+ `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.
484
+
485
+ ```js
486
+ // @recipe follow-path
487
+ const ROUTE = 'M 120 560 C 360 120 920 120 1160 560';
488
+ return [
489
+ { type: 'shape', shape: 'path', d: ROUTE, duration: 5, initial: { strokeColor: '#ffffff', strokeWidth: 3, strokeAlpha: 0.25 } }, // the route, faint
490
+ { type: 'shape', shape: 'polygon', points: [[-18, -12], [18, 0], [-18, 12]], at: 0.5, duration: 4.5, initial: { fillColor: '#ffd166' },
491
+ keyframes: followPath({ d: ROUTE, duration: 4, ease: 'power2.inOut', orient: true, frameRate: 30 }) }, // a pointer that faces the way it goes
492
+ ];
493
+ ```
494
+
495
+ ```js
496
+ // @recipe morph
497
+ 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';
498
+ 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';
499
+ return [{
500
+ type: 'shape', shape: 'path', d: BLOB, morphTo: STAR, duration: 4,
501
+ initial: { fillColor: '#ffd166', strokeColor: '#ffffff', strokeWidth: 6, strokeJoin: 'round' },
502
+ keyframes: [{ at: 0.5, to: { morph: 1 }, duration: 1.5, ease: 'power2.inOut', repeat: 1, yoyo: true }], // there and back
503
+ }];
504
+ ```
505
+
506
+ Both outlines are in canvas coordinates. Closed outlines are lined up so they do not twist.
507
+
508
+ ---
509
+
434
510
  ## Looping motion
435
511
 
436
512
  `repeat` (a finite count) and `yoyo` go on any keyframe. Total time = `duration × (repeat + 1)`.
@@ -446,20 +522,74 @@ return [{
446
522
 
447
523
  ---
448
524
 
449
- ## Particles (seeded, so every run and export is identical)
525
+ ## Particles (`particles()`: seeded, so every run and export is identical)
526
+
527
+ `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`.
450
528
 
451
529
  ```js
452
530
  // @recipe particles
453
- let seed = 7;
454
- const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
455
- return Array.from({ length: 40 }, () => {
456
- const x = rnd() * 1280, y = 100 + rnd() * 620;
457
- return {
458
- type: 'shape', shape: 'circle', radius: 2 + rnd() * 4, duration: 6,
459
- initial: { x, y, fillColor: '#ffffff', fillAlpha: 0.3 + rnd() * 0.5 },
460
- keyframes: [{ at: 0, to: { y: y - 80 }, duration: 6, ease: 'none' }],
461
- };
462
- });
531
+ const colors = ['#ffd166', '#ff6b9d', '#4cc9f0', '#ffffff'];
532
+ 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,
533
+ size: [4, 8], scale: [1, 0.3], colors, fade: { out: 0.7 }, blendMode: 'add', seed, name: 'burst' + seed }); // bursts of sparks
534
+ return [
535
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0a0f1f' } },
536
+ ...burst(0.5, 400, 260, 1), ...burst(1.6, 880, 200, 2), ...burst(2.8, 640, 330, 3),
537
+ ];
538
+ ```
539
+
540
+ ```js
541
+ // @recipe particles-snow
542
+ return { duration: 10, sequences: [
543
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#16203d' } },
544
+ ...particles({ count: 120, emit: 4, life: [4, 6], area: { x: 640, y: -20, width: 1400 }, angle: 90, speed: [40, 90],
545
+ size: [2, 5], sway: { amp: 30, freq: 0.6 }, fade: { in: 0.5, out: 0.5 }, name: 'snow', seed: 4 }), // drifting down, swaying
546
+ ] };
547
+ ```
548
+
549
+ ---
550
+
551
+ ## Group and orbit with a null layer (`parent`)
552
+
553
+ 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).
554
+
555
+ ```js
556
+ // @recipe null-orbit
557
+ return [
558
+ { type: 'shape', shape: 'circle', radius: 50, duration: 8, initial: { x: 640, y: 360, fillColor: '#ffd166' } }, // the sun
559
+ { type: 'null', name: 'earth-orbit', initial: { x: 640, y: 360 }, // pivot at the sun
560
+ keyframes: [{ at: 0, to: { rotation: 360 }, duration: 8, ease: 'none' }] },
561
+ { type: 'shape', shape: 'circle', radius: 20, parent: 'earth-orbit', initial: { x: 240, y: 0, fillColor: '#4cc9f0' } }, // 240 px from the sun
562
+ { type: 'null', name: 'moon-orbit', parent: 'earth-orbit', initial: { x: 240, y: 0 }, // pivot at the earth
563
+ keyframes: [{ at: 0, to: { rotation: 1440 }, duration: 8, ease: 'none' }] },
564
+ { type: 'shape', shape: 'circle', radius: 7, parent: 'moon-orbit', initial: { x: 55, y: 0, fillColor: '#e8eefc' } }, // 55 px from the earth
565
+ ];
566
+ ```
567
+
568
+ ---
569
+
570
+ ## A hand-held shake and a flickering light (`wiggle`, seeded)
571
+
572
+ `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`.
573
+
574
+ ```js
575
+ // @recipe wiggle
576
+ return [
577
+ // a title that floats and tilts a little, like a hand-held camera
578
+ { type: 'text', text: 'NIGHT SHIFT', duration: 6,
579
+ style: { fontSize: 120, fill: '#ffffff', fontWeight: '800' },
580
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
581
+ 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 } } }) },
582
+ // a neon sign whose light stutters (fast, straight-line steps)
583
+ { type: 'shape', shape: 'rect', width: 520, height: 8, duration: 6,
584
+ initial: { x: 'GW/2', y: 'GH/2 + 90', fillColor: '#ff4d6d' },
585
+ keyframes: wiggle({ duration: 6, freq: 12, seed: 5, ease: 'none', props: { alpha: { around: 0.8, amp: 0.2 } } }) },
586
+ // dust: every speck has its own seed, so its drift is its own and never changes between runs
587
+ ...Array.from({ length: 24 }, (_, i) => ({
588
+ type: 'shape', shape: 'circle', radius: 2 + rand(i) * 3, duration: 6,
589
+ initial: { x: 40 + rand(i + 100) * 1200, y: 40 + rand(i + 200) * 640, fillColor: '#ffffff', fillAlpha: 0.25 + rand(i + 300) * 0.4 },
590
+ keyframes: [...wiggle({ duration: 6, freq: 0.6, seed: i, props: { y: { around: 40 + rand(i + 200) * 640, amp: 30 } } })],
591
+ })),
592
+ ];
463
593
  ```
464
594
 
465
595
  ---
package/ai/template.html CHANGED
@@ -4,18 +4,21 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>pixi-effects video</title>
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.11.0/dist/loader.css" /> <!-- the loader: HTML + CSS only, so it is on screen at once and keeps moving while the page builds the video -->
7
8
  <style>
8
9
  body { background: #0d1220; margin: 0; padding: 16px; }
9
- canvas { width: min(960px, 100%); border-radius: 8px; }
10
- /* the player wraps the canvas: size the wrapper, so the bar is as wide as the picture */
11
- .movie-controller-wrap { width: min(960px, 100%); }
12
- .movie-controller-wrap canvas { width: 100%; height: auto; }
10
+ .stage { position: relative; width: min(960px, 100%); } /* the player and the loader both sit inside this box */
11
+ .stage canvas { display: block; width: 100%; height: auto; border-radius: 8px; }
12
+ /* the loader (pixi-effects/loader.css, linked above): movie.init() fades it out when the movie is ready */
13
13
  /* Screenshots: the player bar overlays the bottom ~60px of the canvas (it is NOT part of the exported video).
14
14
  Uncomment to hide it while you check the layout: .movie-controller { display: none !important; } */
15
15
  </style>
16
16
  </head>
17
17
  <body>
18
- <canvas id="stage" width="1280" height="720"></canvas>
18
+ <div class="stage">
19
+ <canvas id="stage" width="1280" height="720"></canvas>
20
+ <div class="pe-loader" data-label="LOADING"></div> <!-- shared loader: .pe-loader--pulse / --bar for other looks, --custom for your own -->
21
+ </div>
19
22
 
20
23
  <script>
21
24
  // Harness: every console warning/error is collected. pixi-effects warnings are INSTRUCTIONS — read them.
@@ -36,9 +39,9 @@
36
39
  "gsap": "https://esm.sh/gsap@3.12.5",
37
40
  "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
38
41
  "mediabunny": "https://esm.sh/mediabunny",
39
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.9.0/dist/index.js",
40
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.9.0/dist/Controller.js",
41
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.9.0/dist/three.js",
42
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.11.0/dist/index.js",
43
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.11.0/dist/Controller.js",
44
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.11.0/dist/three.js",
42
45
  "pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js",
43
46
  "three": "https://esm.sh/three@0.178.0"
44
47
  }
@@ -13,6 +13,8 @@
13
13
  *
14
14
  * Options: -o, --out FILE (default ./<page name>.<format>; the container follows the extension: mp4 webm mov mkv) ·
15
15
  * --format mp4|webm|mov|mkv · --quality very-low|low|medium|high|very-high (video and audio bitrate, default high) ·
16
+ * --motion-blur SAMPLES (2-64: each frame is drawn that many times over the shutter and averaged; overrides the page's own motionBlur) ·
17
+ * --shutter FRACTION (with --motion-blur: how long the shutter is open, 0-1 of a frame, default 0.5) ·
16
18
  * --video-codec C · --audio-codec C · --query "a=1&b=2" (added to the page URL) · --fail-on-warn (exit 1 when the page
17
19
  * logged a warning; the file is still written) · --quiet (no progress line) · --timeout SECONDS (default 900) ·
18
20
  * --root DIR (static server root; default: the nearest folder above the page with dist/) · --chrome PATH
@@ -39,7 +41,7 @@ export function defaultOutput(page, format) {
39
41
  }
40
42
 
41
43
  export function parseRenderArgs(argv) {
42
- const o = { page: null, out: null, format: null, quality: 'high', videoCodec: null, audioCodec: null, query: null, failOnWarn: false, quiet: false, timeout: 900, root: null, chrome: null };
44
+ const o = { page: null, out: null, format: null, quality: 'high', videoCodec: null, audioCodec: null, motionBlur: null, shutter: null, query: null, failOnWarn: false, quiet: false, timeout: 900, root: null, chrome: null };
43
45
  const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
44
46
  for (let i = 0; i < argv.length; i++) {
45
47
  const a = argv[i];
@@ -52,7 +54,13 @@ export function parseRenderArgs(argv) {
52
54
  if (!QUALITIES.includes(o.quality)) throw new Error(`--quality must be very-low, low, medium, high or very-high (got "${o.quality}")`);
53
55
  } else if (a === '--video-codec') o.videoCodec = need(i++, a);
54
56
  else if (a === '--audio-codec') o.audioCodec = need(i++, a);
55
- else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
57
+ else if (a === '--motion-blur') {
58
+ o.motionBlur = Number(need(i++, a));
59
+ if (!(Number.isInteger(o.motionBlur) && o.motionBlur >= 2 && o.motionBlur <= 64)) throw new Error(`--motion-blur must be a whole number of samples from 2 to 64 (got "${argv[i]}")`);
60
+ } else if (a === '--shutter') {
61
+ o.shutter = Number(need(i++, a));
62
+ if (!(o.shutter > 0 && o.shutter <= 1)) throw new Error(`--shutter must be above 0 and at most 1 (0.5 is a 180° shutter), got "${argv[i]}"`);
63
+ } else if (a === '--query') o.query = need(i++, a).replace(/^\?/, '');
56
64
  else if (a === '--fail-on-warn') o.failOnWarn = true;
57
65
  else if (a === '--quiet') o.quiet = true;
58
66
  else if (a === '--timeout') o.timeout = Math.max(10, Number(need(i++, a)) || 900);
@@ -62,6 +70,7 @@ export function parseRenderArgs(argv) {
62
70
  else if (!o.page) o.page = a;
63
71
  else throw new Error(`unexpected argument ${a}`);
64
72
  }
73
+ if (o.shutter !== null && o.motionBlur === null) throw new Error('--shutter goes with --motion-blur SAMPLES');
65
74
  o.format ??= (o.out ? formatFromPath(o.out) : null) ?? 'mp4';
66
75
  return o;
67
76
  }
@@ -70,14 +79,14 @@ const UPLOAD = '/__pixi_effects_out';
70
79
  const kb = n => (n >= 1048576 ? `${(n / 1048576).toFixed(2)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
71
80
 
72
81
  /** The page's side: render, and PUT the Blob to our server (so a long video never goes through base64). */
73
- const renderScript = (format, quality, videoCodec, audioCodec) => `(() => {
82
+ const renderScript = (format, quality, videoCodec, audioCodec, motionBlur, shutter) => `(() => {
74
83
  window.__r = { state: 'running', progress: 0 };
75
84
  movie.on('progress', e => { window.__r.progress = e.progress; });
76
85
  (async () => {
77
86
  try {
78
87
  const video = { bitrate: ${JSON.stringify(quality)}${videoCodec ? `, codec: ${JSON.stringify(videoCodec)}` : ''} };
79
88
  const audio = { bitrate: ${JSON.stringify(quality)}${audioCodec ? `, codec: ${JSON.stringify(audioCodec)}` : ''} };
80
- const blob = await movie.render({ format: ${JSON.stringify(format)}, video, audio });
89
+ const blob = await movie.render({ format: ${JSON.stringify(format)}, video, audio${motionBlur ? `, motionBlur: { samples: ${motionBlur}${shutter ? `, shutter: ${shutter}` : ''} }` : ''} });
81
90
  const res = await fetch(${JSON.stringify(UPLOAD)}, { method: 'PUT', body: blob });
82
91
  if (!res.ok) throw new Error('could not hand the file to the command (' + res.status + ')');
83
92
  window.__r = { state: 'done', bytes: blob.size, type: blob.type };
@@ -143,7 +152,7 @@ export async function runRender(opts, log = console.log, progress = () => {}) {
143
152
  }
144
153
  Object.assign(result, { duration: info.duration, frames: info.totalFrames, width: info.width, height: info.height, frameRate: info.frameRate });
145
154
 
146
- await cdp.eval(renderScript(opts.format, opts.quality, opts.videoCodec, opts.audioCodec));
155
+ await cdp.eval(renderScript(opts.format, opts.quality, opts.videoCodec, opts.audioCodec, opts.motionBlur, opts.shutter));
147
156
  let state;
148
157
  for (;;) {
149
158
  left();
@@ -1,5 +1,5 @@
1
1
  import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape, B as AudioDescriptor } from './types-oJijbn-K.cjs';
2
+ import { S as SequenceSpec, C as CompositionShape, B as AudioDescriptor } from './types-BXu28nJj.cjs';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -1,5 +1,5 @@
1
1
  import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape, B as AudioDescriptor } from './types-oJijbn-K.js';
2
+ import { S as SequenceSpec, C as CompositionShape, B as AudioDescriptor } from './types-BXu28nJj.js';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ var chunkF7NCHS6Q_cjs = require('./chunk-F7NCHS6Q.cjs');
4
+ require('./chunk-6NWJLVCA.cjs');
5
+ require('./chunk-UQBGW5DO.cjs');
6
+
7
+
8
+
9
+ Object.defineProperty(exports, "CompositionSequence", {
10
+ enumerable: true,
11
+ get: function () { return chunkF7NCHS6Q_cjs.CompositionSequence; }
12
+ });
13
+ //# sourceMappingURL=Composition-7TEKHZNQ.cjs.map
14
+ //# sourceMappingURL=Composition-7TEKHZNQ.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-CM62XJKT.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-7TEKHZNQ.cjs"}
@@ -0,0 +1,5 @@
1
+ export { CompositionSequence } from './chunk-3IPDNC3E.js';
2
+ import './chunk-C6QHKYC5.js';
3
+ import './chunk-DKGG7WD4.js';
4
+ //# sourceMappingURL=Composition-HIISJAX2.js.map
5
+ //# sourceMappingURL=Composition-HIISJAX2.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-RJDSQYTV.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-HIISJAX2.js"}