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