pixi-effects 0.30.1 → 0.32.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 (82) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +6 -5
  3. package/ai/chat-template.html +3 -3
  4. package/ai/tools/check.mjs +42 -2
  5. package/ai/tools/render.mjs +34 -4
  6. package/ai/tools/varArgs.mjs +75 -0
  7. package/dist/{Base-Ne74ztNl.d.ts → Base-CXsMdmJB.d.ts} +1 -1
  8. package/dist/{Base-CB1_FPZD.d.cts → Base-pNq6sVGl.d.cts} +1 -1
  9. package/dist/Composition-LNMKWXSL.cjs +16 -0
  10. package/dist/{Composition-MUKR3FYD.cjs.map → Composition-LNMKWXSL.cjs.map} +1 -1
  11. package/dist/Composition-RF7QOEXT.js +7 -0
  12. package/dist/{Composition-RJGA7KDU.js.map → Composition-RF7QOEXT.js.map} +1 -1
  13. package/dist/Controller.cjs +42 -1
  14. package/dist/Controller.cjs.map +1 -1
  15. package/dist/Controller.d.cts +10 -2
  16. package/dist/Controller.d.ts +10 -2
  17. package/dist/Controller.js +42 -1
  18. package/dist/Controller.js.map +1 -1
  19. package/dist/{Movie-DdnpLpw1.d.cts → Movie-CL6SsAWe.d.cts} +90 -3
  20. package/dist/{Movie-C2rZFkrO.d.ts → Movie-f0tQG9y5.d.ts} +90 -3
  21. package/dist/Spans-NQGKVMR5.cjs +55 -0
  22. package/dist/Spans-NQGKVMR5.cjs.map +1 -0
  23. package/dist/Spans-SDXABOV6.js +53 -0
  24. package/dist/Spans-SDXABOV6.js.map +1 -0
  25. package/dist/{blendModes-RIVZXL5A.js → blendModes-MVRLG3T7.js} +4 -4
  26. package/dist/{blendModes-RIVZXL5A.js.map → blendModes-MVRLG3T7.js.map} +1 -1
  27. package/dist/{blendModes-ETMYWATF.cjs → blendModes-VTKDXWEN.cjs} +5 -5
  28. package/dist/{blendModes-ETMYWATF.cjs.map → blendModes-VTKDXWEN.cjs.map} +1 -1
  29. package/dist/{chunk-ZAXYPXKW.js → chunk-AW5NPDE4.js} +1367 -1150
  30. package/dist/chunk-AW5NPDE4.js.map +1 -0
  31. package/dist/{chunk-RCVZX2JP.js → chunk-C5BNHVKK.js} +36 -3
  32. package/dist/chunk-C5BNHVKK.js.map +1 -0
  33. package/dist/{chunk-WHNADXXG.cjs → chunk-CRUDOKSD.cjs} +91 -91
  34. package/dist/chunk-CRUDOKSD.cjs.map +1 -0
  35. package/dist/{chunk-A3Q3SDSH.js → chunk-D5BFQVKL.js} +6 -6
  36. package/dist/chunk-D5BFQVKL.js.map +1 -0
  37. package/dist/{chunk-LYOKH7KE.cjs → chunk-FCGYJCVO.cjs} +36 -3
  38. package/dist/chunk-FCGYJCVO.cjs.map +1 -0
  39. package/dist/{chunk-CRQFDY7E.js → chunk-FCMOREIS.js} +467 -58
  40. package/dist/chunk-FCMOREIS.js.map +1 -0
  41. package/dist/{chunk-HREBQAY4.cjs → chunk-K3MAEKFM.cjs} +493 -84
  42. package/dist/chunk-K3MAEKFM.cjs.map +1 -0
  43. package/dist/{chunk-4KB3GUEK.js → chunk-QFNS6BFL.js} +4 -4
  44. package/dist/{chunk-4KB3GUEK.js.map → chunk-QFNS6BFL.js.map} +1 -1
  45. package/dist/{chunk-ZVUO325O.cjs → chunk-QHDN6TF5.cjs} +1382 -1160
  46. package/dist/chunk-QHDN6TF5.cjs.map +1 -0
  47. package/dist/{chunk-2DXPTKQZ.cjs → chunk-Z437UWI4.cjs} +5 -5
  48. package/dist/{chunk-2DXPTKQZ.cjs.map → chunk-Z437UWI4.cjs.map} +1 -1
  49. package/dist/index.cjs +33 -33
  50. package/dist/index.d.cts +5 -5
  51. package/dist/index.d.ts +5 -5
  52. package/dist/index.js +5 -5
  53. package/dist/loader.css +1 -1
  54. package/dist/present.cjs +5 -5
  55. package/dist/present.d.cts +2 -2
  56. package/dist/present.d.ts +2 -2
  57. package/dist/present.js +5 -5
  58. package/dist/three.cjs +6 -6
  59. package/dist/three.d.cts +2 -2
  60. package/dist/three.d.ts +2 -2
  61. package/dist/three.js +2 -2
  62. package/dist/{types-BzIB65Vk.d.ts → types-CavnUvd5.d.cts} +28 -1
  63. package/dist/{types-BzIB65Vk.d.cts → types-CavnUvd5.d.ts} +28 -1
  64. package/docs/api.md +28 -1
  65. package/docs/dsl.md +46 -5
  66. package/llms-full.txt +136 -10
  67. package/package.json +83 -20
  68. package/skills/pixi-effects/SKILL.md +1 -1
  69. package/skills/pixi-effects/reference/cheatsheet.md +5 -2
  70. package/skills/pixi-effects/reference/pitfalls.md +5 -1
  71. package/skills/pixi-effects/reference/recipes.md +52 -0
  72. package/skills/pixi-effects/template.html +6 -6
  73. package/dist/Composition-MUKR3FYD.cjs +0 -16
  74. package/dist/Composition-RJGA7KDU.js +0 -7
  75. package/dist/chunk-A3Q3SDSH.js.map +0 -1
  76. package/dist/chunk-CRQFDY7E.js.map +0 -1
  77. package/dist/chunk-HREBQAY4.cjs.map +0 -1
  78. package/dist/chunk-LYOKH7KE.cjs.map +0 -1
  79. package/dist/chunk-RCVZX2JP.js.map +0 -1
  80. package/dist/chunk-WHNADXXG.cjs.map +0 -1
  81. package/dist/chunk-ZAXYPXKW.js.map +0 -1
  82. package/dist/chunk-ZVUO325O.cjs.map +0 -1
package/docs/api.md CHANGED
@@ -42,6 +42,8 @@ interface MovieOptions {
42
42
  canvas?: HTMLCanvasElement; // existing canvas to render into; otherwise PixiJS creates one
43
43
  assets?: AssetSpec[]; // [{ name, src }]
44
44
  composition?: CompositionSpec; // root composition (see DSL reference)
45
+ vars?: Record<string, number | string>; // named values the composition reads: a number as a name in an expression, any as {name} in a string (see Variables)
46
+ loop?: boolean; // start with movie.loop on: playing goes back to the start at the end (default false, like a <video>)
45
47
  poster?: number; // seconds: the frame shown before play (negative: from the end), see movie.poster
46
48
  motionBlur?: boolean | number | { samples?: number; shutter?: number }; // blur for render() / snapshot() / contactSheet(), not live playback; see Motion blur
47
49
  loader?: HTMLElement | string | false; // the page's loader (see pixi-effects/loader.css): shows how far init is (a bar, "BUILDING LAYERS 62%"), faded out when ready, stopped if init fails; default: a .pe-loader next to the canvas
@@ -78,6 +80,25 @@ Play, and pause exactly at `seconds` (the frame `round(seconds × frameRate)`).
78
80
 
79
81
  Look around and put the movie back: pauses, runs `fn` (seek, take pictures with `snapshot()`; the seeks made inside are not reported as `seeking` / `seeked`), then puts the playhead (or the poster) back where it was, whatever `fn` did, and returns what `fn` returned. `stopImages()` and `inspectStops()` (`pixi-effects/present`) are made with it.
80
82
 
83
+ ### Movie variables and rebuilding: `vars`, `setVars()`, `resize()`, `rebuild()`
84
+
85
+ Variables are declared in `init({ vars: { title: 'NORD', shift: 0 } })` and read by the composition (see [Variables](dsl.md#variables)). Changing one builds the composition again: the new one is built off the stage first and swapped in when it is whole, at the same frame; a failure leaves the old one in place.
86
+
87
+ ```ts
88
+ movie.vars // the current values (a copy)
89
+ await movie.setVars({ shift: 40 }); // the declared names only (others: ignored with a did-you-mean); the same values: nothing happens
90
+ await movie.resize(1080, 1920); // the movie's size; W / H / GW / GH follow. Beats a width / height the composition names
91
+ await movie.rebuild({ composition, vars, width, height }); // the general form: any of the four; `composition` replaces the whole spec (your layout function, called again)
92
+ // all three resolve to a report, and fire 'rebuilt' with it:
93
+ interface RebuildReport { reason: 'vars' | 'resize' | 'rebuild'; ms: number; audio: 'reused' | 'mixed' | 'none'; frame: number; playing: boolean; unchanged?: true }
94
+ ```
95
+
96
+ - The new composition is built while the old one goes on; at the swap the movie keeps doing what it is doing at that moment: the same frame, playing or paused (a `pause()`, a seek or a `play()` you made during the build is kept), the poster if it was shown. A playing movie whose sound is unchanged (`audio: 'reused'`) is not stopped at all: the frame clock and the sound go on and only the picture is swapped, so rebuilds in a row (a pointer) do not hold the playhead back. When the sound changed, the movie stops and plays on from the same frame with the new sound. A `playUntil()` in flight ends. The canvas changes size at the swap, not before. A `render()` or `peek()` in flight is not interrupted: the swap waits for it.
97
+ - The sound is mixed only when it changed: `audio: 'reused'` means the same mix as before (the mix is the slow part, 0.6 to 0.8 s for a film; the rest takes about 20 ms). A time remap (`warp` / `sourceMap`) turns the cache off.
98
+ - Calls that arrive during a rebuild are joined: at most one rebuild runs and one waits, with the latest values. Safe to call from `pointermove` or `scroll`: a rebuild that leaves the sound alone takes tens of milliseconds (11 to 42 ms on four gallery pieces), one that mixes the sound again 0.6 to 0.8 s, so a slider that changes the sound will lag behind the hand.
99
+ - A rebuild that fails (a bad expression, a missing asset, a size that is not pixels) rejects, emits `error` with `where: 'rebuild'`, and the movie goes on with what it had. A `destroy()` during a rebuild ends it the same way.
100
+ - To follow a window: observe a CSS-sized box around the canvas, not the canvas itself (the movie sets the canvas's pixel size): `new ResizeObserver(() => movie.resize(Math.round(box.clientWidth * devicePixelRatio), Math.round(box.clientHeight * devicePixelRatio))).observe(box)` (the `liquid-layout` recipe).
101
+
81
102
  ### `movie.snapshot(frame?, options?): Promise<Blob | string>`
82
103
 
83
104
  ```ts
@@ -261,7 +282,9 @@ movie.off(event, fn): this
261
282
  | `'seeking'` / `'seeked'` | `{ frame, totalFrames }` (the frame it lands on) | a `gotoFrame()` to another frame starts / has finished. NOT emitted for playback ticks, `render()`, `snapshot()` or `contactSheet()` |
262
283
  | `'ended'` | none | playback ran off the end (after `'pause'`); pausing by hand does not emit it |
263
284
  | `'volumechange'` | `{ volume: number; muted: boolean }` | `volume` or `muted` really changed (clamping counts) |
264
- | `'error'` | `{ where: 'init' \| 'render' \| 'playback'; message: string; error: unknown }` | `init()` / `render()` failed (the call still rejects), or a frame failed during playback |
285
+ | `'loopchange'` | `{ loop: boolean }` | `movie.loop` really changed (the first value from `init({ loop })` is not a change) |
286
+ | `'error'` | `{ where: 'init' \| 'render' \| 'playback' \| 'rebuild'; message: string; error: unknown }` | `init()` / `render()` / a rebuild failed (the call still rejects), or a frame failed during playback |
287
+ | `'rebuilt'` | `RebuildReport` (see above) | a `setVars()` / `resize()` / `rebuild()` built the composition again (not for a call that changed nothing) |
265
288
 
266
289
  The names are the ones an HTML5 `<video>` uses, so a player written for `<video>` ports over.
267
290
 
@@ -280,8 +303,10 @@ The names are the ones an HTML5 `<video>` uses, so a player written for `<video>
280
303
  | `background` | string | CSS color |
281
304
  | `volume` | number | 0..1 getter/setter; immediate. Setter clamps and applies to active audio |
282
305
  | `muted` | boolean | getter/setter; immediate |
306
+ | `loop` | boolean | getter/setter, default `false`. When true, an ordinary `play()` that reaches the end goes back to the start and plays on: no `ended`, no `pause` / `play` events at the wrap, the sound starts again (a short gap). `playUntil()` and `render()` ignore it. Say `loopchange` when it changes |
283
307
  | `app` | `pixi.js Application \| null` | PIXI Application instance (advanced/escape hatch) |
284
308
  | `timeline` | `Timeline \| null` | the movie's timeline (advanced): `time(t)`, `duration()`, `segments()` |
309
+ | `vars` | `Record<string, number \| string>` | the current variable values (a copy) |
285
310
  | `stops` | `Stop[]` | the composition's stops: named times (see Presentations); empty for an ordinary movie |
286
311
 
287
312
  ### Presentations: `stops` and `pixi-effects/present`
@@ -455,6 +480,7 @@ interface ControllerOptions {
455
480
  exportButton?: boolean; // default true; false hides ⬇ + popover
456
481
  keyboard?: boolean; // default true
457
482
  presentButton?: boolean; // default true: a movie with stops gets a Present button (marks on the seek bar are always shown)
483
+ loopButton?: boolean; // default true: a loop button next to the volume (and the L key) that flips movie.loop
458
484
  pauseAtStops?: boolean; // default true: with stops, play / Space / click on the picture play to the next stop and pause; false plays straight through
459
485
  clickToPlay?: boolean; // default true: a click / tap on the picture plays / pauses (like <video>); false leaves the canvas alone
460
486
  className?: string; // default 'movie-controller'
@@ -523,6 +549,7 @@ Active when `keyboard: true` and the key target is not `<input>`/`<textarea>`/`<
523
549
  | `←` / `→` | step ±1 frame |
524
550
  | `↑` / `↓` | volume ±5% (clears mute when increasing past zero) |
525
551
  | `M` | toggle mute |
552
+ | `L` | toggle loop (`movie.loop`) |
526
553
  | `Shift+E` | export with current settings (skips the popover) |
527
554
  | `F` | toggle fullscreen |
528
555
  | `Esc` | close the export popover or exit fullscreen (browser) |
package/docs/dsl.md CHANGED
@@ -59,7 +59,7 @@ interface SequenceCommon {
59
59
  | Layer | Where `x, y` land by default | To change it |
60
60
  |---|---|---|
61
61
  | `shape` rect / circle / ellipse | the **centre** of the shape | `anchorX` / `anchorY` (0–1). A bar growing from its base: `anchorY: 1`; left to right: `anchorX: 0` |
62
- | `shape` line / polygon / path | the centre of the shape's bounds | `pivotX` / `pivotY` (in the shape's own canvas coordinates): the point that sits at `x, y` and that rotation / scale turn about. **`anchorX` / `anchorY` do nothing on these.** |
62
+ | `shape` line / polygon / path | the centre of the shape's bounds | `pivotX` / `pivotY` (in the shape's own canvas coordinates): the point that sits at `x, y` and that rotation / scale turn about. **`anchorX` / `anchorY` do nothing on these.** A `path` whose `d` is already written in canvas coordinates takes no `x` / `y` at all (they would move its centre to that point); to move it, change the coordinates or wrap it in a composition. |
63
63
  | `text`, `image`, `video` | the **top-left** corner | `anchorX` / `anchorY: 0.5` to centre |
64
64
  | `composition` | its top-left corner | `pivotX` / `pivotY` = the point that sits at `x, y` and that rotation / scale turn about |
65
65
 
@@ -71,7 +71,7 @@ A layer is **not removed** when its lifespan `[at, at + duration)` ends: it is o
71
71
 
72
72
  ### `text`
73
73
 
74
- Renders a [PIXI.Text](https://pixijs.com/8.x/guides/components/scene-objects/text/text). Animatable position, rotation, scale, opacity and `fill`. `style` accepts any PixiJS `TextStyle` field and is applied once at build time — notably `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding` (numbers may be expressions). A `dropShadow` glow is clipped unless `padding` is at least twice its `blur`. Text content can change over time (see *Changing text* below): a `{value}` placeholder prints an animatable number (counters), a keyframe `set: { text: '…' }` swaps the string, and `visibleChars` types it out. Multi-line text is centre-aligned unless `style.align` says otherwise. In expressions `w` / `h` are the size of the styled text, so `x: '-w'` places it just off the left edge.
74
+ Renders a [PIXI.Text](https://pixijs.com/8.x/guides/components/scene-objects/text/text). Animatable position, rotation, scale, opacity and `fill`. `style` accepts any PixiJS `TextStyle` field and is applied once at build time (an outline `stroke: { color, width }` is **round-joined** unless you give `join`: PixiJS's own default, `miter`, grows spikes on the sharp corners of W, V, A, k; the stroke is centred on the glyph edge, so half its width covers the fill: keep `width` to about a tenth of `fontSize`, or put a second, stroked copy of the text under the plain one for a thick outline) — notably `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding` (numbers may be expressions). A `dropShadow` glow is clipped unless `padding` is at least twice its `blur`. Text content can change over time (see *Changing text* below): a `{value}` placeholder prints an animatable number (counters), a keyframe `set: { text: '…' }` swaps the string, and `visibleChars` types it out. Multi-line text is centre-aligned unless `style.align` says otherwise. In expressions `w` / `h` are the size of the styled text, so `x: '-w'` places it just off the left edge.
75
75
 
76
76
  ```ts
77
77
  {
@@ -129,6 +129,16 @@ A text layer has one animatable number, `value`, printed wherever the text conta
129
129
 
130
130
  `format`: `decimals` (default 0), thousands `grouping` (default false) and `pad` (zero-pad the whole part to that many digits: `pad: 2` gives `05`, so a clock is `text: '18:42:{value}'` with `format: { pad: 2 }`). Put prefixes and suffixes in the text (`'${value}'`, `'{value}%'`). If `value` is animated but the text has no `{value}`, a warning says so.
131
131
 
132
+ **Colour inside one text layer: `spans`.** Instead of `text`, write an array of strings (drawn in the layer's own `style`) and `{ text, fill, … }` objects; they are shown one after the other, and `\n` inside a span starts a new line:
133
+
134
+ ```ts
135
+ { type: 'text', name: 'code', style: { fontFamily: 'Menlo', fontSize: 30, fill: '#e8f5e8', align: 'left' },
136
+ spans: [{ text: 'type', fill: '#7fa07f' }, ': ', { text: "'text'", fill: '#ffd166', fontWeight: '700' }, ',\n at: 0.5'],
137
+ initial: { x: 120, y: 200 } }
138
+ ```
139
+
140
+ A span may change `fill`, `fontWeight`, `fontStyle`, `fontVariant`, `letterSpacing` and `dropShadow`; a size, a font, `lineHeight`, `align` or a `stroke` belongs to the layer's `style` (one outline per layer: a stroke shifts a piece by half its width; a span that sets one of these is warned about once and the key is ignored; `style.wordWrap` and `style.leading` do not apply either: the lines are the `\n` of the spans). `style.align` places the lines on the width of the block (`'left'`, `'center'` — the default, as for every text — or `'right'`). At build time the layer becomes a group (a composition) of ordinary text layers, one per piece of a line, named `<name>.0`, `<name>.1` …, each at the x it has when the pieces are measured one after the other (the same measuring as `splitText`), so `inspect`, the timeline view and `check` see them. The group carries the layer's `name`, `at`, `duration`, `initial` (`anchorX` / `anchorY` are numbers, 0.5 centres the whole block, and cannot be keyframed), `keyframes` (`x`, `y`, `alpha`, `scale`, `rotation`, …), `filters`, `mask` and `blendMode`. A **typewriter** works over all the spans: `initial: { visibleChars: 0 }` and a keyframe `to: { visibleChars: N }` count every character in order, the newline included. `fill`, `text`, `value`, `fillGradient`, `format` and `colorSpace` do not apply to a group (a warning says so): use a separate text layer, or put the colour in a span. Measuring needs the font loaded (`await document.fonts.load('30px "Name"')` for a web font), as for `splitText`; the size and spacing in `style` may be numbers, expressions (`'GH * 0.05'`) or declared variables: they are resolved before the pieces are measured, and a missing `fontSize` is measured as the text layer's own default (Arial, 36).
141
+
132
142
  ### `image`
133
143
 
134
144
  Renders a [PIXI.Sprite](https://pixijs.com/8.x/guides/components/scene-objects/sprite/sprite) from a registered asset. Intrinsic `w`/`h` come from the loaded texture.
@@ -1084,6 +1094,8 @@ No comparison, conditional, bitwise, or string operators — keep it numeric (`s
1084
1094
 
1085
1095
  ### Scope variables
1086
1096
 
1097
+ (Your own names, set from outside, are [Variables](#variables) below.)
1098
+
1087
1099
  Each sequence has its own scope, computed at build time:
1088
1100
 
1089
1101
  | Name | Meaning |
@@ -1119,6 +1131,35 @@ style: { fontSize: 'min(GW, GH) * 0.06' }
1119
1131
  initial: { x: 'GW/2', y: 'GH * 0.96', anchorX: 0.5, anchorY: 1 }
1120
1132
  ```
1121
1133
 
1134
+ ### Variables
1135
+
1136
+ A **variable** is a name with a value you can change after the movie is built: the language of a title, a brand colour, an offset, the screen size. Declare them once in `movie.init`; the composition reads them by name.
1137
+
1138
+ ```ts
1139
+ await movie.init({
1140
+ vars: { title: 'NORD', accent: '#e5399a', shift: 0 }, // a number or a string; no booleans (write 0 / 1)
1141
+ composition: { sequences: [
1142
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '{accent}' } },
1143
+ { type: 'text', text: '{title}', initial: { x: 'W/2 + shift', y: 'H/2' } },
1144
+ ] },
1145
+ });
1146
+ await movie.setVars({ title: 'SÜD', shift: 40 }); // builds the composition again, at the same frame
1147
+ ```
1148
+
1149
+ | Where | How it reads a variable |
1150
+ | --- | --- |
1151
+ | a numeric expression (`x`, `scale`, `width`, `fontSize` … any value that takes an [expression](#expressions)) | the **name**, like `W` or `GW`: `'W/2 + shift'`. Only number variables. |
1152
+ | any string anywhere in the composition (`text`, a colour, `asset`, a layer `name`, nested layers) | **`{name}`**: `'{title}'`, `'#{hex}'`, `'{lang}-logo'`. The text is replaced before the layer is built. A `{name}` that was not declared stays as written (`{value}` is the [counter](#counters-value) of a text layer, so it is also a name no variable can take). `$` is not used: it is a price (`'$5'`). |
1153
+
1154
+ Rules, all checked when the movie is built (each warning says what to change):
1155
+ - A name is letters, digits and `_`, not starting with a digit. These are **reserved** and are refused: `w h W H GW GH t d T contain cover value`, every expression function (`min max abs … step rand noise lerp clamp smoothstep mod`) and `PI`.
1156
+ - A variable that nothing reads (no `{name}` in a string, no use in an expression) is said: it is usually a typo on one side. A `{name}` that was not declared says the declared name it looks like.
1157
+ - Values are read **when the composition is built**, once (not every frame). `at` and `duration` are plain numbers, so a variable moves values, not timing; to change the structure in time, call `movie.rebuild({ composition })` with a composition made by your own function.
1158
+ - `setVars` of a name that was not declared is ignored with a did-you-mean; `setVars` of the values it already has does nothing.
1159
+ - Everything is built again, the playhead stays at the same frame, a playing movie keeps playing, and the sound is mixed again **only if** the sound changed. See [`movie.rebuild`](api.md#movie-variables-and-rebuilding-vars-setvars-resize-rebuild).
1160
+ - A video file is a function of the script **and** the variables: `pixi-effects render page.html --var title=SÜD --var shift=40`, `check --size 1920x1080,1080x1920` (a contact sheet per size).
1161
+ - Layout that should follow the screen size uses `W` / `H` / `GW` / `GH`, not pixels: after `movie.resize(w, h)` they are the new size.
1162
+
1122
1163
  ---
1123
1164
 
1124
1165
  ## Stops (presentations)
@@ -1234,7 +1275,7 @@ A scaled punch-in / punch-out. By default `B` opens up: it starts large and zoom
1234
1275
 
1235
1276
  ### `dissolve`
1236
1277
 
1237
- Pixel-grain noise reveal driven by deterministic 2D Perlin noise. Pixels with a low noise value reveal first; as `uProgress` advances, more pixels reveal. The same `seed` always produces the same dissolve pattern, so a render is bit-exact reproducible.
1278
+ A noise reveal driven by deterministic 2D Perlin noise. Pixels with a low noise value reveal first; as `uProgress` advances, more pixels reveal. At the default `scale` of 30 it reads as large blotches (about 40 px each), not as grain: `scale: 200` or more gives a fine grain. The same `seed` always produces the same dissolve pattern, so a render is bit-exact reproducible.
1238
1279
 
1239
1280
  ```ts
1240
1281
  {
@@ -1344,7 +1385,7 @@ Gotchas, all measured:
1344
1385
  - **Distortion:** `warp` (`wave`: a flag or the surface of water, `haze`: heat shimmer; moves with the frame's time, no keyframes needed, and pads the layer by `strength` so a shifted edge is not cut), `twist` (a swirl), `bulgePinch` (a lens), `shockwave` (a ring that spreads), `rgbSplit` (colour fringes), `glitch` (slices). `warp`'s time is the time of the frame (inside a time-remapped composition, that composition's clock), `speed: 0` stands still, `strength` is in px and is capped at 200. It bends the layer's picture; to draw something that does not exist yet, use a `shader` layer.
1345
1386
  - **Centres and offsets are in canvas pixels, not the layer's:** `twist.offset`, `bulgePinch.center`, `zoomBlur.center`, `radialBlur.center`, `shockwave.center` default to the canvas's top-left corner, so a layer in the middle looks unaffected. Put the centre on the layer: `{ type: 'twist', offset: { x: 640, y: 360 } }` for a layer centred on a 1280×720 canvas.
1346
1387
  - **A filter works inside the layer's bounds.** Glow, dropShadow, outline and blur draw outside it and are clipped unless you widen the area (`filterArea: { x: -24, y: -24, width: w + 48, height: h + 48 }`, in the layer's own coordinates). Do not widen it for `grayscale`, `oldFilm` or other filters that paint the whole area: they fill the extra margin with black.
1347
- - **Animate scalars by name** (`'filters.halo.outerStrength'`). Options that are points (`rgbSplit.red`, `dropShadow.offset`) do not tween as a whole; animate `'filters.<name>.red.x'` if the filter exposes it, or swap the filter. `pixelate` animates through `sizeX` / `sizeY`, not `size`.
1388
+ - **Animate scalars by name** (`'filters.halo.outerStrength'`). Options that are points (`rgbSplit.red`, `dropShadow.offset`) are animated one leaf at a time: `'filters.<name>.red.x'` / `'filters.<name>.red.y'` tween like any number (the whole point `red: { x, y }` as a keyframe value switches at its keyframe and does not tween; a path that leads nowhere is warned about once and ignored). `pixelate` animates through `sizeX` / `sizeY`, not `size`.
1348
1389
  - `shockwave` and `godray` are driven by `time` (a still frame at `time: 0` looks like nothing): animate it. `radialBlur`, `shockwave` and `tiltShift` positions are canvas pixels too.
1349
1390
  - Some filters take their value as one argument; the DSL hides that (`{ type: 'pixelate', size: 8 }`, `{ type: 'emboss', strength: 6 }`).
1350
1391
 
@@ -1375,7 +1416,7 @@ import { GlowFilter, OldFilmFilter } from 'pixi-filters';
1375
1416
  Notes:
1376
1417
 
1377
1418
  - `filter` must be a `Filter` instance (constructor must have run on the consumer side).
1378
- - Animation paths use scalar property setters. Filters whose properties are PointData (e.g. `pixi-filters` `RGBSplitFilter` exposes `red: { x, y }`) currently don't propagate the change to the GPU uniform when only `.x` is mutated — replace the whole point in an `onUpdate` callback or use a scalar-API filter instead.
1419
+ - A dotted key (`'filters.s.red.x'`) is a path into the filter: the leaf of the point is written in place, which `pixi-filters` reads when it draws (checked on `RGBSplitFilter` in a real browser, `tests/tools/pointOptions.test.ts`). Before 0.32 such a key was written as a property literally named `red.x` and nothing moved.
1379
1420
  - Without a `name`, the filter still applies but cannot be addressed via `filters.<name>.<prop>` keyframe paths.
1380
1421
 
1381
1422
  Notes:
package/llms-full.txt CHANGED
@@ -6,7 +6,7 @@ Concatenation of the skill, cheatsheet, pitfalls, recipes and the DSL / API refe
6
6
 
7
7
  <!-- ===== skills/pixi-effects/SKILL.md ===== -->
8
8
 
9
- > Written for pixi-effects 0.30.1. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
9
+ > Written for pixi-effects 0.32.0. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
10
10
 
11
11
  # pixi-effects — write a video as data
12
12
 
@@ -138,6 +138,7 @@ Operators `+ - * /`, parentheses, unary `-`. Functions `min max abs floor ceil r
138
138
  | `w`, `h` | this layer's own size (image/video natural size, text size **after** its style is applied, shape bounds) |
139
139
  | `cover`, `contain` | scale that makes the layer cover / fit the parent (`scale: 'cover'`) |
140
140
  | `t`, `d`, `T` | layer start time, layer duration, parent duration |
141
+ | your own names | `movie.init({ vars: { shift: 0, title: 'NORD' } })`: a number is a name in an expression (`'W/2 + shift'`), any value is `{title}` inside a string (`text: '{title}'`, `fillColor: '{accent}'`). Reserved: the names above, `value`, the functions, `PI`. `await movie.setVars({ shift: 40 })` builds again at the same frame; `await movie.resize(w, h)` makes `W` / `H` follow a window. `render --var title=SÜD --size 1080x1920`, `check --size 1920x1080,1080x1920` |
141
142
 
142
143
  ## Common fields (every layer)
143
144
 
@@ -158,7 +159,7 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
158
159
 
159
160
  ## Layer types
160
161
 
161
- **text** — `text`, `style` (any PixiJS TextStyle field: `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding`; expressions OK for numbers), `colorSpace`. Default anchor is **top-left**; use `anchorX/anchorY: 0.5` to centre on `x,y`. A text layer has one animatable number, `value`, printed where the text contains `{value}` (`text: '{value} users'`, `initial: { value: 0 }`, keyframe `to: { value: 2480 }`; `format: { decimals, grouping, pad /* zero-pad the whole part: pad 2 → 05 */ }`) — that is how counters work. **Changing text:** a keyframe `set: { text: 'Two' }` swaps the string at that time (undone on seek back; `to` / `from` cannot tween a string); `visibleChars` (number, first N characters; starts at 0 once animated) is a typewriter: `to: { visibleChars: 30 }, duration: 2.4, ease: 'none'` — use `anchorX: 0` + `style.align: 'left'` so it grows rightward. Multi-line text is centre-aligned by default (`style.align: 'left'` to change).
162
+ **text** — `text`, `style` (any PixiJS TextStyle field: `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding`; expressions OK for numbers), `colorSpace`. Default anchor is **top-left**; use `anchorX/anchorY: 0.5` to centre on `x,y`. A text layer has one animatable number, `value`, printed where the text contains `{value}` (`text: '{value} users'`, `initial: { value: 0 }`, keyframe `to: { value: 2480 }`; `format: { decimals, grouping, pad /* zero-pad the whole part: pad 2 → 05 */ }`) — that is how counters work. **Changing text:** a keyframe `set: { text: 'Two' }` swaps the string at that time (undone on seek back; `to` / `from` cannot tween a string); `visibleChars` (number, first N characters; starts at 0 once animated) is a typewriter: `to: { visibleChars: 30 }, duration: 2.4, ease: 'none'` — use `anchorX: 0` + `style.align: 'left'` so it grows rightward. Multi-line text is centre-aligned by default (`style.align: 'left'` to change). **Colour inside one line:** `spans: [{ text: 'type', fill: '#7fa07f' }, ': ', { text: "'a'", fill: '#ffd166', fontWeight: '700' }, ',\n next line']` instead of `text` (strings use the layer's `style`; a span changes only fill / fontWeight / fontStyle / fontVariant / letterSpacing / dropShadow, never a size, font or stroke (those are the layer's `style`); `\n` starts a line; `style.align: 'left'` for code; `visibleChars` types over all the spans; it becomes a group of text layers `<name>.0…`).
162
163
 
163
164
  **image** — `asset`, `tint`, `colorSpace: 'rgb'|'oklab'|'oklch'`. Default anchor top-left; natural size = `w`,`h`.
164
165
 
@@ -263,7 +264,7 @@ measureText('Hello', style) // → { width, height,
263
264
  splitText('Hello world', style, { by: 'chars' | 'words' | 'lines', x, y, align: 'left' | 'center' | 'right' })
264
265
  // → [{ text, x, y, width, index, line }] — one text layer per piece, same style, anchor top-left
265
266
  ```
266
- 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, in the same wave; by default BACKWARDS FROM THE LAYER'S END (at + duration, so to be gone at time T set duration = T − at); `out: { preset: 'fade', at: 6 }` starts the exit at composition second 6 instead>, 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`. **`x` / `y` are pixel NUMBERS (an expression like `'GW/2'` throws: compute `W / 2`), and `y` is the TOP of the line box**: the visible text is centred `lineHeight / 2` below it, so an underline goes at `y + lineHeight`, not at `y` (recipe `animate-text`). By hand: `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
267
+ 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, in the same wave (leave `out` out and the pieces stay until the layer ends); by default BACKWARDS FROM THE LAYER'S END (at + duration, so to be gone at time T set duration = T − at); `out: { preset: 'fade', at: 6 }` starts the exit at composition second 6 instead>, 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`. **`x` / `y` are pixel NUMBERS (an expression like `'GW/2'` throws: compute `W / 2`), and `y` is the TOP of the line box**: the visible text is centred `lineHeight / 2` below it, so an underline goes at `y + lineHeight`, not at `y` (recipe `animate-text`). By hand: `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
267
268
 
268
269
  ## Assets
269
270
 
@@ -278,6 +279,8 @@ movie.play(); movie.pause();
278
279
  await movie.gotoFrame(n, true); // seek (frames; 30 fps => t = n/30)
279
280
  const blob = await movie.render({ format: 'mp4' }); // 'mp4' | 'webm' | 'mov' | 'mkv'; ~real-time. `format: 'wav'` / `'ogg'` writes only the movie's SOUND (a `music` score as an audio file). `video: { codec: 'hevc' | 'av1' | 'vp9' | 'avc', bitrate: '8M', hardware, keyFrameInterval }` (a codec this browser cannot encode names the one that works). Part of it: `range: [2, 5]` (seconds) or `range: 'title'` (a top-level layer's span; picture and sound); `scale: 0.5` / `draft: true` (half size, low quality, no motion blur: a smaller file, not a faster drawing). 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
280
281
  movie.on('progress', e => e.progress /* 0–100 */);
282
+ await movie.setVars({ shift: 40 }); // declared variables → build again, same frame, sound mixed again only if it changed. `resize(w, h)`, `rebuild({ composition, vars, width, height })`; `movie.on('rebuilt', report => …)`; `movie.vars`
283
+ movie.loop = true // (or init({ loop: true })) playing goes back to the start at the end instead of stopping; the Controller bar has a loop button (L). playUntil() and render() ignore it
281
284
  movie.audioBuffer // mixed audio (after init), if any audio layers
282
285
  await movie.contactSheet({ count: 6, as: 'dataURL' }) // ONE image of several labelled frames — look at it. Options: frames | times | count, columns, cellWidth, as
283
286
  await movie.snapshot(60, { as: 'dataURL' }) // one frame, canvas only (no player bar) — use it for detail; sheet tiles are small
@@ -418,7 +421,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
418
421
  93. **A `threeD` layer's shadow budget is four casters per receiver, counted in layer order, not geometry.** Five letters as five `castsShadows` layers over one floor: the fifth casts nothing (the warning names it). Make the word ONE text layer (the light is per pixel, each letter is still lit on its own) or give only the four that matter `castsShadows`. Splitting the floor does not help: each half takes the first four.
419
422
  94. **`resolution` below 1 softens crisp lines in a shader.** `fwidth` anti-aliased edges come out a little blurry after the upscale: keep `resolution: 1` for line art and use 0.5 only for soft fields (flowing colour, glow).
420
423
  95. **A `color-dodge` leak over a dark picture is a muddy red disc.** Dodge divides each channel of near-black separately, red first. Cut the dodge to the bright subject with a matte (`mask: 'letters'`, a matte-only copy of the word) and put the warm haze in a separate `screen` radial.
421
- 96. **A filter option that is a point (`rgbSplit.red`, `glitch` offsets) cannot be tweened as a whole**, but a scalar can: `set: { 'filters.tear.seed': n }` steps `glitch.seed` (a `set` keyframe works on filter options; no warning, visible tearing). To switch between looks on one subject, put the subject in time-windowed `composition` copies, each carrying the filters of its look.
424
+ 96. **A filter option that is a point (`rgbSplit.red`, `glitch` offsets) is tweened one leaf at a time** (`'filters.s.red.x'`; the whole point as a value switches, it does not tween), and a scalar too: `set: { 'filters.tear.seed': n }` steps `glitch.seed` (a `set` keyframe works on filter options; no warning, visible tearing). To switch between looks on one subject, put the subject in time-windowed `composition` copies, each carrying the filters of its look.
422
425
  97. **A deck inside your own composition loses its `transitions` and `stops`** unless you take them across: `const d = deck({ width: 1920, height: 1080, pages })`, then `{ type: 'composition', sequences: d.composition.sequences, transitions: d.composition.transitions }`. Pass `width` / `height` yourself for a page that is not the movie's own size.
423
426
  98. **A matte layer needs a fill.** A named matte (`mask: 'glass'`) that is a shape with no `fillColor` / `fillGradient` (or a text with no fill) cuts everything away and nothing warns: the layers it should show simply vanish. Give the matte a fill (white for `alpha`, a gradient for `luma`).
424
427
  99. **In a `music` score a capital letter is a chord and a lowercase one a note.** `G7` is G dominant seventh, `C5` a power chord, `C6` a sixth chord; `g7`, `c5`, `c6` are notes (octave 7 is a very high note: it used to be what `G7` meant, silently). A bare note in capitals (`Bb2`) is refused with the lowercase spelling (`bb2`); inside brackets (`[C4 E4 G4]`) any case is a note. An unknown chord kind suggests the nearest (`Cmin7` → `Cm7`, `Csus` → `Csus4`, `CM7` → `Cmaj7`).
@@ -426,6 +429,10 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
426
429
  101. **A film built from one adjective looks like every other one.** Read `reference/direction.md` and write `brief.md` / `style-guide.md` / `shotlist.md` first: real assets with paths, the one sentence to remember, a row per beat with entry and exit state. If a real asset is missing, ask: a drawn fake (a logo, a dashboard, a figure) is the failure that matters. Judge the contact sheet cold: three defects, each with a timestamp, evidence and a local fix.
427
430
  102. **A vertical cut is a second composition, not a crop, and only when someone asked for it.** One source, a layout function, `?f=9x16`; `check` and `render` take `--query f=9x16` (recipe `two-formats`). Look at a contact sheet of each format: type that fits wide is tiny tall. Ask which formats are wanted; make one if the answer is open. In a 1080p film keep every line of text at 34 px or more; if you cannot read it in a sheet tile, a phone cannot.
428
431
  103. **Load a web font BEFORE you measure with it.** `measureText` / `splitText` / `animateText` measure once, when the composition is built. Measured in Chrome: "Respect the Local, Loop the Value." at 80 px Lato 900 is 1487 px wide before the Google font has arrived (the fallback) and 1231 px after; Japanese text in a similar fallback was within 3 px. Every box, line break and centred position built from the wrong width stays wrong. `await document.fonts.load('900 80px "Lato"', theTextYouUse)` first (the text argument loads exactly the glyphs you need), then build the composition.
432
+ 104. **A variable moves values, not timing, and it is read when the composition is built.** `at` and `duration` are plain numbers; `vars: { shift }` works in `x: 'W/2 + shift'` and `'{title}'` inside any string, not in `at: 'shift'`. `setVars` builds everything again (about 20 ms, plus the sound only if it changed): for a continuous input call it as often as you like, calls that overlap are joined, but do not use it for something that should move every frame (that is a keyframe).
433
+ 105. **Layout that follows the screen is written in `W` / `H` / `GW` / `GH`, never in pixels.** After `movie.resize(w, h)` an expression sees the new size; a literal `x: 960` stays at 960. A string variable cannot be read in an expression (`x: 'title'` is a number context): use `{title}` in a string, or declare the number. A name that is declared but never read, and a `{name}` that was not declared, are both said at `init` (usually a typo). Load a web font BEFORE the first `setVars` that changes a text (see 103): the text is measured again each rebuild.
434
+ 106. **A text outline used to spike; it is round-joined now.** `style: { stroke: { color: '#000', width: 12 } }` was drawn with PixiJS's default `miter` join, so the sharp corners of W, V, A, k and 4 grew spikes ("the outline sticks out"). The library now adds `join: 'round'` to an outline that names no join; say `join: 'miter'` or `'bevel'` if you want one. The stroke is centred on the glyph edge, so half of it eats into the letter: keep `width` to about a tenth of `fontSize`, or for a thick outline put a second copy of the text with the stroke UNDER the plain one.
435
+ 107. **`spans` places pieces by measuring, so the font must be loaded first, and a span cannot change size.** A web font that is not loaded yet is measured as the fallback font and the pieces drift apart: `await document.fonts.load('30px "Name"')` before `movie.init`. `fontSize` / `fontFamily` / `lineHeight` / `align` on a span are ignored (warned): they belong to the layer's `style`. `fill`, `text` and `value` keyframes do not reach a spans layer (it is a group of text layers).
429
436
 
430
437
 
431
438
  <!-- ===== skills/pixi-effects/reference/recipes.md ===== -->
@@ -1607,6 +1614,57 @@ while IFS=, read -r name score; do
1607
1614
  done < table.csv
1608
1615
  ```
1609
1616
 
1617
+ ---
1618
+
1619
+ ## One film, several languages and colours (variables)
1620
+
1621
+ Declare what changes as `vars` and write `{name}` in the strings and the names as identifiers in the expressions. One page then makes every variant: `pixi-effects render film.html --var lang=ja --var accent=#3949ab -o film-ja.mp4`, and a page can switch live with `movie.setVars(...)`. Load the fonts of every language before the first build (pitfall 103); `check` says a variable nothing reads, and a `{name}` nobody declared.
1622
+
1623
+ ```js
1624
+ // @docs-only variants
1625
+ const COPY = { en: { head: 'Spring sale', sub: 'Up to 40% off' }, ja: { head: '春のセール', sub: '最大40%オフ' } };
1626
+ const lang = new URLSearchParams(location.search).get('lang') ?? 'en';
1627
+ await movie.init({
1628
+ canvas, width: 1920, height: 1080, duration: 5,
1629
+ vars: { head: COPY[lang].head, sub: COPY[lang].sub, accent: '#e5399a', lift: 0 }, // numbers can be read in expressions, any value in {name}
1630
+ composition: { sequences: [
1631
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#12151f' } },
1632
+ { type: 'text', text: '{head}', at: 0.3, duration: 4.7, style: { fontSize: 120, fontWeight: 'bold', fill: '{accent}' },
1633
+ initial: { x: 'GW/2', y: 'GH/2 - 40 - lift', anchorX: 0.5, anchorY: 0.5 }, keyframes: [{ at: 0, from: { alpha: 0 }, to: { alpha: 1 }, duration: 0.6 }] },
1634
+ { type: 'text', text: '{sub}', at: 0.9, duration: 4.1, style: { fontSize: 56, fill: '#ffffff' },
1635
+ initial: { x: 'GW/2', y: 'GH/2 + 80', anchorX: 0.5, anchorY: 0.5 }, keyframes: [{ at: 0, from: { alpha: 0 }, to: { alpha: 1 }, duration: 0.6 }] },
1636
+ ] },
1637
+ });
1638
+ // later, from a button: await movie.setVars({ head: COPY.ja.head, sub: COPY.ja.sub, accent: '#3949ab' }); // same frame, sound untouched
1639
+ ```
1640
+
1641
+ ---
1642
+
1643
+ ## A layout that follows the window (liquid)
1644
+
1645
+ `resize(w, h)` builds the composition again at that size, and every expression sees it as `W` / `H` / `GW` / `GH`: write the layout in those, never in pixels. For a different arrangement per shape (wide / tall) write the composition in a function of the size and hand it to `rebuild`. Watch a **box** that you size with CSS (`#box { width: 100vw; height: 100vh }`, the canvas inside it `width: 100%; height: 100%`), not the canvas: the movie sets the canvas's pixel size, and an observer on the canvas would see its own change and grow it again. `check --size 1920x1080,1080x1920` makes a contact sheet and a layout review for each size without a browser window.
1646
+
1647
+ ```js
1648
+ // @docs-only liquid-layout
1649
+ const layout = (w, h) => {
1650
+ const tall = h > w;
1651
+ return { sequences: [
1652
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#12151f' } },
1653
+ { type: 'text', text: 'Headline', style: { fontSize: tall ? 'GW * 0.12' : 'GH * 0.14', fontWeight: 'bold', fill: '#ffffff' },
1654
+ initial: { x: tall ? 'GW * 0.08' : 'GW/2', y: tall ? 'GH * 0.7' : 'GH/2', anchorX: tall ? 0 : 0.5, anchorY: 0.5 } },
1655
+ ] };
1656
+ };
1657
+ // <div id="box" style="width:100vw;height:100vh"><canvas id="stage" style="width:100%;height:100%;display:block"></canvas></div>
1658
+ const box = document.getElementById('box');
1659
+ const pixels = () => [Math.round(box.clientWidth * devicePixelRatio), Math.round(box.clientHeight * devicePixelRatio)];
1660
+ const [w0, h0] = pixels();
1661
+ await movie.init({ canvas, width: w0, height: h0, duration: 6, composition: layout(w0, h0) });
1662
+ new ResizeObserver(() => {
1663
+ const [w, h] = pixels();
1664
+ movie.rebuild({ composition: layout(w, h), width: w, height: h }).catch(console.error); // overlapping calls are joined: only the latest size is built
1665
+ }).observe(box);
1666
+ ```
1667
+
1610
1668
 
1611
1669
  <!-- ===== skills/pixi-effects/reference/direction.md ===== -->
1612
1670
 
@@ -1813,7 +1871,7 @@ interface SequenceCommon {
1813
1871
  | Layer | Where `x, y` land by default | To change it |
1814
1872
  |---|---|---|
1815
1873
  | `shape` rect / circle / ellipse | the **centre** of the shape | `anchorX` / `anchorY` (0–1). A bar growing from its base: `anchorY: 1`; left to right: `anchorX: 0` |
1816
- | `shape` line / polygon / path | the centre of the shape's bounds | `pivotX` / `pivotY` (in the shape's own canvas coordinates): the point that sits at `x, y` and that rotation / scale turn about. **`anchorX` / `anchorY` do nothing on these.** |
1874
+ | `shape` line / polygon / path | the centre of the shape's bounds | `pivotX` / `pivotY` (in the shape's own canvas coordinates): the point that sits at `x, y` and that rotation / scale turn about. **`anchorX` / `anchorY` do nothing on these.** A `path` whose `d` is already written in canvas coordinates takes no `x` / `y` at all (they would move its centre to that point); to move it, change the coordinates or wrap it in a composition. |
1817
1875
  | `text`, `image`, `video` | the **top-left** corner | `anchorX` / `anchorY: 0.5` to centre |
1818
1876
  | `composition` | its top-left corner | `pivotX` / `pivotY` = the point that sits at `x, y` and that rotation / scale turn about |
1819
1877
 
@@ -1825,7 +1883,7 @@ A layer is **not removed** when its lifespan `[at, at + duration)` ends: it is o
1825
1883
 
1826
1884
  ### `text`
1827
1885
 
1828
- Renders a [PIXI.Text](https://pixijs.com/8.x/guides/components/scene-objects/text/text). Animatable position, rotation, scale, opacity and `fill`. `style` accepts any PixiJS `TextStyle` field and is applied once at build time — notably `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding` (numbers may be expressions). A `dropShadow` glow is clipped unless `padding` is at least twice its `blur`. Text content can change over time (see *Changing text* below): a `{value}` placeholder prints an animatable number (counters), a keyframe `set: { text: '…' }` swaps the string, and `visibleChars` types it out. Multi-line text is centre-aligned unless `style.align` says otherwise. In expressions `w` / `h` are the size of the styled text, so `x: '-w'` places it just off the left edge.
1886
+ Renders a [PIXI.Text](https://pixijs.com/8.x/guides/components/scene-objects/text/text). Animatable position, rotation, scale, opacity and `fill`. `style` accepts any PixiJS `TextStyle` field and is applied once at build time (an outline `stroke: { color, width }` is **round-joined** unless you give `join`: PixiJS's own default, `miter`, grows spikes on the sharp corners of W, V, A, k; the stroke is centred on the glyph edge, so half its width covers the fill: keep `width` to about a tenth of `fontSize`, or put a second, stroked copy of the text under the plain one for a thick outline) — notably `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding` (numbers may be expressions). A `dropShadow` glow is clipped unless `padding` is at least twice its `blur`. Text content can change over time (see *Changing text* below): a `{value}` placeholder prints an animatable number (counters), a keyframe `set: { text: '…' }` swaps the string, and `visibleChars` types it out. Multi-line text is centre-aligned unless `style.align` says otherwise. In expressions `w` / `h` are the size of the styled text, so `x: '-w'` places it just off the left edge.
1829
1887
 
1830
1888
  ```ts
1831
1889
  {
@@ -1883,6 +1941,16 @@ A text layer has one animatable number, `value`, printed wherever the text conta
1883
1941
 
1884
1942
  `format`: `decimals` (default 0), thousands `grouping` (default false) and `pad` (zero-pad the whole part to that many digits: `pad: 2` gives `05`, so a clock is `text: '18:42:{value}'` with `format: { pad: 2 }`). Put prefixes and suffixes in the text (`'${value}'`, `'{value}%'`). If `value` is animated but the text has no `{value}`, a warning says so.
1885
1943
 
1944
+ **Colour inside one text layer: `spans`.** Instead of `text`, write an array of strings (drawn in the layer's own `style`) and `{ text, fill, … }` objects; they are shown one after the other, and `\n` inside a span starts a new line:
1945
+
1946
+ ```ts
1947
+ { type: 'text', name: 'code', style: { fontFamily: 'Menlo', fontSize: 30, fill: '#e8f5e8', align: 'left' },
1948
+ spans: [{ text: 'type', fill: '#7fa07f' }, ': ', { text: "'text'", fill: '#ffd166', fontWeight: '700' }, ',\n at: 0.5'],
1949
+ initial: { x: 120, y: 200 } }
1950
+ ```
1951
+
1952
+ A span may change `fill`, `fontWeight`, `fontStyle`, `fontVariant`, `letterSpacing` and `dropShadow`; a size, a font, `lineHeight`, `align` or a `stroke` belongs to the layer's `style` (one outline per layer: a stroke shifts a piece by half its width; a span that sets one of these is warned about once and the key is ignored; `style.wordWrap` and `style.leading` do not apply either: the lines are the `\n` of the spans). `style.align` places the lines on the width of the block (`'left'`, `'center'` — the default, as for every text — or `'right'`). At build time the layer becomes a group (a composition) of ordinary text layers, one per piece of a line, named `<name>.0`, `<name>.1` …, each at the x it has when the pieces are measured one after the other (the same measuring as `splitText`), so `inspect`, the timeline view and `check` see them. The group carries the layer's `name`, `at`, `duration`, `initial` (`anchorX` / `anchorY` are numbers, 0.5 centres the whole block, and cannot be keyframed), `keyframes` (`x`, `y`, `alpha`, `scale`, `rotation`, …), `filters`, `mask` and `blendMode`. A **typewriter** works over all the spans: `initial: { visibleChars: 0 }` and a keyframe `to: { visibleChars: N }` count every character in order, the newline included. `fill`, `text`, `value`, `fillGradient`, `format` and `colorSpace` do not apply to a group (a warning says so): use a separate text layer, or put the colour in a span. Measuring needs the font loaded (`await document.fonts.load('30px "Name"')` for a web font), as for `splitText`; the size and spacing in `style` may be numbers, expressions (`'GH * 0.05'`) or declared variables: they are resolved before the pieces are measured, and a missing `fontSize` is measured as the text layer's own default (Arial, 36).
1953
+
1886
1954
  ### `image`
1887
1955
 
1888
1956
  Renders a [PIXI.Sprite](https://pixijs.com/8.x/guides/components/scene-objects/sprite/sprite) from a registered asset. Intrinsic `w`/`h` come from the loaded texture.
@@ -2838,6 +2906,8 @@ No comparison, conditional, bitwise, or string operators — keep it numeric (`s
2838
2906
 
2839
2907
  ### Scope variables
2840
2908
 
2909
+ (Your own names, set from outside, are [Variables](#variables) below.)
2910
+
2841
2911
  Each sequence has its own scope, computed at build time:
2842
2912
 
2843
2913
  | Name | Meaning |
@@ -2873,6 +2943,35 @@ style: { fontSize: 'min(GW, GH) * 0.06' }
2873
2943
  initial: { x: 'GW/2', y: 'GH * 0.96', anchorX: 0.5, anchorY: 1 }
2874
2944
  ```
2875
2945
 
2946
+ ### Variables
2947
+
2948
+ A **variable** is a name with a value you can change after the movie is built: the language of a title, a brand colour, an offset, the screen size. Declare them once in `movie.init`; the composition reads them by name.
2949
+
2950
+ ```ts
2951
+ await movie.init({
2952
+ vars: { title: 'NORD', accent: '#e5399a', shift: 0 }, // a number or a string; no booleans (write 0 / 1)
2953
+ composition: { sequences: [
2954
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '{accent}' } },
2955
+ { type: 'text', text: '{title}', initial: { x: 'W/2 + shift', y: 'H/2' } },
2956
+ ] },
2957
+ });
2958
+ await movie.setVars({ title: 'SÜD', shift: 40 }); // builds the composition again, at the same frame
2959
+ ```
2960
+
2961
+ | Where | How it reads a variable |
2962
+ | --- | --- |
2963
+ | a numeric expression (`x`, `scale`, `width`, `fontSize` … any value that takes an [expression](#expressions)) | the **name**, like `W` or `GW`: `'W/2 + shift'`. Only number variables. |
2964
+ | any string anywhere in the composition (`text`, a colour, `asset`, a layer `name`, nested layers) | **`{name}`**: `'{title}'`, `'#{hex}'`, `'{lang}-logo'`. The text is replaced before the layer is built. A `{name}` that was not declared stays as written (`{value}` is the [counter](#counters-value) of a text layer, so it is also a name no variable can take). `$` is not used: it is a price (`'$5'`). |
2965
+
2966
+ Rules, all checked when the movie is built (each warning says what to change):
2967
+ - A name is letters, digits and `_`, not starting with a digit. These are **reserved** and are refused: `w h W H GW GH t d T contain cover value`, every expression function (`min max abs … step rand noise lerp clamp smoothstep mod`) and `PI`.
2968
+ - A variable that nothing reads (no `{name}` in a string, no use in an expression) is said: it is usually a typo on one side. A `{name}` that was not declared says the declared name it looks like.
2969
+ - Values are read **when the composition is built**, once (not every frame). `at` and `duration` are plain numbers, so a variable moves values, not timing; to change the structure in time, call `movie.rebuild({ composition })` with a composition made by your own function.
2970
+ - `setVars` of a name that was not declared is ignored with a did-you-mean; `setVars` of the values it already has does nothing.
2971
+ - Everything is built again, the playhead stays at the same frame, a playing movie keeps playing, and the sound is mixed again **only if** the sound changed. See [`movie.rebuild`](api.md#movie-variables-and-rebuilding-vars-setvars-resize-rebuild).
2972
+ - A video file is a function of the script **and** the variables: `pixi-effects render page.html --var title=SÜD --var shift=40`, `check --size 1920x1080,1080x1920` (a contact sheet per size).
2973
+ - Layout that should follow the screen size uses `W` / `H` / `GW` / `GH`, not pixels: after `movie.resize(w, h)` they are the new size.
2974
+
2876
2975
  ---
2877
2976
 
2878
2977
  ## Stops (presentations)
@@ -2988,7 +3087,7 @@ A scaled punch-in / punch-out. By default `B` opens up: it starts large and zoom
2988
3087
 
2989
3088
  ### `dissolve`
2990
3089
 
2991
- Pixel-grain noise reveal driven by deterministic 2D Perlin noise. Pixels with a low noise value reveal first; as `uProgress` advances, more pixels reveal. The same `seed` always produces the same dissolve pattern, so a render is bit-exact reproducible.
3090
+ A noise reveal driven by deterministic 2D Perlin noise. Pixels with a low noise value reveal first; as `uProgress` advances, more pixels reveal. At the default `scale` of 30 it reads as large blotches (about 40 px each), not as grain: `scale: 200` or more gives a fine grain. The same `seed` always produces the same dissolve pattern, so a render is bit-exact reproducible.
2992
3091
 
2993
3092
  ```ts
2994
3093
  {
@@ -3098,7 +3197,7 @@ Gotchas, all measured:
3098
3197
  - **Distortion:** `warp` (`wave`: a flag or the surface of water, `haze`: heat shimmer; moves with the frame's time, no keyframes needed, and pads the layer by `strength` so a shifted edge is not cut), `twist` (a swirl), `bulgePinch` (a lens), `shockwave` (a ring that spreads), `rgbSplit` (colour fringes), `glitch` (slices). `warp`'s time is the time of the frame (inside a time-remapped composition, that composition's clock), `speed: 0` stands still, `strength` is in px and is capped at 200. It bends the layer's picture; to draw something that does not exist yet, use a `shader` layer.
3099
3198
  - **Centres and offsets are in canvas pixels, not the layer's:** `twist.offset`, `bulgePinch.center`, `zoomBlur.center`, `radialBlur.center`, `shockwave.center` default to the canvas's top-left corner, so a layer in the middle looks unaffected. Put the centre on the layer: `{ type: 'twist', offset: { x: 640, y: 360 } }` for a layer centred on a 1280×720 canvas.
3100
3199
  - **A filter works inside the layer's bounds.** Glow, dropShadow, outline and blur draw outside it and are clipped unless you widen the area (`filterArea: { x: -24, y: -24, width: w + 48, height: h + 48 }`, in the layer's own coordinates). Do not widen it for `grayscale`, `oldFilm` or other filters that paint the whole area: they fill the extra margin with black.
3101
- - **Animate scalars by name** (`'filters.halo.outerStrength'`). Options that are points (`rgbSplit.red`, `dropShadow.offset`) do not tween as a whole; animate `'filters.<name>.red.x'` if the filter exposes it, or swap the filter. `pixelate` animates through `sizeX` / `sizeY`, not `size`.
3200
+ - **Animate scalars by name** (`'filters.halo.outerStrength'`). Options that are points (`rgbSplit.red`, `dropShadow.offset`) are animated one leaf at a time: `'filters.<name>.red.x'` / `'filters.<name>.red.y'` tween like any number (the whole point `red: { x, y }` as a keyframe value switches at its keyframe and does not tween; a path that leads nowhere is warned about once and ignored). `pixelate` animates through `sizeX` / `sizeY`, not `size`.
3102
3201
  - `shockwave` and `godray` are driven by `time` (a still frame at `time: 0` looks like nothing): animate it. `radialBlur`, `shockwave` and `tiltShift` positions are canvas pixels too.
3103
3202
  - Some filters take their value as one argument; the DSL hides that (`{ type: 'pixelate', size: 8 }`, `{ type: 'emboss', strength: 6 }`).
3104
3203
 
@@ -3129,7 +3228,7 @@ import { GlowFilter, OldFilmFilter } from 'pixi-filters';
3129
3228
  Notes:
3130
3229
 
3131
3230
  - `filter` must be a `Filter` instance (constructor must have run on the consumer side).
3132
- - Animation paths use scalar property setters. Filters whose properties are PointData (e.g. `pixi-filters` `RGBSplitFilter` exposes `red: { x, y }`) currently don't propagate the change to the GPU uniform when only `.x` is mutated — replace the whole point in an `onUpdate` callback or use a scalar-API filter instead.
3231
+ - A dotted key (`'filters.s.red.x'`) is a path into the filter: the leaf of the point is written in place, which `pixi-filters` reads when it draws (checked on `RGBSplitFilter` in a real browser, `tests/tools/pointOptions.test.ts`). Before 0.32 such a key was written as a property literally named `red.x` and nothing moved.
3133
3232
  - Without a `name`, the filter still applies but cannot be addressed via `filters.<name>.<prop>` keyframe paths.
3134
3233
 
3135
3234
  Notes:
@@ -3473,6 +3572,8 @@ interface MovieOptions {
3473
3572
  canvas?: HTMLCanvasElement; // existing canvas to render into; otherwise PixiJS creates one
3474
3573
  assets?: AssetSpec[]; // [{ name, src }]
3475
3574
  composition?: CompositionSpec; // root composition (see DSL reference)
3575
+ vars?: Record<string, number | string>; // named values the composition reads: a number as a name in an expression, any as {name} in a string (see Variables)
3576
+ loop?: boolean; // start with movie.loop on: playing goes back to the start at the end (default false, like a <video>)
3476
3577
  poster?: number; // seconds: the frame shown before play (negative: from the end), see movie.poster
3477
3578
  motionBlur?: boolean | number | { samples?: number; shutter?: number }; // blur for render() / snapshot() / contactSheet(), not live playback; see Motion blur
3478
3579
  loader?: HTMLElement | string | false; // the page's loader (see pixi-effects/loader.css): shows how far init is (a bar, "BUILDING LAYERS 62%"), faded out when ready, stopped if init fails; default: a .pe-loader next to the canvas
@@ -3509,6 +3610,25 @@ Play, and pause exactly at `seconds` (the frame `round(seconds × frameRate)`).
3509
3610
 
3510
3611
  Look around and put the movie back: pauses, runs `fn` (seek, take pictures with `snapshot()`; the seeks made inside are not reported as `seeking` / `seeked`), then puts the playhead (or the poster) back where it was, whatever `fn` did, and returns what `fn` returned. `stopImages()` and `inspectStops()` (`pixi-effects/present`) are made with it.
3511
3612
 
3613
+ ### Movie variables and rebuilding: `vars`, `setVars()`, `resize()`, `rebuild()`
3614
+
3615
+ Variables are declared in `init({ vars: { title: 'NORD', shift: 0 } })` and read by the composition (see [Variables](dsl.md#variables)). Changing one builds the composition again: the new one is built off the stage first and swapped in when it is whole, at the same frame; a failure leaves the old one in place.
3616
+
3617
+ ```ts
3618
+ movie.vars // the current values (a copy)
3619
+ await movie.setVars({ shift: 40 }); // the declared names only (others: ignored with a did-you-mean); the same values: nothing happens
3620
+ await movie.resize(1080, 1920); // the movie's size; W / H / GW / GH follow. Beats a width / height the composition names
3621
+ await movie.rebuild({ composition, vars, width, height }); // the general form: any of the four; `composition` replaces the whole spec (your layout function, called again)
3622
+ // all three resolve to a report, and fire 'rebuilt' with it:
3623
+ interface RebuildReport { reason: 'vars' | 'resize' | 'rebuild'; ms: number; audio: 'reused' | 'mixed' | 'none'; frame: number; playing: boolean; unchanged?: true }
3624
+ ```
3625
+
3626
+ - The new composition is built while the old one goes on; at the swap the movie keeps doing what it is doing at that moment: the same frame, playing or paused (a `pause()`, a seek or a `play()` you made during the build is kept), the poster if it was shown. A playing movie whose sound is unchanged (`audio: 'reused'`) is not stopped at all: the frame clock and the sound go on and only the picture is swapped, so rebuilds in a row (a pointer) do not hold the playhead back. When the sound changed, the movie stops and plays on from the same frame with the new sound. A `playUntil()` in flight ends. The canvas changes size at the swap, not before. A `render()` or `peek()` in flight is not interrupted: the swap waits for it.
3627
+ - The sound is mixed only when it changed: `audio: 'reused'` means the same mix as before (the mix is the slow part, 0.6 to 0.8 s for a film; the rest takes about 20 ms). A time remap (`warp` / `sourceMap`) turns the cache off.
3628
+ - Calls that arrive during a rebuild are joined: at most one rebuild runs and one waits, with the latest values. Safe to call from `pointermove` or `scroll`: a rebuild that leaves the sound alone takes tens of milliseconds (11 to 42 ms on four gallery pieces), one that mixes the sound again 0.6 to 0.8 s, so a slider that changes the sound will lag behind the hand.
3629
+ - A rebuild that fails (a bad expression, a missing asset, a size that is not pixels) rejects, emits `error` with `where: 'rebuild'`, and the movie goes on with what it had. A `destroy()` during a rebuild ends it the same way.
3630
+ - To follow a window: observe a CSS-sized box around the canvas, not the canvas itself (the movie sets the canvas's pixel size): `new ResizeObserver(() => movie.resize(Math.round(box.clientWidth * devicePixelRatio), Math.round(box.clientHeight * devicePixelRatio))).observe(box)` (the `liquid-layout` recipe).
3631
+
3512
3632
  ### `movie.snapshot(frame?, options?): Promise<Blob | string>`
3513
3633
 
3514
3634
  ```ts
@@ -3692,7 +3812,9 @@ movie.off(event, fn): this
3692
3812
  | `'seeking'` / `'seeked'` | `{ frame, totalFrames }` (the frame it lands on) | a `gotoFrame()` to another frame starts / has finished. NOT emitted for playback ticks, `render()`, `snapshot()` or `contactSheet()` |
3693
3813
  | `'ended'` | none | playback ran off the end (after `'pause'`); pausing by hand does not emit it |
3694
3814
  | `'volumechange'` | `{ volume: number; muted: boolean }` | `volume` or `muted` really changed (clamping counts) |
3695
- | `'error'` | `{ where: 'init' \| 'render' \| 'playback'; message: string; error: unknown }` | `init()` / `render()` failed (the call still rejects), or a frame failed during playback |
3815
+ | `'loopchange'` | `{ loop: boolean }` | `movie.loop` really changed (the first value from `init({ loop })` is not a change) |
3816
+ | `'error'` | `{ where: 'init' \| 'render' \| 'playback' \| 'rebuild'; message: string; error: unknown }` | `init()` / `render()` / a rebuild failed (the call still rejects), or a frame failed during playback |
3817
+ | `'rebuilt'` | `RebuildReport` (see above) | a `setVars()` / `resize()` / `rebuild()` built the composition again (not for a call that changed nothing) |
3696
3818
 
3697
3819
  The names are the ones an HTML5 `<video>` uses, so a player written for `<video>` ports over.
3698
3820
 
@@ -3711,8 +3833,10 @@ The names are the ones an HTML5 `<video>` uses, so a player written for `<video>
3711
3833
  | `background` | string | CSS color |
3712
3834
  | `volume` | number | 0..1 getter/setter; immediate. Setter clamps and applies to active audio |
3713
3835
  | `muted` | boolean | getter/setter; immediate |
3836
+ | `loop` | boolean | getter/setter, default `false`. When true, an ordinary `play()` that reaches the end goes back to the start and plays on: no `ended`, no `pause` / `play` events at the wrap, the sound starts again (a short gap). `playUntil()` and `render()` ignore it. Say `loopchange` when it changes |
3714
3837
  | `app` | `pixi.js Application \| null` | PIXI Application instance (advanced/escape hatch) |
3715
3838
  | `timeline` | `Timeline \| null` | the movie's timeline (advanced): `time(t)`, `duration()`, `segments()` |
3839
+ | `vars` | `Record<string, number \| string>` | the current variable values (a copy) |
3716
3840
  | `stops` | `Stop[]` | the composition's stops: named times (see Presentations); empty for an ordinary movie |
3717
3841
 
3718
3842
  ### Presentations: `stops` and `pixi-effects/present`
@@ -3886,6 +4010,7 @@ interface ControllerOptions {
3886
4010
  exportButton?: boolean; // default true; false hides ⬇ + popover
3887
4011
  keyboard?: boolean; // default true
3888
4012
  presentButton?: boolean; // default true: a movie with stops gets a Present button (marks on the seek bar are always shown)
4013
+ loopButton?: boolean; // default true: a loop button next to the volume (and the L key) that flips movie.loop
3889
4014
  pauseAtStops?: boolean; // default true: with stops, play / Space / click on the picture play to the next stop and pause; false plays straight through
3890
4015
  clickToPlay?: boolean; // default true: a click / tap on the picture plays / pauses (like <video>); false leaves the canvas alone
3891
4016
  className?: string; // default 'movie-controller'
@@ -3954,6 +4079,7 @@ Active when `keyboard: true` and the key target is not `<input>`/`<textarea>`/`<
3954
4079
  | `←` / `→` | step ±1 frame |
3955
4080
  | `↑` / `↓` | volume ±5% (clears mute when increasing past zero) |
3956
4081
  | `M` | toggle mute |
4082
+ | `L` | toggle loop (`movie.loop`) |
3957
4083
  | `Shift+E` | export with current settings (skips the popover) |
3958
4084
  | `F` | toggle fullscreen |
3959
4085
  | `Esc` | close the export popover or exit fullscreen (browser) |