pixi-effects 0.9.0 → 0.10.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 (51) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +6 -6
  3. package/ai/SKILL.md +2 -1
  4. package/ai/reference/cheatsheet.md +13 -5
  5. package/ai/reference/pitfalls.md +2 -2
  6. package/ai/reference/recipes.md +123 -2
  7. package/ai/template.html +3 -3
  8. package/dist/{Base-CSIulyIq.d.cts → Base-CwUxTfja.d.cts} +1 -1
  9. package/dist/{Base-DVgwyIUj.d.ts → Base-CyvSLoCP.d.ts} +1 -1
  10. package/dist/Composition-EKVTUYCI.cjs +14 -0
  11. package/dist/{Composition-CM62XJKT.cjs.map → Composition-EKVTUYCI.cjs.map} +1 -1
  12. package/dist/Composition-NJCOMGD7.js +5 -0
  13. package/dist/{Composition-RJDSQYTV.js.map → Composition-NJCOMGD7.js.map} +1 -1
  14. package/dist/Controller.cjs +34 -0
  15. package/dist/Controller.cjs.map +1 -1
  16. package/dist/Controller.d.cts +12 -2
  17. package/dist/Controller.d.ts +12 -2
  18. package/dist/Controller.js +34 -0
  19. package/dist/Controller.js.map +1 -1
  20. package/dist/{Movie-QMlFyHkf.d.ts → Movie-puD9oZPg.d.ts} +1 -1
  21. package/dist/{Movie-qGHp15c_.d.cts → Movie-w3GliuP3.d.cts} +1 -1
  22. package/dist/{chunk-PDBDDGG5.cjs → chunk-4WJUSDJJ.cjs} +103 -30
  23. package/dist/chunk-4WJUSDJJ.cjs.map +1 -0
  24. package/dist/{chunk-FNKSXBKB.js → chunk-JAMNDBXW.js} +193 -14
  25. package/dist/chunk-JAMNDBXW.js.map +1 -0
  26. package/dist/{chunk-AGMNSJJH.cjs → chunk-S3ZI4MKS.cjs} +196 -12
  27. package/dist/chunk-S3ZI4MKS.cjs.map +1 -0
  28. package/dist/{chunk-O4AJYUPK.js → chunk-WDGAVOMS.js} +85 -12
  29. package/dist/chunk-WDGAVOMS.js.map +1 -0
  30. package/dist/index.cjs +309 -22
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +231 -5
  33. package/dist/index.d.ts +231 -5
  34. package/dist/index.js +282 -11
  35. package/dist/index.js.map +1 -1
  36. package/dist/three.cjs +5 -5
  37. package/dist/three.d.cts +2 -2
  38. package/dist/three.d.ts +2 -2
  39. package/dist/three.js +1 -1
  40. package/dist/{types-oJijbn-K.d.ts → types-BXu28nJj.d.cts} +26 -2
  41. package/dist/{types-oJijbn-K.d.cts → types-BXu28nJj.d.ts} +26 -2
  42. package/docs/api.md +1 -0
  43. package/docs/dsl.md +108 -2
  44. package/llms-full.txt +249 -12
  45. package/package.json +1 -1
  46. package/dist/Composition-CM62XJKT.cjs +0 -14
  47. package/dist/Composition-RJDSQYTV.js +0 -5
  48. package/dist/chunk-AGMNSJJH.cjs.map +0 -1
  49. package/dist/chunk-FNKSXBKB.js.map +0 -1
  50. package/dist/chunk-O4AJYUPK.js.map +0 -1
  51. package/dist/chunk-PDBDDGG5.cjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.0
4
+
5
+ **Added**
6
+
7
+ - **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.
8
+ - **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.
9
+ - **`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.
10
+ - 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.
11
+ - 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).
12
+ - **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.
13
+ - **`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.
14
+ - **`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.
15
+ - **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.
16
+ - **`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.
17
+
3
18
  ## 0.9.0
4
19
 
5
20
  **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.10.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `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/)** · 35 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.10.0/dist/index.js",
44
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.10.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.10.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.10.0/dist/three.js"
72
72
  }
73
73
  }
74
74
  </script>
package/ai/SKILL.md CHANGED
@@ -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) `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, recipes exist or loops are easy):** stroke draw-on (trim path), text that changes over time (other than `{value}`), other per-frame curves, particle emitters.
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,27 @@ 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, 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
+ const r = random(7); r(); r(); // a repeatable stream in 0…1 for JS loops (never Math.random())
116
124
  ```
117
125
  `kenBurns` covers the canvas, so source images should be at least canvas-sized (1920×1080 for 1280×720).
118
126
 
119
127
  ```js
120
- import { measureText, splitText } from 'pixi-effects'; // text layout helpers (measured with the renderer's own fonts)
128
+ import { measureText, splitText, animateText } from 'pixi-effects'; // text layout helpers (measured with the renderer's own fonts)
121
129
  measureText('Hello', style) // → { width, height, lines }
122
130
  splitText('Hello world', style, { by: 'chars' | 'words' | 'lines', x, y, align: 'left' | 'center' | 'right' })
123
131
  // → [{ text, x, y, width, index, line }] — one text layer per piece, same style, anchor top-left
124
132
  ```
125
- Per-letter / per-word animation = `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
133
+ 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
134
 
127
135
  ## Assets
128
136
 
@@ -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 particle emitter, 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
 
@@ -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)`.
@@ -450,8 +526,7 @@ return [{
450
526
 
451
527
  ```js
452
528
  // @recipe particles
453
- let seed = 7;
454
- const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
529
+ const rnd = random(7); // seeded: the same stream every run (never Math.random())
455
530
  return Array.from({ length: 40 }, () => {
456
531
  const x = rnd() * 1280, y = 100 + rnd() * 620;
457
532
  return {
@@ -464,6 +539,52 @@ return Array.from({ length: 40 }, () => {
464
539
 
465
540
  ---
466
541
 
542
+ ## Group and orbit with a null layer (`parent`)
543
+
544
+ 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).
545
+
546
+ ```js
547
+ // @recipe null-orbit
548
+ return [
549
+ { type: 'shape', shape: 'circle', radius: 50, duration: 8, initial: { x: 640, y: 360, fillColor: '#ffd166' } }, // the sun
550
+ { type: 'null', name: 'earth-orbit', initial: { x: 640, y: 360 }, // pivot at the sun
551
+ keyframes: [{ at: 0, to: { rotation: 360 }, duration: 8, ease: 'none' }] },
552
+ { type: 'shape', shape: 'circle', radius: 20, parent: 'earth-orbit', initial: { x: 240, y: 0, fillColor: '#4cc9f0' } }, // 240 px from the sun
553
+ { type: 'null', name: 'moon-orbit', parent: 'earth-orbit', initial: { x: 240, y: 0 }, // pivot at the earth
554
+ keyframes: [{ at: 0, to: { rotation: 1440 }, duration: 8, ease: 'none' }] },
555
+ { type: 'shape', shape: 'circle', radius: 7, parent: 'moon-orbit', initial: { x: 55, y: 0, fillColor: '#e8eefc' } }, // 55 px from the earth
556
+ ];
557
+ ```
558
+
559
+ ---
560
+
561
+ ## A hand-held shake and a flickering light (`wiggle`, seeded)
562
+
563
+ `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`.
564
+
565
+ ```js
566
+ // @recipe wiggle
567
+ return [
568
+ // a title that floats and tilts a little, like a hand-held camera
569
+ { type: 'text', text: 'NIGHT SHIFT', duration: 6,
570
+ style: { fontSize: 120, fill: '#ffffff', fontWeight: '800' },
571
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
572
+ 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 } } }) },
573
+ // a neon sign whose light stutters (fast, straight-line steps)
574
+ { type: 'shape', shape: 'rect', width: 520, height: 8, duration: 6,
575
+ initial: { x: 'GW/2', y: 'GH/2 + 90', fillColor: '#ff4d6d' },
576
+ keyframes: wiggle({ duration: 6, freq: 12, seed: 5, ease: 'none', props: { alpha: { around: 0.8, amp: 0.2 } } }) },
577
+ // dust: every speck has its own seed, so its drift is its own and never changes between runs
578
+ ...Array.from({ length: 24 }, (_, i) => ({
579
+ type: 'shape', shape: 'circle', radius: 2 + rand(i) * 3, duration: 6,
580
+ initial: { x: 40 + rand(i + 100) * 1200, y: 40 + rand(i + 200) * 640, fillColor: '#ffffff', fillAlpha: 0.25 + rand(i + 300) * 0.4 },
581
+ keyframes: [...wiggle({ duration: 6, freq: 0.6, seed: i, props: { y: { around: 40 + rand(i + 200) * 640, amp: 30 } } })],
582
+ })),
583
+ ];
584
+ ```
585
+
586
+ ---
587
+
467
588
  ## Gradients and vignettes
468
589
 
469
590
  `fillGradient` fills a shape with a linear or radial gradient (positions are 0–1 of the shape's own bounds; colours can have alpha). A radial gradient from transparent to dark, on a full-screen rect placed last, is a vignette.
package/ai/template.html CHANGED
@@ -36,9 +36,9 @@
36
36
  "gsap": "https://esm.sh/gsap@3.12.5",
37
37
  "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
38
38
  "mediabunny": "https://esm.sh/mediabunny",
39
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.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",
39
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.10.0/dist/index.js",
40
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.10.0/dist/Controller.js",
41
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.10.0/dist/three.js",
42
42
  "pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js",
43
43
  "three": "https://esm.sh/three@0.178.0"
44
44
  }
@@ -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 chunk4WJUSDJJ_cjs = require('./chunk-4WJUSDJJ.cjs');
4
+ require('./chunk-S3ZI4MKS.cjs');
5
+ require('./chunk-UQBGW5DO.cjs');
6
+
7
+
8
+
9
+ Object.defineProperty(exports, "CompositionSequence", {
10
+ enumerable: true,
11
+ get: function () { return chunk4WJUSDJJ_cjs.CompositionSequence; }
12
+ });
13
+ //# sourceMappingURL=Composition-EKVTUYCI.cjs.map
14
+ //# sourceMappingURL=Composition-EKVTUYCI.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-CM62XJKT.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-EKVTUYCI.cjs"}
@@ -0,0 +1,5 @@
1
+ export { CompositionSequence } from './chunk-WDGAVOMS.js';
2
+ import './chunk-JAMNDBXW.js';
3
+ import './chunk-DKGG7WD4.js';
4
+ //# sourceMappingURL=Composition-NJCOMGD7.js.map
5
+ //# sourceMappingURL=Composition-NJCOMGD7.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-RJDSQYTV.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-NJCOMGD7.js"}
@@ -334,6 +334,11 @@ var Controller = class _Controller {
334
334
  volumeContainerEl = null;
335
335
  fullscreenBtn = null;
336
336
  isVolumeScrubbing = false;
337
+ canvasClickHandler = null;
338
+ canvasPointerDownHandler = null;
339
+ canvasCursor = "";
340
+ /** Set when a pointerdown on the canvas closed the export popover: the click that follows must not play / pause. */
341
+ swallowCanvasClick = false;
337
342
  fullscreenChangeHandler = null;
338
343
  wheelHandler = null;
339
344
  /** Ends a drag the pointerup of which never arrives (see bindScrubbing). */
@@ -347,6 +352,7 @@ var Controller = class _Controller {
347
352
  canvas: options.canvas,
348
353
  showExportButton: options.showExportButton ?? true,
349
354
  enableKeyboardShortcuts: options.enableKeyboardShortcuts ?? true,
355
+ clickToPlay: options.clickToPlay ?? true,
350
356
  className: options.className ?? "movie-controller"
351
357
  };
352
358
  installStyles();
@@ -366,6 +372,7 @@ var Controller = class _Controller {
366
372
  this.root.setAttribute("data-state", "visible");
367
373
  this.bindMovieEvents();
368
374
  this.bindPlayButton();
375
+ if (this.options.clickToPlay) this.bindCanvasClick();
369
376
  this.bindScrubbing();
370
377
  this.bindMuteButton();
371
378
  this.bindVolumeSlider();
@@ -556,6 +563,25 @@ var Controller = class _Controller {
556
563
  }
557
564
  });
558
565
  }
566
+ /** Clicking the picture plays / pauses (a tap on a phone too). */
567
+ bindCanvasClick() {
568
+ const canvas = this.options.canvas;
569
+ this.canvasCursor = canvas.style.cursor;
570
+ canvas.style.cursor = "pointer";
571
+ this.canvasPointerDownHandler = () => {
572
+ this.swallowCanvasClick = false;
573
+ };
574
+ this.canvasClickHandler = () => {
575
+ if (this.swallowCanvasClick) {
576
+ this.swallowCanvasClick = false;
577
+ return;
578
+ }
579
+ if (this.movie.isPlaying) this.movie.pause();
580
+ else this.movie.play();
581
+ };
582
+ canvas.addEventListener("pointerdown", this.canvasPointerDownHandler);
583
+ canvas.addEventListener("click", this.canvasClickHandler);
584
+ }
559
585
  refreshPlayIcon() {
560
586
  this.playBtn.innerHTML = this.movie.isPlaying ? ICONS.pause : ICONS.play;
561
587
  this.playBtn.setAttribute("aria-label", this.movie.isPlaying ? "Pause" : "Play");
@@ -778,6 +804,7 @@ var Controller = class _Controller {
778
804
  if (!target) return;
779
805
  if (this.settingsPopoverEl?.contains(target)) return;
780
806
  if (this.exportBtn?.contains(target)) return;
807
+ if (this.options.canvas.contains(target)) this.swallowCanvasClick = true;
781
808
  this.toggleSettings(false);
782
809
  };
783
810
  document.addEventListener("pointerdown", this.settingsOutsideHandler);
@@ -1003,6 +1030,13 @@ var Controller = class _Controller {
1003
1030
  this.resizeObserver.disconnect();
1004
1031
  this.resizeObserver = null;
1005
1032
  }
1033
+ if (this.canvasClickHandler) {
1034
+ this.options.canvas.removeEventListener("click", this.canvasClickHandler);
1035
+ this.options.canvas.removeEventListener("pointerdown", this.canvasPointerDownHandler);
1036
+ this.options.canvas.style.cursor = this.canvasCursor;
1037
+ this.canvasClickHandler = null;
1038
+ this.canvasPointerDownHandler = null;
1039
+ }
1006
1040
  if (this.fitObserver) {
1007
1041
  this.fitObserver.disconnect();
1008
1042
  this.fitObserver = null;