pixi-effects 0.2.0 → 0.4.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 +55 -0
- package/README.md +16 -6
- package/ai/SKILL.md +46 -0
- package/ai/reference/cheatsheet.md +121 -0
- package/ai/reference/pitfalls.md +75 -0
- package/ai/reference/recipes.md +375 -0
- package/ai/template.html +82 -0
- package/ai/tools/save-image.py +24 -0
- package/dist/{Base-Bh_VSusi.d.ts → Base-5R7C5pHR.d.cts} +7 -1
- package/dist/{Base-B1Tdn0Cv.d.cts → Base-DhY9oMxr.d.ts} +7 -1
- package/dist/Composition-D7RCAGEA.cjs +13 -0
- package/dist/{Composition-BI5HZJFL.cjs.map → Composition-D7RCAGEA.cjs.map} +1 -1
- package/dist/Composition-RGTRZ2JX.js +4 -0
- package/dist/{Composition-IO7ZN32J.js.map → Composition-RGTRZ2JX.js.map} +1 -1
- package/dist/Controller.d.cts +2 -2
- package/dist/Controller.d.ts +2 -2
- package/dist/Movie-CghFXprP.d.cts +179 -0
- package/dist/Movie-DRZtIGU7.d.ts +179 -0
- package/dist/{chunk-7OIWYXGV.cjs → chunk-6BQ6IEW2.cjs} +363 -91
- package/dist/chunk-6BQ6IEW2.cjs.map +1 -0
- package/dist/{chunk-H55V3U56.js → chunk-A574IA4F.js} +68 -13
- package/dist/chunk-A574IA4F.js.map +1 -0
- package/dist/{chunk-64IHCYYN.cjs → chunk-QGBOC5YL.cjs} +76 -21
- package/dist/chunk-QGBOC5YL.cjs.map +1 -0
- package/dist/{chunk-VJCDG6YG.js → chunk-ZQX7WIYL.js} +359 -93
- package/dist/chunk-ZQX7WIYL.js.map +1 -0
- package/dist/index.cjs +366 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +61 -31
- package/dist/index.d.ts +61 -31
- package/dist/index.js +363 -22
- package/dist/index.js.map +1 -1
- 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-CNBilhpz.d.cts → types-CRE9WKd4.d.cts} +59 -6
- package/dist/{types-CNBilhpz.d.ts → types-CRE9WKd4.d.ts} +59 -6
- package/docs/api.md +333 -0
- package/docs/dsl.md +1028 -0
- package/llms-full.txt +2001 -0
- package/llms.txt +30 -0
- package/package.json +5 -3
- package/dist/Composition-BI5HZJFL.cjs +0 -13
- package/dist/Composition-IO7ZN32J.js +0 -4
- package/dist/Movie-CcR6h2jO.d.cts +0 -82
- package/dist/Movie-D-n8glA6.d.ts +0 -82
- package/dist/chunk-64IHCYYN.cjs.map +0 -1
- package/dist/chunk-7OIWYXGV.cjs.map +0 -1
- package/dist/chunk-H55V3U56.js.map +0 -1
- package/dist/chunk-VJCDG6YG.js.map +0 -1
package/llms-full.txt
ADDED
|
@@ -0,0 +1,2001 @@
|
|
|
1
|
+
# pixi-effects — full documentation for AI agents
|
|
2
|
+
|
|
3
|
+
Concatenation of the skill, cheatsheet, pitfalls, recipes and the DSL / API references. Generated by scripts/build-llms.mjs; do not edit.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
<!-- ===== ai/SKILL.md ===== -->
|
|
8
|
+
|
|
9
|
+
# pixi-effects — write a video as data
|
|
10
|
+
|
|
11
|
+
You describe a video as a tree of plain objects; the library plays it in a canvas and exports it (MP4/WebM/MOV). You never write per-frame code. **Read `reference/cheatsheet.md` once before writing anything**, copy a recipe from `reference/recipes.md` when one fits, and skim `reference/pitfalls.md` — every item there cost a previous session a retry.
|
|
12
|
+
|
|
13
|
+
## Workflow
|
|
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.
|
|
16
|
+
2. **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.
|
|
17
|
+
3. **Run it and read the logs first.** `window.__ready` must be `true` and `window.__logs` should be `[]`. A pixi-effects warning is an instruction: fix what it names.
|
|
18
|
+
4. **Look at it** — you cannot judge motion from code. The library has the tools built in:
|
|
19
|
+
- `await movie.contactSheet({ count: 6, as: 'dataURL' })` → ONE image of several labelled frames (pass `frames: [...]` / `times: [...]` to choose; include the middle of every transition and the last second). Save it and view it.
|
|
20
|
+
- `await movie.inspect(frame)` → every layer's canvas bounds and visibility, plus `issues` (text off the canvas, cut off by an edge, empty, or overlapping other text). Run it at several frames; fix every issue it names.
|
|
21
|
+
- `await movie.snapshot(frame, { as: 'dataURL' })` → one frame, canvas only (no player bar).
|
|
22
|
+
From a browser tool the result is a `data:` URL string: `agent-browser eval "movie.contactSheet({ count: 6, as: 'dataURL' })" | python3 ai/tools/save-image.py /absolute/sheet.png`, then open the PNG. Sheet tiles are small (~480 px): use `snapshot` for detail. `inspect` output is long on busy scenes: pass `{ layers: 'none' }` and read `issues` first. Give layers a `name` so inspect paths are readable. A moving ticker is not flagged as cut off.
|
|
23
|
+
5. **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).
|
|
24
|
+
|
|
25
|
+
## The rules that cause most failures
|
|
26
|
+
|
|
27
|
+
- **Keyframe `at` starts at 0 when the layer appears** (negative = back from the layer's end). A layer's own `at` and a transition's `at` are composition time.
|
|
28
|
+
- **Layers are hidden, not removed, after their lifespan; z-order is array order.** Switch scenes by stacking layers with `at`/`duration` over an opaque background rect.
|
|
29
|
+
- **Angles are degrees. `+z` is toward the viewer. Numbers can be expressions** (`'GW/2 - w/2'`; no `pi`; no per-frame variable).
|
|
30
|
+
- **Anchors**: rect/circle/ellipse are centred on `x,y`; text and image are top-left (use `anchorX/anchorY: 0.5`); compositions use `pivotX/pivotY`. A bar that grows from its base needs `anchorY: 1`.
|
|
31
|
+
- **`audio` shorter than its layer goes silent** — add `loop: true`.
|
|
32
|
+
- **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.**
|
|
33
|
+
- **2.5D needs `threeD: true`** on each layer plus a `{ type: 'camera' }` layer; camera props go in `initial`/keyframes; keep every `z` below the camera distance (≈ 989 at 720p, fov 40); under a dolly zoom only `z = 0` stays fixed.
|
|
34
|
+
- **Built in:** `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`. **Not in the DSL (hand-roll, recipes exist or loops are easy):** arc / progress-ring and stroke draw-on, text that changes over time (other than `{value}`), other per-frame curves and jitter, per-letter text animators (one layer per letter), particle emitters, group/parent layers.
|
|
35
|
+
- **Blend modes:** `blendMode: 'add' | 'screen' | 'multiply'` on any layer (glows, light leaks).
|
|
36
|
+
- **One `Movie` per page.** A sound effect needs no `duration` (it lasts as long as its clip). Seeking is safe: counters, colours and growing shapes are right after any jump.
|
|
37
|
+
|
|
38
|
+
## Layout sanity (the cheap bugs a screenshot catches)
|
|
39
|
+
|
|
40
|
+
Centred text must be positioned by `anchorX/anchorY: 0.5`; estimate text width as ~0.6 × `fontSize` per glyph (monospace) or ~0.8 (heavy sans) and leave margin; captions stay above the bottom 60 px; nothing important within 5 % of the frame edge; a `threeD` layer can be sorted in front of text.
|
|
41
|
+
|
|
42
|
+
## Files
|
|
43
|
+
|
|
44
|
+
- `reference/cheatsheet.md` — every layer type, prop, default and rule on one page.
|
|
45
|
+
- `reference/recipes.md` — tested building blocks: slam type, lower-third, marquee, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions + music, looping, particles, gradients/vignette, three.js metal.
|
|
46
|
+
- `reference/pitfalls.md` — the full list of real mistakes, with status.
|
|
47
|
+
- `template.html` — the starting file. (It loads the library from a CDN; when testing against a local build, point the three `pixi-effects*` importmap entries at `../../dist/…`.)
|
|
48
|
+
- `tools/save-image.py` — save a `dataURL` result (contact sheet / snapshot) to a PNG.
|
|
49
|
+
- Full reference: `docs/dsl.md` and `docs/api.md` in the repository.
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
<!-- ===== ai/reference/cheatsheet.md ===== -->
|
|
53
|
+
|
|
54
|
+
# pixi-effects cheatsheet
|
|
55
|
+
|
|
56
|
+
A video is **data**: a tree of plain objects. Everything below is a JS object you put in `composition.sequences`. Build repeated structure with JS functions and loops; the spec is just objects.
|
|
57
|
+
|
|
58
|
+
## Conventions (state once, never guess)
|
|
59
|
+
|
|
60
|
+
| Thing | Rule |
|
|
61
|
+
|---|---|
|
|
62
|
+
| Canvas | pixels, origin top-left, `+x` right, `+y` down. Typical: 1280×720 @ 30 fps |
|
|
63
|
+
| Angles | **degrees**: `rotation`, `skew*`, `rotationX`, `rotationY` |
|
|
64
|
+
| Depth (`z`) | `+z` toward the viewer (bigger, nearer) |
|
|
65
|
+
| 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) |
|
|
66
|
+
| Colours | `'#rrggbb'` strings or numbers; text colour is `style.fill`, shape colour is `fillColor`/`strokeColor`, image colour is `tint` |
|
|
67
|
+
| Z-order | **array order**, later = on top. A layer is hidden (not removed) outside `[at, at+duration)` and keeps its last values |
|
|
68
|
+
| Time of a sequence's `at` | seconds from the start of its **parent composition** (default 0) |
|
|
69
|
+
| Time of a keyframe's `at` | seconds from the start of **its own layer**; **negative = back from the layer's end** |
|
|
70
|
+
| Time of a transition's `at` | seconds from the start of the **parent composition**; negative = back from its end |
|
|
71
|
+
| `duration` | defaults to the parent composition's duration |
|
|
72
|
+
|
|
73
|
+
## Expressions
|
|
74
|
+
|
|
75
|
+
Operators `+ - * /`, parentheses, unary `-`. Functions `min max abs floor ceil round sqrt pow sin cos tan` (radians; there is **no `pi`**: write `3.14159`).
|
|
76
|
+
|
|
77
|
+
| Variable | Meaning |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `W`, `H` | parent composition size |
|
|
80
|
+
| `GW`, `GH` | root (movie) size |
|
|
81
|
+
| `w`, `h` | this layer's own size (image/video natural size, text size **after** its style is applied, shape bounds) |
|
|
82
|
+
| `cover`, `contain` | scale that makes the layer cover / fit the parent (`scale: 'cover'`) |
|
|
83
|
+
| `t`, `d`, `T` | layer start time, layer duration, parent duration |
|
|
84
|
+
|
|
85
|
+
## Common fields (every layer)
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
name, at, duration,
|
|
89
|
+
initial: { ...props applied before any keyframe },
|
|
90
|
+
keyframes: [ { at, duration, ease, set | to | from | (from + to), repeat?, yoyo?, repeatDelay? } ],
|
|
91
|
+
filters: [ { type: 'chromaKey', keyColor, threshold, smoothing, spill } | { type: 'custom', name, filter: <Pixi Filter> } ],
|
|
92
|
+
mask: <a layer spec>, maskInverted, // a mask with no `at` of its own starts and ends with the layer it masks (its keyframes count from that layer's start); an explicit mask `at` is composition time
|
|
93
|
+
filterArea: { x, y, width, height } // in the layer's OWN coordinates; lets blur/glow draw past the layer's bounds
|
|
94
|
+
threeD: true // opt into 2.5D (see below)
|
|
95
|
+
blendMode: 'normal' | 'add' | 'screen' | 'multiply' // add / screen brighten (glows, light leaks: overlapping halos add up); on a composition its children inherit the mode
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Animatable props (`initial` / keyframes): `x y alpha rotation scale scaleX scaleY pivotX pivotY anchorX anchorY skewX skewY tint width height visible autoAlpha`, plus for shapes their style/geometry, for text `fill`, for audio `volume`, for 3D `z rotationX rotationY`, filter params as `'filters.<name>.<param>'`, three objects as `'three.<obj>.<path>'`.
|
|
99
|
+
|
|
100
|
+
Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate to), `from` (animate from), `from`+`to`. `from` / `from+to` show their start value from the layer's start, so a delayed fade-in needs no `initial.alpha`. `repeat` (finite extra plays), `yoyo`, `repeatDelay` loop any of them (endless repeats are not allowed). `ease` = any GSAP ease (`'none'`, `'power2.out'`, `'expo.out'`, `'back.out(1.7)'`, `'elastic.out(1,0.5)'`, `'sine.inOut'`, `'bounce.out'`…). Default ease is linear.
|
|
101
|
+
|
|
102
|
+
## Layer types
|
|
103
|
+
|
|
104
|
+
**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 }`) — that is how counters work. Other content cannot change over time. Multi-line text is centre-aligned by default (`style.align: 'left'` to change).
|
|
105
|
+
|
|
106
|
+
**image** — `asset`, `tint`, `colorSpace: 'rgb'|'oklab'|'oklch'`. Default anchor top-left; natural size = `w`,`h`.
|
|
107
|
+
|
|
108
|
+
**video** — `asset`, `loop`, `audio`, `volume`; `initial: { scale: 'cover' }`.
|
|
109
|
+
|
|
110
|
+
**audio** — `asset`, `loop`, `volume`; fade with volume keyframes (`{ at: -2, to: { volume: 0 }, duration: 2 }` fades the last 2 s). **Omit `duration` for a one-shot sound effect: it lasts exactly as long as its clip.** With `loop: true` and no `duration` it lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true`.
|
|
111
|
+
|
|
112
|
+
**shape** — `shape: 'rect' | 'circle' | 'ellipse' | 'line' | 'polygon' | 'path'`:
|
|
113
|
+
|
|
114
|
+
| shape | geometry (top level **or** in `initial`) |
|
|
115
|
+
|---|---|
|
|
116
|
+
| rect | `width height cornerRadius anchorX anchorY` |
|
|
117
|
+
| circle | `radius anchorX anchorY` |
|
|
118
|
+
| ellipse | `radiusX radiusY anchorX anchorY` |
|
|
119
|
+
| 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` |
|
|
120
|
+
| polygon | `points: [[x,y],…] open` — canvas coordinates like `line` |
|
|
121
|
+
| path | `d` (SVG path data) — canvas coordinates like `line` |
|
|
122
|
+
|
|
123
|
+
Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha strokeWidth`; `colorSpace: 'oklab'|'oklch'` for clean colour tweens. rect/circle/ellipse are **centred on `x,y` by default** (`anchorX/anchorY` default 0.5): for a bar growing from its base use `anchorY: 1` (or `anchorX: 0` for left-to-right). **`fillGradient`** (top level or `initial`; instead of `fillColor`): `{ type?: 'linear'|'radial', stops: [[0, '#000'], [1, 'rgba(0,0,0,.6)']], angle? /* linear, deg, 90 = top→bottom */, center?, innerRadius?, radius? /* radial, 0–1 of bounds */ }` — stops may have alpha, so a radial transparent→dark is a vignette.
|
|
124
|
+
|
|
125
|
+
**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) 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.
|
|
126
|
+
|
|
127
|
+
**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`.
|
|
128
|
+
|
|
129
|
+
**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`.
|
|
130
|
+
|
|
131
|
+
## 2.5D in one paragraph
|
|
132
|
+
|
|
133
|
+
Add `threeD: true` to any visual layer, then use `z`, `rotationX`, `rotationY` (centre rotation with `anchorX/Y: 0.5` or `pivotX/Y`). Add a `{ type: 'camera' }` layer for a view. A `threeD` layer at `z: 0` with the default camera looks identical to a 2D one. Consecutive `threeD` layers are drawn farthest-first (equal `z` keeps array order); non-`threeD` layers ignore the camera and keep array order. Layers at/behind the camera plane are hidden. Not supported on `threeD` layers: masks, transitions, `filterArea`.
|
|
134
|
+
|
|
135
|
+
## Transitions (on the parent composition)
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
transitions: [{ kind: 'crossfade' | 'wipe' | 'iris' | 'slide' | 'dip' | 'zoom' | 'dissolve',
|
|
139
|
+
from: 'nameA', to: 'nameB', at: 3, duration: 1, ease: 'none',
|
|
140
|
+
direction: 'left|right|up|down' /* wipe, slide */, mode: 'in|out' /* iris, zoom */,
|
|
141
|
+
smoothing, fromScale /* zoom */, scale, seed /* dissolve */ }]
|
|
142
|
+
```
|
|
143
|
+
`from`/`to` are sibling layer `name`s, `to` declared after `from`, and both layers must be alive for the whole window (overlap them by `duration`). Only those two layers are affected; every other layer stays put. Wipe/iris/dissolve/zoom wrap the layers for you.
|
|
144
|
+
|
|
145
|
+
## Presets
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
import { kenBurns, withFade } from 'pixi-effects';
|
|
149
|
+
kenBurns({ asset, name, at, duration, motion: 'still'|'scale'|'rotation'|'position',
|
|
150
|
+
fit: 'cover'|'contain', ease, /* scale */ origin, zoom, direction, /* rotation */ angle, /* position */ from, to })
|
|
151
|
+
withFade(spec, { in: 0.5, out: 0.5 }) // alpha fade; `out` needs spec.duration
|
|
152
|
+
orbit({ duration: 6, degrees: 40 /* , radius, center, start, fov, ease, at */ }) // a camera layer that circles a point
|
|
153
|
+
```
|
|
154
|
+
`kenBurns` covers the canvas, so source images should be at least canvas-sized (1920×1080 for 1280×720).
|
|
155
|
+
|
|
156
|
+
## Assets
|
|
157
|
+
|
|
158
|
+
`assets: [{ name, src }]` in `movie.init` (images, audio, video). `src` may be a URL or a `data:`/`blob:` URL (e.g. `canvas.toDataURL()`).
|
|
159
|
+
|
|
160
|
+
## Movie API
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
const movie = new Movie();
|
|
164
|
+
await movie.init({ canvas, width, height, duration, frameRate, background, assets, composition });
|
|
165
|
+
movie.play(); movie.pause();
|
|
166
|
+
await movie.gotoFrame(n, true); // seek (frames; 30 fps => t = n/30)
|
|
167
|
+
const blob = await movie.render({ format: 'mp4' }); // 'mp4' | 'webm' | 'mov' | 'mkv'; ~real-time
|
|
168
|
+
movie.on('progress', e => e.progress /* 0–100 */);
|
|
169
|
+
movie.audioBuffer // mixed audio (after init), if any audio layers
|
|
170
|
+
await movie.contactSheet({ count: 6, as: 'dataURL' }) // ONE image of several labelled frames — look at it. Options: frames | times | count, columns, cellWidth, as
|
|
171
|
+
await movie.snapshot(60, { as: 'dataURL' }) // one frame, canvas only (no player bar) — use it for detail; sheet tiles are small
|
|
172
|
+
await movie.inspect(60, { layers: 'none' }) // issues only (default lists the visible layers); text off-canvas / cut off / empty / overlapping text. Name your layers so paths are readable
|
|
173
|
+
new Controller(movie, { canvas }) // optional player bar (overlays the canvas bottom ~60px; not in the export)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
<!-- ===== ai/reference/pitfalls.md ===== -->
|
|
178
|
+
|
|
179
|
+
# pixi-effects pitfalls — what actually went wrong
|
|
180
|
+
|
|
181
|
+
Collected by having fresh AI sessions build pieces from the docs alone: first six (kinetic type, news lower-third, slideshow, 2.5D showcase, three.js title, bar chart), then a portfolio of 16 more (items 32 and up), made by three different models. Each entry is a real stumble. **Status**: `fixed` = the library now handles or warns; `docs` = nothing can catch it, so remember it; `hand-roll` = the DSL has no feature, see the workaround.
|
|
182
|
+
|
|
183
|
+
A good habit that catches most of these: after `init`, read `window.__logs` (see `ai/template.html`) — every pixi-effects warning says what to change.
|
|
184
|
+
|
|
185
|
+
## Time
|
|
186
|
+
|
|
187
|
+
1. **Keyframe `at` starts at 0 when THIS layer appears** *(fixed in 0.3.0; earlier versions measured from the composition and failed silently)*. A layer at `at: 2` with a keyframe `at: 0` animates at global 2 s. Negative `at` counts back from the layer's end. A keyframe that starts after its layer ends now warns ("never plays").
|
|
188
|
+
2. **A layer's own `at` and a transition's `at` are composition time** (seconds from the parent composition's start). Only keyframes are layer-local.
|
|
189
|
+
3. **Layers are hidden, not removed, after `[at, at+duration)`** and keep their last values. Z-order is array order (later = on top). To switch scenes, stack scenes with `at`/`duration`, each starting with an opaque full-screen rect. *(docs)*
|
|
190
|
+
4. **Transitions need overlapping lifespans.** Both layers must be alive for the whole window: scene `i` starts at `i·(L−T)`, the transition's `at` is the next scene's start, total = `n·(L−T)+T`. A scene that ends while the transition is still running leaves a gap. *(docs)*
|
|
191
|
+
5. **Audio length.** Omit `duration` on a one-shot sound effect: the layer lasts exactly as long as its clip (pop 0.13 s, click 0.1 s, chime 0.6 s, swoosh 0.55 s; nothing to measure). `loop: true` with no `duration` lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true` (`bgm.mp3` is 6 s) and warns. Volume fades work like any keyframe: `volume: 0` + `{ at: 0, to: { volume: v }, duration: 2 }` + `{ at: -2, to: { volume: 0 }, duration: 2 }` (the last 2 s only; `set` is a jump). *(fixed: volume keyframes used to ramp from the previous keyframe's end, so the music faded across the whole piece)*
|
|
192
|
+
6. **Snap times to the frame grid (`Math.round(t*30)/30`) when two things must stay locked** (a bar and its label). Drive both from one JS easing function with per-frame `set` keyframes rather than mixing a tween with computed positions. *(docs)*
|
|
193
|
+
7. **Pick eases on purpose.** `expo.in` stays tiny until the very end (a "flood" circle never reaches the corners — use a bigger radius and `power2.in`). A zoom transition with `power2.out` looks like a cut because the incoming scene is nearly opaque after 40 %. Default is linear. *(docs)*
|
|
194
|
+
8. **`render()` runs in roughly real time** (6 s of 720p ≈ 6 s). Listen to `movie.on('progress', e => e.progress)` (0–100). *(docs)*
|
|
195
|
+
|
|
196
|
+
## Layout and anchors
|
|
197
|
+
|
|
198
|
+
9. **Defaults differ by type.** rect/circle/ellipse are centred on `x,y`; text and image are top-left; a composition has no anchor — use `pivotX/pivotY` and set `x,y` to where the pivot should land. For a bar growing from its base use `anchorY: 1` (or `anchorX: 0`). *(docs)*
|
|
199
|
+
10. **Shape geometry may be at the top level or in `initial`** (`width height radius anchorX …`). *(fixed: `initial` used to be silently ignored)*
|
|
200
|
+
11. **Angles are degrees** — `rotation`, `skew*`, `rotationX/Y` — and `rotation: -0.2` is almost no rotation. *(docs)*
|
|
201
|
+
12. **Text**: anchor 0.5 centres the line *box*, not the cap height (an underline under serif or italic text needs ~`0.9·size` below the centre: `0.46·size` touches the descenders of a g or y). Arial Black is ~0.8·size wide per glyph, monospace ~0.6·size; leave ~7 % for hold-scale. `style` accepts any PixiJS TextStyle field: `letterSpacing`, `wordWrap` + `wordWrapWidth`, `lineHeight`, `stroke`, `dropShadow`, `padding`. A glow (`dropShadow` with `blur`) is clipped unless `padding ≥ 2·blur`. *(docs)*
|
|
202
|
+
13. **`w` / `h` in expressions** are the layer's own size. For text they now describe the styled text (`x: '-w'` scrolls a marquee fully out). *(fixed)*
|
|
203
|
+
14. **Masks live in the parent's coordinate space and do not follow the masked layer.** Reveal by growing the mask's `width` from a fixed edge (`anchorX: 0`); exit by tweening `x` and `width` together; slide the content separately. *(docs)*
|
|
204
|
+
15. **`line` / `polygon` / `path` use plain canvas coordinates** when you give no `x,y` (they used to be drawn around (0,0)); with `x,y` those place the shape's centre. Stroke colour and width go in `initial`. A background pill must be sized from its text (~0.58 × fontSize per glyph + padding): `inspect` does not check text against shapes. *(fixed)*
|
|
205
|
+
15b. **`filterArea` is in the layer's own coordinates** (origin = its local origin, e.g. a circle's centre), not the parent's. A blurred shape is clipped to its bounding box without it: `filterArea: { x: -(r+260), y: -(r+260), width: 2*(r+260), height: 2*(r+260) }`. `threeD` layers pad automatically. *(docs)*
|
|
206
|
+
16. **The player bar overlays the bottom ~60 px of the canvas** in browser screenshots (not in the exported video). `movie.snapshot()` / `movie.contactSheet()` never include it; keep captions above it anyway. *(fixed: use the built-in snapshot tools)*
|
|
207
|
+
|
|
208
|
+
## Missing features (hand-roll)
|
|
209
|
+
|
|
210
|
+
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)*
|
|
211
|
+
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)*
|
|
212
|
+
19. **Loops**: `repeat` (finite) / `yoyo` / `repeatDelay` on any keyframe; infinite repeats are rejected with a warning. *(fixed)*
|
|
213
|
+
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)*
|
|
214
|
+
21. **No text split / per-letter animator, no group/null parent, no particle emitter, no blend modes, no caption-linked-to-slide.** One layer per letter; many layers; seeded random loops; plain alpha. *(hand-roll)*
|
|
215
|
+
22. **Colour tweens look muddy in RGB** (red→green passes through brown). Use `colorSpace: 'oklab'` or `'oklch'` on the layer. *(docs)*
|
|
216
|
+
23. **Generated images**: `canvas.toDataURL()` as an asset `src` works. Make them ≥ canvas size or `kenBurns` zooms look soft. *(docs)*
|
|
217
|
+
|
|
218
|
+
## 2.5D / three.js
|
|
219
|
+
|
|
220
|
+
24. **`threeD` is required** for `z`, `rotationX`, `rotationY` (they warn otherwise). Camera props go in `initial`/keyframes, never on the camera object. `+z` is toward the viewer. *(fixed: warns)*
|
|
221
|
+
25. **A layer at/behind the camera plane is hidden** — the default camera sits at `z = (H/2)/tan(fov/2)` ≈ 989 at 720p, fov 40 (≈ 808 at fov 48, lower at higher fov). Keep every layer's `z` below it. *(fixed: warns once)*
|
|
222
|
+
26. **Under a dolly zoom only the `z = 0` plane stays put**; everything nearer balloons and clips. Headline at `z: 0`, near cards `z ≤ ~70`. Perspective also pulls far layers toward the centre: put far cards at the outer x, near cards inward, and keep ~10 % margin for an orbit sweep. `orbit({ dollyZoom: { from, to } })` does both together. *(docs)*
|
|
223
|
+
27. **Combining orbit and dolly zoom**: `orbit()` specifies `z`, so it stops following `fov` — write your own `fov` and `z = (H/2)/tan(fov/2)` keyframes for the zoom. *(docs)*
|
|
224
|
+
28. **Nearer `threeD` layers sort on top of text** — lay out around them; equal `z` keeps array order. A card (rect + icon + label that move together) is a `composition` with `threeD: true`. A final call-to-action or overlay should be a plain 2D layer (last in the array): it ignores the camera, and fade the 3D scene and headline out before it appears (a dim rect alone leaves them visible). *(docs)*
|
|
225
|
+
29. **three.js**: the layer clips at its own rectangle — size it to the area you want and pull the camera back (`z ≈ 5.4` for a radius-1 torus knot at fov 50). Metal needs an environment map (`PMREMGenerator(ctx.renderer)`); `three/addons` is not in the importmap. Route `three` through the importmap so there is one copy. Masks/transitions/`filterArea` are not supported on `threeD` layers. *(docs)*
|
|
226
|
+
|
|
227
|
+
## Tooling (agent harness)
|
|
228
|
+
|
|
229
|
+
30. **A scene change must keep the outgoing scene alive until the covering flood / wipe has finished**, and the next scene starts when it is complete; the flood circle must reach the corners (radius ≥ half the diagonal) and use `power2.in`, not `expo.in`. *(docs)*
|
|
230
|
+
30b. **Unknown option names now warn** (`contactSheet({ cols: 2 })` → did you mean `columns`; `init({ fps })` → `frameRate`). Layer names show up in `inspect` paths, so give layers a `name`. `inspect` ignores faint (alpha < 0.3) layers and text whose x / y is animated (tickers), so a scrolling marquee is not reported as cut off. Pixi may print `[BindGroup] … destroyed while still bound` warnings at `movie.destroy()`: harmless (playback and seeking never warn). *(docs)*
|
|
231
|
+
30c. **Look with the built-in tools, not OS screenshots**: `await movie.contactSheet({ count: 6, as: 'dataURL' })` returns one labelled image of the whole animation; `await movie.inspect(frame)` returns every layer's canvas bounds and `issues`. If you do use a browser tool, `screenshot` needs an **absolute path**, macOS `sed -i` needs `''`, and a static server may redirect `/x.html` → `/x` and drop `?query`. Keep the template's `try/catch` around `init` so failures land in `window.__logs`. Check ≥ 5 timestamps including mid-transition. *(fixed: tools built in)*
|
|
232
|
+
31. **Check audio without rendering by decoding the render**: `const b = await movie.render({format:'mp4'})` then `new OfflineAudioContext(...).decodeAudioData(await b.arrayBuffer())` and measure RMS over time. `movie.audioBuffer` holds the mix. *(docs)*
|
|
233
|
+
|
|
234
|
+
## Round 2 — found by building 16 portfolio pieces (examples/gallery)
|
|
235
|
+
|
|
236
|
+
32. **Seeking is safe now** *(fixed)*: a `set` keyframe (counter value, text fill, image tint, shape style) is undone when you seek back before it, and shapes are redrawn before they are culled, so a rect growing from `width: 0` at the left edge draws after any jump seek (contact sheet, snapshot, scrubbing, export). The old workarounds (`width: 0.01`, a one-frame `to` instead of `set`) are not needed.
|
|
237
|
+
33. **A mask starts and ends with the layer it masks** *(fixed)*, so its keyframes count from that layer's start. A mask with an explicit `at` keeps composition time. Before, a mask `at: 0` reveal had finished before a layer that appeared at 8 s, and the layer stayed invisible without any warning.
|
|
238
|
+
34. **`from` and `from+to` show their start value from the LAYER's start**, not from the keyframe's `at` (GSAP behaviour): a delayed fade-in `{ at: 2, from: { alpha: 0 }, to: { alpha: 1 } }` needs no `initial.alpha`. To keep the current value until `at` and then jump, use `set`. *(docs)*
|
|
239
|
+
35. **One `Movie` per page.** Creating a second Movie (for an isolated repro) and destroying it breaks the first (`null.clear`). Reload instead. *(docs)*
|
|
240
|
+
36. **Multi-line text is centre-aligned by default** (`style.align: 'left'` for left-aligned paragraphs). *(docs)*
|
|
241
|
+
37. **A `threeD` composition (a card) clips its children at its own width × height.** Size the card for its soft shadow too. A growing `threeD` shape or text is fine: its texture is resized in place (it used to warn "destroyed while still bound"). *(docs / fixed)*
|
|
242
|
+
38. **Under an orbit the side the camera swings toward becomes the NEAR side**: a "far" card at `z: −200` can be drawn bigger than 1× on that side and run off the frame (`inspect` only checks text). Pull those cards inward / lower `z`, and check a frame at the extreme of the sweep. After `orbit()` ends the default camera returns, so to hold the final pose add a camera layer that starts there. Near particles balloon under a dolly zoom: cap particle `z` at about +150, or one becomes a fake full stop. *(docs)*
|
|
243
|
+
39. **`fillGradient` stops span the whole unmasked shape**, not the visible part: under a mask, place the stops for the visible fraction (e.g. 0.64→1). A translucent shape over near-black reads as grey: put the alpha in the gradient stops. *(docs)*
|
|
244
|
+
40. **Rotate a polygon / path about a chosen point** by wrapping it in a `composition` with `pivotX/pivotY` (its centre is otherwise the bounds centre). *(docs)*
|
|
245
|
+
41. **Glow a whole set of layers** by putting them in one `composition` with a blur filter and `filterArea` (only a composition carries a filter for its children): wide enough for the blur, and the set is built once, so the extra copy is just a second group. *(docs)*
|
|
246
|
+
42. **Transition looks:** the default `dissolve` reads as a hard black-and-white camouflage and `scale: 220` as static; `scale: 90, smoothing: 0.5` is a soft film dissolve. The default edge softness of `iris` (0.02) and `wipe` blurs flat graphics at 720p: `smoothing: 0.004`–`0.01` gives a clean edge. *(docs)*
|
|
247
|
+
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)*
|
|
248
|
+
44. **`{value}` has no number padding**: a clock or timecode cannot show `:05`. Keep the value in a range that always has the same digit count, or split digits into two counters. *(hand-roll)*
|
|
249
|
+
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)*
|
|
250
|
+
46. **Still not in the DSL (hand-roll; round-two notes have workarounds):** arc / progress-ring primitive and stroke draw-on / trim path (use a growing rect mask for a one-direction reveal, tick marks, or pre-drawn arcs shown one per frame), text content that changes over time (only `{value}`), per-frame jitter (film grain = seeded `set` keyframes on an oversized image), particle emitters, per-letter / per-line text split (measure with `canvas.measureText` using Pixi's font string), shared masks, `zIndex`.
|
|
251
|
+
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. *(fixed)*
|
|
252
|
+
47. **Browser tooling:** 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 and ignores the two scenes of a running transition; it still does not compare text with picture content or shapes.
|
|
253
|
+
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)*
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
<!-- ===== ai/reference/recipes.md ===== -->
|
|
257
|
+
|
|
258
|
+
# pixi-effects recipes
|
|
259
|
+
|
|
260
|
+
Copy, adapt, run. Every block marked `@recipe` is a function body that **returns the composition's `sequences`** (or `{ sequences, transitions, duration }`); the repo's tests build each one and fail on any warning, so they stay correct. Blocks marked `@docs-only` need a browser (canvas / three.js) and are not executed by the tests.
|
|
261
|
+
|
|
262
|
+
Assumed canvas: 1280×720 @ 30 fps. `kenBurns`, `withFade` and `orbit` come from `pixi-effects`.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Words that slam in on the beat (kinetic type)
|
|
267
|
+
|
|
268
|
+
Slam = big scale + tiny rotation easing out fast, plus a white flash rect on the beat. Keyframe `at` is local to the layer, so every word's keyframes start at `0`.
|
|
269
|
+
|
|
270
|
+
```js
|
|
271
|
+
// @recipe slam-words
|
|
272
|
+
const WORDS = [['KAFFE', '#ffd166'], ['NORD', '#ef476f'], ['ROAST', '#06d6a0']];
|
|
273
|
+
const BEAT = 0.5; // seconds per beat
|
|
274
|
+
const sequences = [
|
|
275
|
+
{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#111111' } },
|
|
276
|
+
];
|
|
277
|
+
WORDS.forEach(([word, color], i) => {
|
|
278
|
+
const at = i * BEAT * 2;
|
|
279
|
+
sequences.push({
|
|
280
|
+
type: 'text', text: word, at, duration: BEAT * 2,
|
|
281
|
+
style: { fontSize: 'GH * 0.4', fontWeight: '900', fill: color, fontFamily: 'Arial Black, Arial, sans-serif' },
|
|
282
|
+
initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
|
|
283
|
+
keyframes: [{ at: 0, from: { scale: 2.6, rotation: -5, alpha: 0 }, to: { scale: 1, rotation: 0, alpha: 1 }, duration: 0.16, ease: 'expo.out' }],
|
|
284
|
+
});
|
|
285
|
+
sequences.push({ // white flash on the beat
|
|
286
|
+
type: 'shape', shape: 'rect', width: 'GW', height: 'GH', at, duration: 0.3,
|
|
287
|
+
initial: { x: 'GW/2', y: 'GH/2', fillColor: '#ffffff', alpha: 0.8 },
|
|
288
|
+
keyframes: [{ at: 0, to: { alpha: 0 }, duration: 0.28 }],
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
return sequences;
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Size text by eye: Arial Black glyphs are ~0.8 × fontSize wide, monospace ~0.6 ×. Leave ~7 % of the frame for a hold-scale.
|
|
295
|
+
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
## Lower-third revealed by a wipe mask
|
|
299
|
+
|
|
300
|
+
A `mask` is a layer in the **parent's** coordinate space and does **not** move with the layer it masks: reveal by growing the mask's `width` from a fixed left edge (`anchorX: 0`); exit by moving the left edge to the right while the width shrinks to 0. Rects are centred by default, hence `anchorX: 0`.
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
// @recipe lower-third
|
|
304
|
+
const bar = (name, y, w, h, color, at) => ({
|
|
305
|
+
type: 'shape', shape: 'rect', name, width: w, height: h, anchorX: 0, at, duration: 3.6,
|
|
306
|
+
initial: { x: 80, y, fillColor: color },
|
|
307
|
+
mask: {
|
|
308
|
+
type: 'shape', shape: 'rect', width: 0, height: h, anchorX: 0,
|
|
309
|
+
initial: { x: 80, y, fillColor: '#ffffff' },
|
|
310
|
+
keyframes: [
|
|
311
|
+
{ at: 0, to: { width: w }, duration: 0.5, ease: 'power3.out' }, // reveal
|
|
312
|
+
{ at: -0.5, to: { x: 80 + w, width: 0 }, duration: 0.5, ease: 'power3.in' }, // exit
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
return [
|
|
317
|
+
bar('name-bar', 600, 520, 56, '#e63946', 0.2),
|
|
318
|
+
bar('title-bar', 656, 420, 40, '#1d3557', 0.35),
|
|
319
|
+
{ type: 'text', text: 'JANE DOE', at: 0.5, duration: 3, style: { fontSize: 34, fontWeight: 'bold', fill: '#ffffff' },
|
|
320
|
+
initial: { x: 100, y: 600, anchorX: 0, anchorY: 0.5, alpha: 0 },
|
|
321
|
+
keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }, { at: -0.4, to: { alpha: 0 }, duration: 0.3 }] },
|
|
322
|
+
];
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## A ticker that scrolls out completely (`w` = the text's own width)
|
|
328
|
+
|
|
329
|
+
`'-w'` is the exact x at which the text has left the screen on the left (`w` is measured after the style is applied).
|
|
330
|
+
|
|
331
|
+
```js
|
|
332
|
+
// @recipe marquee
|
|
333
|
+
return [{
|
|
334
|
+
type: 'text', text: 'BREAKING NEWS • MARKETS RALLY • NEW RECORD SET', duration: 8,
|
|
335
|
+
style: { fontSize: 30, fontWeight: 'bold', fill: '#ffffff' },
|
|
336
|
+
initial: { x: 'GW', y: 670, anchorX: 0, anchorY: 0.5 },
|
|
337
|
+
keyframes: [{ at: 0, to: { x: '-w' }, duration: 8, ease: 'none' }],
|
|
338
|
+
}];
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Numbers that count up
|
|
344
|
+
|
|
345
|
+
A text layer has an animatable number, `value`, printed wherever the text has `{value}`. Animate it with ordinary keyframes (ease, `from`/`to`, `repeat`).
|
|
346
|
+
|
|
347
|
+
```js
|
|
348
|
+
// @recipe count-up
|
|
349
|
+
return [{
|
|
350
|
+
type: 'text', text: '{value}', format: { grouping: true }, // → "2,480" (decimals: 0 by default)
|
|
351
|
+
style: { fontSize: 96, fontWeight: 'bold', fill: '#ffffff' },
|
|
352
|
+
initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5, value: 0 },
|
|
353
|
+
keyframes: [{ at: 1, to: { value: 2480 }, duration: 1.2, ease: 'power3.out' }],
|
|
354
|
+
}];
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Prefixes and suffixes go in the text (`'${value}'`, `'{value} users'`, `'{value}%'`). If a bar must stay locked to its counter, give both the same `at`, `duration` and `ease` — see the bar chart below.
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## Data-driven bar chart
|
|
362
|
+
|
|
363
|
+
Generate the whole spec from the data. Bars grow from the baseline (`anchorY: 1` + a `height` keyframe); each value label rides the top of its bar and counts up with the same ease and timing, so they stay locked. A line's `from` / `to` are plain canvas coordinates (give `x,y` only to move it). A callout pill must be sized from its text.
|
|
364
|
+
|
|
365
|
+
```js
|
|
366
|
+
// @recipe bar-chart
|
|
367
|
+
const data = [['Mon', 12], ['Tue', 30], ['Wed', 22], ['Thu', 41], ['Fri', 35]];
|
|
368
|
+
const max = Math.max(...data.map(d => d[1]));
|
|
369
|
+
const step = [1, 2, 2.5, 5, 10].map(m => m * 10 ** Math.floor(Math.log10(max / 4))).find(v => max / v <= 5) ?? 10;
|
|
370
|
+
const NICE = Math.ceil(max / step) * step; // axis top, so the chart survives changed data
|
|
371
|
+
const BASE = 600, CHART_H = 380, BAR_W = 90, GAP = 40, DUR = 0.9, EASE = 'power3.out';
|
|
372
|
+
const X0 = (1280 - (data.length * BAR_W + (data.length - 1) * GAP)) / 2, X1 = X0 + data.length * BAR_W + (data.length - 1) * GAP;
|
|
373
|
+
const sequences = [];
|
|
374
|
+
for (let v = 0; v <= NICE; v += step) { // grid lines + tick labels
|
|
375
|
+
const y = BASE - CHART_H * v / NICE;
|
|
376
|
+
sequences.push({ type: 'shape', shape: 'line', from: [X0 - 20, y], to: [X1 + 20, y], initial: { strokeColor: '#33405c', strokeWidth: 1 } });
|
|
377
|
+
sequences.push({ type: 'text', text: String(v), style: { fontSize: 20, fill: '#7f8bb0' }, initial: { x: X0 - 32, y, anchorX: 1, anchorY: 0.5 } });
|
|
378
|
+
}
|
|
379
|
+
const best = data.findIndex(d => d[1] === max);
|
|
380
|
+
data.forEach(([label, value], i) => {
|
|
381
|
+
const x = X0 + i * (BAR_W + GAP) + BAR_W / 2;
|
|
382
|
+
const at = 0.4 + i * 0.12;
|
|
383
|
+
const h = CHART_H * value / NICE;
|
|
384
|
+
sequences.push({
|
|
385
|
+
type: 'shape', shape: 'rect', width: BAR_W, height: 0, cornerRadius: 8, anchorY: 1, at, colorSpace: 'oklab',
|
|
386
|
+
initial: { x, y: BASE, fillColor: '#4f6df5' },
|
|
387
|
+
keyframes: [
|
|
388
|
+
{ at: 0, to: { height: h }, duration: DUR, ease: EASE },
|
|
389
|
+
...(i === best ? [{ at: 4 - at, to: { fillColor: '#ffd166' }, duration: 0.5 }] : [{ at: 4 - at, to: { alpha: 0.45 }, duration: 0.5 }]), // highlight the maximum at t = 4 s
|
|
390
|
+
],
|
|
391
|
+
});
|
|
392
|
+
sequences.push({ // value label: counts up while riding the bar's top
|
|
393
|
+
type: 'text', text: '{value}', at,
|
|
394
|
+
style: { fontSize: 28, fontWeight: 'bold', fill: '#ffffff' },
|
|
395
|
+
initial: { x, y: BASE - 14, anchorX: 0.5, anchorY: 1, value: 0 },
|
|
396
|
+
keyframes: [{ at: 0, to: { value, y: BASE - h - 14 }, duration: DUR, ease: EASE }],
|
|
397
|
+
});
|
|
398
|
+
sequences.push({ type: 'text', text: label, at, style: { fontSize: 26, fill: '#aab4d4' }, initial: { x, y: BASE + 16, anchorX: 0.5, anchorY: 0 } });
|
|
399
|
+
});
|
|
400
|
+
// callout: a pill sized from its text (~0.58 x fontSize per glyph + padding), a pointer triangle, clamped inside the plot
|
|
401
|
+
const text = 'Peak: ' + data[best][0] + ' ' + max, FS = 26;
|
|
402
|
+
const PW = text.length * FS * 0.58 + 44, cx = Math.min(Math.max(X0 + best * (BAR_W + GAP) + BAR_W / 2, X0 + PW / 2), X1 - PW / 2), cy = BASE - CHART_H - 70;
|
|
403
|
+
sequences.push({ type: 'shape', shape: 'rect', width: PW, height: 52, cornerRadius: 26, at: 4.2, initial: { x: cx, y: cy, fillColor: '#ffd166', alpha: 0 },
|
|
404
|
+
keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
|
|
405
|
+
sequences.push({ type: 'shape', shape: 'polygon', points: [[-10, 0], [10, 0], [0, 12]], at: 4.2, initial: { x: cx, y: cy + 32, fillColor: '#ffd166', alpha: 0 },
|
|
406
|
+
keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
|
|
407
|
+
sequences.push({ type: 'text', text, at: 4.2, style: { fontSize: FS, fontWeight: 'bold', fill: '#1b1b2f' }, initial: { x: cx, y: cy, anchorX: 0.5, anchorY: 0.5, alpha: 0 },
|
|
408
|
+
keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
|
|
409
|
+
return sequences;
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Letters that fly in from depth (2.5D title)
|
|
415
|
+
|
|
416
|
+
One `threeD` text layer per letter, staggered; monospace so the advance is predictable (~0.6 × fontSize). `fov` eases while `z` auto-follows it, so the `z = 0` plane (the title) stays put. Letters start at `z: 560`, which must stay **below the camera distance** (≈ 808 at `fov` 48) or they are hidden. `dropShadow` glow needs `padding` (≥ 2 × blur).
|
|
417
|
+
|
|
418
|
+
```js
|
|
419
|
+
// @recipe depth-title
|
|
420
|
+
const TEXT = 'PIXI EFFECTS', SIZE = 118, ADV = SIZE * 0.602;
|
|
421
|
+
const letters = [...TEXT].flatMap((ch, i) => ch === ' ' ? [] : [{
|
|
422
|
+
type: 'text', text: ch, threeD: true,
|
|
423
|
+
style: {
|
|
424
|
+
fontSize: SIZE, fontWeight: '800', fill: '#3de0ff',
|
|
425
|
+
fontFamily: "ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
|
|
426
|
+
dropShadow: { color: '#5b6cff', blur: 20, distance: 0, alpha: 0.9 }, padding: 48,
|
|
427
|
+
},
|
|
428
|
+
initial: { x: 640 + (i - (TEXT.length - 1) / 2) * ADV, y: 330, anchorX: 0.5, anchorY: 0.5, z: 560, rotationY: 75, rotationX: -45, alpha: 0 },
|
|
429
|
+
keyframes: [
|
|
430
|
+
{ at: 0.55 + i * 0.075, to: { z: 0, rotationY: 0, rotationX: 0 }, duration: 1.0, ease: 'expo.out' },
|
|
431
|
+
{ at: 0.55 + i * 0.075, to: { alpha: 1 }, duration: 0.3 },
|
|
432
|
+
{ at: 0.7 + i * 0.075, to: { fill: '#f4f6ff' }, duration: 0.8, ease: 'sine.out' }, // colour flash on landing
|
|
433
|
+
],
|
|
434
|
+
}]);
|
|
435
|
+
return [
|
|
436
|
+
{ type: 'camera', initial: { fov: 48 }, keyframes: [{ at: 0, to: { fov: 34 }, duration: 6.5, ease: 'sine.inOut' }] },
|
|
437
|
+
...letters,
|
|
438
|
+
];
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Camera orbit (+ optional dolly zoom)
|
|
444
|
+
|
|
445
|
+
`orbit()` returns a camera layer that circles a point (the circle is sampled into short linear keyframes; the ease applies to the angle). Add `dollyZoom: { from, to }` and `fov` animates while the radius follows it, so the `z = 0` plane keeps its size and only the perspective changes. Under a dolly zoom everything with `z > 0` balloons toward the viewer: keep the hero content at `z = 0` and near content at small `z`; far layers are pulled toward the centre, so spread far cards to the outer x positions and near cards inward, and leave ~10 % margin for the orbit sweep.
|
|
446
|
+
|
|
447
|
+
```js
|
|
448
|
+
// @recipe camera-orbit
|
|
449
|
+
const card = (x, z, color) => ({
|
|
450
|
+
type: 'shape', shape: 'rect', width: 300, height: 200, cornerRadius: 20, threeD: true,
|
|
451
|
+
initial: { x, y: 360, z, fillColor: color },
|
|
452
|
+
});
|
|
453
|
+
return [
|
|
454
|
+
orbit({ duration: 6, degrees: 50, dollyZoom: { from: 38, to: 62 } }), // options: radius (not with dollyZoom), center, start, fov, ease
|
|
455
|
+
card(240, -300, '#3a6ea5'), card(640, 0, '#d96a3a'), card(990, 60, '#38a169'), // near cards stay at small z
|
|
456
|
+
];
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
A call-to-action or any overlay that must stay screen-aligned should be a plain 2D layer (no `threeD`) placed last: it ignores the camera and stays on top. Fade the 3D scene and headline out before it appears; a dim rect alone leaves them visible.
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## A card in depth (a composition with `threeD: true`)
|
|
464
|
+
|
|
465
|
+
A `composition` with `threeD: true` is a card: its children are drawn into one texture that is then moved in depth, so a rect, an icon and a label rotate and scale together. `threeD` layers at the same `z` keep array order. Entrance from depth, then a gentle bob with `repeat` / `yoyo`.
|
|
466
|
+
|
|
467
|
+
```js
|
|
468
|
+
// @recipe depth-cards
|
|
469
|
+
const card = (title, color, x, y, z, at) => ({
|
|
470
|
+
type: 'composition', name: title, at, width: 300, height: 190, threeD: true,
|
|
471
|
+
initial: { x, y, z, pivotX: 150, pivotY: 95 }, // pivot = the centre, so x,y is where the centre sits
|
|
472
|
+
sequences: [
|
|
473
|
+
{ type: 'shape', shape: 'rect', width: 300, height: 190, cornerRadius: 24, initial: { x: 150, y: 95, fillColor: color } },
|
|
474
|
+
{ type: 'text', text: title, style: { fontSize: 34, fontWeight: 'bold', fill: '#ffffff' }, initial: { x: 150, y: 95, anchorX: 0.5, anchorY: 0.5 } },
|
|
475
|
+
],
|
|
476
|
+
keyframes: [
|
|
477
|
+
{ at: 0, from: { alpha: 0, z: z - 400, rotationY: 55 }, to: { alpha: 1, z, rotationY: 0 }, duration: 0.9, ease: 'expo.out' },
|
|
478
|
+
{ at: 1, to: { y: y - 14 }, duration: 1.2, ease: 'sine.inOut', repeat: 3, yoyo: true },
|
|
479
|
+
],
|
|
480
|
+
});
|
|
481
|
+
return [
|
|
482
|
+
{ type: 'camera', initial: { fov: 42 } },
|
|
483
|
+
card('Battery', '#3a6ea5', 220, 300, -280, 0.2), // far cards at the outer x
|
|
484
|
+
card('Sound', '#d96a3a', 1060, 260, -200, 0.4),
|
|
485
|
+
card('Comfort', '#38a169', 640, 380, 0, 0.6),
|
|
486
|
+
];
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## Cut to the next scene with an expanding circle
|
|
492
|
+
|
|
493
|
+
The outgoing scene must stay alive until the covering shape has finished; the next scene starts when the flood is complete. Reach the far corners: radius ≥ half the diagonal (734 px at 720p). `expo.in` stays tiny until the very end — use `power2.in`.
|
|
494
|
+
|
|
495
|
+
```js
|
|
496
|
+
// @recipe scene-flood
|
|
497
|
+
const FLOOD_AT = 3, FLOOD = 0.6, END = 6;
|
|
498
|
+
return [
|
|
499
|
+
{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', duration: FLOOD_AT + FLOOD, initial: { x: 'GW/2', y: 'GH/2', fillColor: '#1d2b53' } },
|
|
500
|
+
{ type: 'text', text: 'BEFORE', duration: FLOOD_AT + FLOOD, style: { fontSize: 160, fontWeight: '900', fill: '#ffffff' }, initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 } },
|
|
501
|
+
{ type: 'shape', shape: 'circle', radius: 0, at: FLOOD_AT, duration: END - FLOOD_AT, initial: { x: 'GW/2', y: 'GH/2', fillColor: '#ffd166' },
|
|
502
|
+
keyframes: [{ at: 0, to: { radius: 820 }, duration: FLOOD, ease: 'power2.in' }] },
|
|
503
|
+
{ type: 'text', text: 'AFTER', at: FLOOD_AT + FLOOD, duration: END - FLOOD_AT - FLOOD, style: { fontSize: 160, fontWeight: '900', fill: '#1d2b53' }, initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5, alpha: 0 },
|
|
504
|
+
keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.25 }] },
|
|
505
|
+
];
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
---
|
|
509
|
+
|
|
510
|
+
## Slideshow with transitions and background music
|
|
511
|
+
|
|
512
|
+
Scene `i` starts at `i × (L − T)` and overlaps the next by the transition length `T`; the transition's `at` is the next scene's start; total = `n × (L − T) + T`. Transitions and `kenBurns` combine freely. Captions are top-level layers (transitions never touch layers they do not name); keep them above the bottom ~60 px. `bgm.mp3` is only 6 s: **`loop: true`** or it falls silent.
|
|
513
|
+
|
|
514
|
+
```js
|
|
515
|
+
// @recipe slideshow
|
|
516
|
+
const photos = ['p1', 'p2', 'p3'], L = 4, T = 1, STEP = L - T;
|
|
517
|
+
const kinds = ['crossfade', 'wipe', 'zoom'];
|
|
518
|
+
const total = photos.length * STEP + T;
|
|
519
|
+
const sequences = photos.map((asset, i) =>
|
|
520
|
+
kenBurns({ asset, name: 'scene' + i, at: i * STEP, duration: L, motion: i % 2 ? 'position' : 'scale' }));
|
|
521
|
+
const transitions = photos.slice(1).map((_, i) => ({
|
|
522
|
+
kind: kinds[i % kinds.length], from: 'scene' + i, to: 'scene' + (i + 1), at: (i + 1) * STEP, duration: T,
|
|
523
|
+
...(kinds[i % kinds.length] === 'wipe' ? { direction: 'left' } : {}),
|
|
524
|
+
}));
|
|
525
|
+
photos.forEach((_, i) => sequences.push(withFade({
|
|
526
|
+
type: 'text', text: 'Scene ' + (i + 1), at: i * STEP + 0.5, duration: L - 1.5,
|
|
527
|
+
style: { fontSize: 44, fill: '#ffffff', dropShadow: { color: '#000000', blur: 6, distance: 2, alpha: 0.7 } },
|
|
528
|
+
initial: { x: 'GW/2', y: 600, anchorX: 0.5, anchorY: 0.5 },
|
|
529
|
+
}, { in: 0.4, out: 0.4 })));
|
|
530
|
+
sequences.push({ type: 'audio', asset: 'bgm', loop: true, volume: 0, duration: total,
|
|
531
|
+
keyframes: [{ at: 0, to: { volume: 0.8 }, duration: 2 }, { at: -2, to: { volume: 0 }, duration: 2 }] });
|
|
532
|
+
return { sequences, transitions, duration: total };
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Source images should be at least canvas-sized (`kenBurns` zooms in): draw generated images at 1920×1080 for a 1280×720 movie.
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## Looping motion
|
|
540
|
+
|
|
541
|
+
`repeat` (a finite count) and `yoyo` go on any keyframe. Total time = `duration × (repeat + 1)`.
|
|
542
|
+
|
|
543
|
+
```js
|
|
544
|
+
// @recipe pulse
|
|
545
|
+
return [{
|
|
546
|
+
type: 'shape', shape: 'circle', radius: 40, duration: 6,
|
|
547
|
+
initial: { x: 'GW/2', y: 'GH/2', fillColor: '#ff3b3b' },
|
|
548
|
+
keyframes: [{ at: 0, to: { scale: 1.15 }, duration: 0.5, ease: 'sine.inOut', repeat: 11, yoyo: true }], // 12 plays x 0.5 s = 6 s
|
|
549
|
+
}];
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
---
|
|
553
|
+
|
|
554
|
+
## Particles (seeded, so every run and export is identical)
|
|
555
|
+
|
|
556
|
+
```js
|
|
557
|
+
// @recipe particles
|
|
558
|
+
let seed = 7;
|
|
559
|
+
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
|
|
560
|
+
return Array.from({ length: 40 }, () => {
|
|
561
|
+
const x = rnd() * 1280, y = 100 + rnd() * 620;
|
|
562
|
+
return {
|
|
563
|
+
type: 'shape', shape: 'circle', radius: 2 + rnd() * 4, duration: 6,
|
|
564
|
+
initial: { x, y, fillColor: '#ffffff', fillAlpha: 0.3 + rnd() * 0.5 },
|
|
565
|
+
keyframes: [{ at: 0, to: { y: y - 80 }, duration: 6, ease: 'none' }],
|
|
566
|
+
};
|
|
567
|
+
});
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
---
|
|
571
|
+
|
|
572
|
+
## Gradients and vignettes
|
|
573
|
+
|
|
574
|
+
`fillGradient` fills a shape with a linear or radial gradient (positions are 0–1 of the shape's own bounds; colours can have alpha). A radial gradient from transparent to dark, on a full-screen rect placed last, is a vignette.
|
|
575
|
+
|
|
576
|
+
```js
|
|
577
|
+
// @recipe gradient-background
|
|
578
|
+
return [
|
|
579
|
+
{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2' },
|
|
580
|
+
fillGradient: { stops: [[0, '#1b2a6b'], [0.6, '#7b3fe4'], [1, '#ff6a88']] } }, // top → bottom
|
|
581
|
+
{ type: 'shape', shape: 'rect', width: 520, height: 200, cornerRadius: 30, initial: { x: 'GW/2', y: 'GH/2' },
|
|
582
|
+
fillGradient: { angle: 0, stops: [[0, '#00f5a0'], [1, '#00d9f5']] } }, // left → right
|
|
583
|
+
{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2' },
|
|
584
|
+
fillGradient: { type: 'radial', radius: 0.75, stops: [[0.45, 'rgba(0,0,0,0)'], [1, 'rgba(0,0,0,0.7)']] } }, // vignette, last = on top
|
|
585
|
+
];
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
---
|
|
589
|
+
|
|
590
|
+
## Generated placeholder images (no photos available)
|
|
591
|
+
|
|
592
|
+
Draw on a canvas and register `canvas.toDataURL()` as an asset (`data:` URLs work). Make the image at least canvas-sized (1920×1080 for a 1280×720 movie) so `kenBurns` zooms stay sharp.
|
|
593
|
+
|
|
594
|
+
```js
|
|
595
|
+
// @docs-only
|
|
596
|
+
function placeholderPhoto(name, hueA, hueB, w = 1920, h = 1080) {
|
|
597
|
+
const c = Object.assign(document.createElement('canvas'), { width: w, height: h });
|
|
598
|
+
const g = c.getContext('2d');
|
|
599
|
+
const grad = g.createLinearGradient(0, 0, w, h);
|
|
600
|
+
grad.addColorStop(0, `hsl(${hueA} 70% 45%)`); grad.addColorStop(1, `hsl(${hueB} 70% 25%)`);
|
|
601
|
+
g.fillStyle = grad; g.fillRect(0, 0, w, h);
|
|
602
|
+
return { name, src: c.toDataURL() };
|
|
603
|
+
}
|
|
604
|
+
// await movie.init({ assets: [placeholderPhoto('p1', 210, 280), placeholderPhoto('p2', 10, 60)], composition: { … } })
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## Metallic three.js object
|
|
610
|
+
|
|
611
|
+
`ctx.renderer` is a normal `WebGLRenderer`, so an environment map works; lights alone make `metalness: 1` look black. `three/addons` is not in the importmap: build the environment yourself. Size the layer to the area you want (it clips at its own rectangle) and pull the camera back (`z ≈ 5.4` for a radius-1 torus knot at fov 50).
|
|
612
|
+
|
|
613
|
+
```js
|
|
614
|
+
// @docs-only
|
|
615
|
+
// registerThree(); const THREE = await import('three');
|
|
616
|
+
three({
|
|
617
|
+
type: 'three', width: 'GW * 0.56', height: 'GH * 0.9', initial: { x: 'GW * 0.77', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
|
|
618
|
+
setup: (ctx) => {
|
|
619
|
+
const env = new THREE.Scene();
|
|
620
|
+
for (const [x, y, z] of [[4, 4, 4], [-4, 2, -4], [0, -4, 4]]) {
|
|
621
|
+
const lamp = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshBasicMaterial({ color: 0xffffff }));
|
|
622
|
+
lamp.position.set(x, y, z); env.add(lamp);
|
|
623
|
+
}
|
|
624
|
+
ctx.scene.environment = new THREE.PMREMGenerator(ctx.renderer).fromScene(env).texture;
|
|
625
|
+
const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(1, 0.32, 128, 32), new THREE.MeshStandardMaterial({ color: 0xcfd8ff, metalness: 1, roughness: 0.25 }));
|
|
626
|
+
ctx.scene.add(knot);
|
|
627
|
+
ctx.camera.position.z = 5.4;
|
|
628
|
+
return { objects: { knot } };
|
|
629
|
+
},
|
|
630
|
+
keyframes: [{ at: 0, to: { 'three.knot.rotation.y': Math.PI * 2 }, duration: 6 }],
|
|
631
|
+
})
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
<!-- ===== docs/dsl.md ===== -->
|
|
636
|
+
|
|
637
|
+
# DSL Reference
|
|
638
|
+
|
|
639
|
+
This document describes the declarative composition spec that you pass to `Movie.init({ composition })`. Every shape is exported as a TypeScript type from `pixi-effects` for editor autocomplete.
|
|
640
|
+
|
|
641
|
+
- [Composition](#composition)
|
|
642
|
+
- [Sequences](#sequences)
|
|
643
|
+
- [3D layers & camera](#3d-layers--camera)
|
|
644
|
+
- [Assets](#assets)
|
|
645
|
+
- [Keyframes](#keyframes)
|
|
646
|
+
- [Expressions](#expressions)
|
|
647
|
+
- [Filters](#filters)
|
|
648
|
+
|
|
649
|
+
---
|
|
650
|
+
|
|
651
|
+
## Composition
|
|
652
|
+
|
|
653
|
+
A composition is a tree node that has a width, a height, a duration, and a list of child sequences. The root composition is what you pass to `Movie.init({ composition })`. Nested compositions appear inside a parent as a `{ type: 'composition' }` sequence.
|
|
654
|
+
|
|
655
|
+
```ts
|
|
656
|
+
interface CompositionSpec {
|
|
657
|
+
width?: number; // pixels; defaults to Movie width
|
|
658
|
+
height?: number; // pixels; defaults to Movie height
|
|
659
|
+
duration?: number; // seconds; defaults to Movie duration
|
|
660
|
+
sequences?: SequenceSpec[];
|
|
661
|
+
// (also: name, at, initial, keyframes, filters — same as SequenceCommon below)
|
|
662
|
+
}
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
Children of a nested composition use that composition's local coordinate system. `W`/`H` in the [scope](#expressions) refer to the **immediate parent**; `GW`/`GH` always refer to the root.
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## Sequences
|
|
670
|
+
|
|
671
|
+
Every sequence shares this base shape:
|
|
672
|
+
|
|
673
|
+
```ts
|
|
674
|
+
interface SequenceCommon {
|
|
675
|
+
name?: string; // optional id, used for cross-references and debugging
|
|
676
|
+
at?: number; // start time in seconds, measured from the start of the PARENT composition (default 0)
|
|
677
|
+
duration?: number; // seconds; defaults to the parent's duration
|
|
678
|
+
initial?: Props; // properties applied before any keyframes evaluate
|
|
679
|
+
keyframes?: Keyframe[];
|
|
680
|
+
filters?: FilterSpec[];
|
|
681
|
+
}
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
### Timing at a glance
|
|
685
|
+
|
|
686
|
+
| What | Measured from |
|
|
687
|
+
|---|---|
|
|
688
|
+
| a sequence's `at` | the start of its **parent composition** (the root composition starts at 0) |
|
|
689
|
+
| a **keyframe's** `at` | the start of **its own sequence** (After Effects style): `at: 0` = the moment the layer appears. **Negative = back from the sequence's end** (`-0.5` = 0.5 s before it ends) |
|
|
690
|
+
| a transition's `at` | the start of the **parent composition** (like a sequence's `at`) |
|
|
691
|
+
| `audio` volume keyframes | the start of their own sequence (same rule as every other keyframe) |
|
|
692
|
+
|
|
693
|
+
### Anchors and origins
|
|
694
|
+
|
|
695
|
+
| Layer | Where `x, y` land by default | To change it |
|
|
696
|
+
|---|---|---|
|
|
697
|
+
| `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` |
|
|
698
|
+
| `shape` line / polygon / path | the centre of the shape's bounds | — |
|
|
699
|
+
| `text`, `image`, `video` | the **top-left** corner | `anchorX` / `anchorY: 0.5` to centre |
|
|
700
|
+
| `composition` | its top-left corner | `pivotX` / `pivotY` = the point that sits at `x, y` and that rotation / scale turn about |
|
|
701
|
+
|
|
702
|
+
Shape geometry (`width height radius anchorX …`) may be written at the top level or in `initial`; the top level wins. `rotation`, `skew*` and `rotationX/Y` are in **degrees**.
|
|
703
|
+
|
|
704
|
+
A layer is **not removed** when its lifespan `[at, at + duration)` ends: it is only hidden, and it keeps its last animated values. Outside the lifespan it is invisible. **z-order is array order** (later = on top). Build scenes by giving layers `at` / `duration` and stacking them; a full-screen background rect in each scene hides what is below it.
|
|
705
|
+
|
|
706
|
+
`Props` is `Record<string, number | string>`. String values are evaluated as [expressions](#expressions) unless the prop is a textual one (e.g. `fill`, `fontFamily`).
|
|
707
|
+
|
|
708
|
+
### `text`
|
|
709
|
+
|
|
710
|
+
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 cannot change over time, with one exception: a `{value}` placeholder in `text` prints an animatable number (`text: '{value} users'`, `initial: { value: 0 }`, a keyframe `to: { value: 2480 }`; `format: { decimals, grouping }`) — that is how counters work. Any other change of wording is a second layer (stack layers with `at` / `duration`). 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.
|
|
711
|
+
|
|
712
|
+
```ts
|
|
713
|
+
{
|
|
714
|
+
type: 'text',
|
|
715
|
+
text: 'hello',
|
|
716
|
+
initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
|
|
717
|
+
keyframes: [
|
|
718
|
+
{ at: 0, from: { alpha: 0 }, to: { alpha: 1 }, duration: 0.5 },
|
|
719
|
+
],
|
|
720
|
+
style: {
|
|
721
|
+
fontSize: 'GW * 0.05', // expression OK
|
|
722
|
+
fill: '#ffffff', // verbatim string
|
|
723
|
+
fontWeight: 'bold',
|
|
724
|
+
fontFamily: 'Inter',
|
|
725
|
+
},
|
|
726
|
+
}
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
The text's `fill` is also animatable via keyframes (under the `to`/`from`/`set` keys, not under `style`). Set `colorSpace` for perceptual interpolation:
|
|
730
|
+
|
|
731
|
+
```ts
|
|
732
|
+
{
|
|
733
|
+
type: 'text', text: 'COLORSPACE',
|
|
734
|
+
colorSpace: 'oklch',
|
|
735
|
+
style: { fill: '#ff0000', fontSize: 48, fontWeight: 'bold' },
|
|
736
|
+
keyframes: [
|
|
737
|
+
{ at: 1, to: { fill: '#00ff00' }, duration: 2, ease: 'sine.inOut' },
|
|
738
|
+
],
|
|
739
|
+
}
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
#### Counters (`{value}`)
|
|
743
|
+
|
|
744
|
+
A text layer has one animatable number, `value`, printed wherever the text contains `{value}`. Animate it with the normal keyframes (any ease, `from`/`to`/`set`, `repeat`):
|
|
745
|
+
|
|
746
|
+
```ts
|
|
747
|
+
{
|
|
748
|
+
type: 'text', text: '{value} users', format: { decimals: 0, grouping: true }, // → "2,480 users"
|
|
749
|
+
initial: { value: 0 },
|
|
750
|
+
keyframes: [{ at: 1, to: { value: 2480 }, duration: 1.2, ease: 'power3.out' }],
|
|
751
|
+
style: { fontSize: 72, fill: '#fff' }, ...
|
|
752
|
+
}
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
`format`: `decimals` (default 0) and thousands `grouping` (default false). Put prefixes and suffixes in the text (`'${value}'`, `'{value}%'`). If `value` is animated but the text has no `{value}`, a warning says so.
|
|
756
|
+
|
|
757
|
+
### `image`
|
|
758
|
+
|
|
759
|
+
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.
|
|
760
|
+
|
|
761
|
+
```ts
|
|
762
|
+
{
|
|
763
|
+
type: 'image',
|
|
764
|
+
asset: 'logo',
|
|
765
|
+
initial: { x: 'GW/2 - w/2', y: 'GH/2 - h/2' },
|
|
766
|
+
}
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
`tint` is animatable via keyframes. Optionally set `colorSpace` to interpolate the tint perceptually:
|
|
770
|
+
|
|
771
|
+
```ts
|
|
772
|
+
{
|
|
773
|
+
type: 'image', asset: 'logo',
|
|
774
|
+
colorSpace: 'oklch', // smooth hue sweep instead of muddy sRGB
|
|
775
|
+
initial: { tint: '#ff0000' },
|
|
776
|
+
keyframes: [
|
|
777
|
+
{ at: 1, to: { tint: '#00ff00' }, duration: 2, ease: 'sine.inOut' },
|
|
778
|
+
],
|
|
779
|
+
}
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
### `video`
|
|
783
|
+
|
|
784
|
+
Renders a video. Intrinsic `w`/`h` come from the video's natural size. `currentTime` is driven by the timeline (so seeking the Movie scrubs the video).
|
|
785
|
+
|
|
786
|
+
```ts
|
|
787
|
+
{
|
|
788
|
+
type: 'video',
|
|
789
|
+
asset: 'green',
|
|
790
|
+
loop?: false, // loop back to start when finished (default false)
|
|
791
|
+
audio?: true, // route audio track into the mix (default true)
|
|
792
|
+
volume?: 1, // initial volume 0..1 (animatable via volume keyframes)
|
|
793
|
+
initial: { x: 0, y: 0, scale: 'cover' },
|
|
794
|
+
}
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
Use `scale: 'cover'` or `scale: 'contain'` (these resolve via the [scope](#expressions)) to fit the video to the parent composition.
|
|
798
|
+
|
|
799
|
+
### `audio`
|
|
800
|
+
|
|
801
|
+
Audio-only sequence. No visual. Volume is animatable via keyframes.
|
|
802
|
+
|
|
803
|
+
```ts
|
|
804
|
+
{
|
|
805
|
+
type: 'audio',
|
|
806
|
+
asset: 'bgm',
|
|
807
|
+
loop?: false,
|
|
808
|
+
volume: 0,
|
|
809
|
+
keyframes: [
|
|
810
|
+
{ at: 0, to: { volume: 0.6 }, duration: 1 }, // fade in
|
|
811
|
+
{ at: -1, to: { volume: 0 }, duration: 1 }, // fade out (negative `at` = relative to end)
|
|
812
|
+
],
|
|
813
|
+
}
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
Audio is mixed during `Movie.init()` and during `Movie.render()`. Volume keyframes interpolate linearly.
|
|
817
|
+
|
|
818
|
+
### `composition`
|
|
819
|
+
|
|
820
|
+
Nested composition. Same shape as the root spec but with `type: 'composition'` and an explicit `width`/`height`. Children animate within the local coordinate system; the composition itself can be positioned, scaled, and rotated as a unit.
|
|
821
|
+
|
|
822
|
+
```ts
|
|
823
|
+
{
|
|
824
|
+
type: 'composition',
|
|
825
|
+
width: 600, height: 120,
|
|
826
|
+
initial: { x: 'GW/2 - 300', y: 'GH * 0.86' },
|
|
827
|
+
keyframes: [
|
|
828
|
+
{ at: 2.4, from: { alpha: 0, rotation: -12 },
|
|
829
|
+
to: { alpha: 1, rotation: 0 },
|
|
830
|
+
duration: 0.7, ease: 'elastic.out(1, 0.5)' },
|
|
831
|
+
],
|
|
832
|
+
sequences: [
|
|
833
|
+
{ type: 'text', text: 'inside', initial: { x: 300, y: 60, anchorX: 0.5, anchorY: 0.5 }, style: { fontSize: 28, fill: '#fff' } },
|
|
834
|
+
],
|
|
835
|
+
}
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
### `shape`
|
|
839
|
+
|
|
840
|
+
Parametric primitive backed by PIXI v8 `Graphics`. Six kinds, discriminated by `shape`. All geometry props accept the [expression language](#expressions), so dimensions can follow the canvas:
|
|
841
|
+
|
|
842
|
+
```ts
|
|
843
|
+
// Centered rounded panel that fills 80% of the canvas
|
|
844
|
+
{
|
|
845
|
+
type: 'shape', shape: 'rect',
|
|
846
|
+
width: 'W * 0.8', height: 'H * 0.6', cornerRadius: 24,
|
|
847
|
+
initial: {
|
|
848
|
+
x: 'W/2', y: 'H/2',
|
|
849
|
+
fillColor: '#1a2640', fillAlpha: 0.85,
|
|
850
|
+
strokeColor: '#3a5680', strokeWidth: 2,
|
|
851
|
+
},
|
|
852
|
+
}
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
Every primitive draws centred on its local origin (so `anchorX`/`anchorY` and `pivotX`/`pivotY` semantics line up with the other sequence types).
|
|
856
|
+
|
|
857
|
+
| `shape` | Required props | Optional |
|
|
858
|
+
|-----------|------------------------------------------|------------------------|
|
|
859
|
+
| `rect` | `width`, `height` | `cornerRadius` |
|
|
860
|
+
| `circle` | `radius` | — |
|
|
861
|
+
| `ellipse` | `radiusX`, `radiusY` | — |
|
|
862
|
+
| `line` | `from: [x,y]`, `to: [x,y]` (canvas coordinates; stroke in `initial`) | — |
|
|
863
|
+
| `polygon` | `points: [[x,y], …]` | `open` (default false) |
|
|
864
|
+
| `path` | `d` (SVG path data) | — |
|
|
865
|
+
|
|
866
|
+
**Style** is set on `initial` and animatable via keyframes:
|
|
867
|
+
|
|
868
|
+
| Key | Notes |
|
|
869
|
+
|---------------|--------------------------------------------------------------|
|
|
870
|
+
| `fillColor` | Hex string (`'#3399ff'`) or number (`0x3399ff`). Omit = no fill. |
|
|
871
|
+
| `fillAlpha` | 0..1, default 1 |
|
|
872
|
+
| `strokeColor` | Hex string or number. Requires `strokeWidth > 0` to render. |
|
|
873
|
+
| `strokeAlpha` | 0..1, default 1 |
|
|
874
|
+
| `strokeWidth` | Pixels. Default 0 (no stroke). |
|
|
875
|
+
|
|
876
|
+
Colour keys (`fillColor`, `strokeColor`) tween smoothly between hues — no snap at the end. Numeric keys (`fillAlpha` / `strokeAlpha` / `strokeWidth`) animate linearly.
|
|
877
|
+
|
|
878
|
+
#### `fillGradient`
|
|
879
|
+
|
|
880
|
+
Fill a shape with a gradient instead of `fillColor` (top level or in `initial`). Positions are in 0–1 of the shape's own bounds, so the gradient follows the shape's size. Colours may carry alpha, so a radial gradient from transparent to dark is a vignette. Not animatable.
|
|
881
|
+
|
|
882
|
+
```ts
|
|
883
|
+
{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2' },
|
|
884
|
+
fillGradient: { stops: [[0, '#1b2a6b'], [0.6, '#7b3fe4'], [1, '#ff6a88']] } } // linear, top → bottom
|
|
885
|
+
{ ..., fillGradient: { angle: 0, stops: [[0, '#00f5a0'], [1, '#00d9f5']] } } // left → right
|
|
886
|
+
{ ..., fillGradient: { type: 'radial', radius: 0.75, stops: [[0.45, 'rgba(0,0,0,0)'], [1, 'rgba(0,0,0,0.7)']] } } // vignette
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
| Field | Notes |
|
|
890
|
+
|---|---|
|
|
891
|
+
| `type` | `'linear'` (default) or `'radial'` |
|
|
892
|
+
| `stops` | at least two: `[offset 0–1, colour]` or `{ offset, color }` |
|
|
893
|
+
| `angle` | linear: degrees, `0` = left → right, `90` = top → bottom (default) |
|
|
894
|
+
| `center`, `innerRadius`, `radius` | radial: centre in 0–1 (default `[0.5, 0.5]`), inner radius (default 0), outer radius (default 0.5); an ellipse on a non-square shape |
|
|
895
|
+
|
|
896
|
+
#### `colorSpace`
|
|
897
|
+
|
|
898
|
+
Per-shape choice of how colour keyframes are interpolated. Default `'rgb'` (linear sRGB lerp via `gsap.utils.interpolate`) is fast but classic — a red → green ramp passes through muddy olive at the midpoint. The two perceptually uniform options keep saturation through the transition:
|
|
899
|
+
|
|
900
|
+
| Value | Behaviour |
|
|
901
|
+
|-----------|---------------------------------------------------------------------------------------------------|
|
|
902
|
+
| `'rgb'` | Default. Linear sRGB lerp. |
|
|
903
|
+
| `'oklab'` | Straight line in OKLab's chromaticity plane. Brighter, more chromatic midpoints. |
|
|
904
|
+
| `'oklch'` | (L, C, h) with hue along the shorter angular path. Smooth rainbow-style sweeps; ideal for hue cycling. |
|
|
905
|
+
|
|
906
|
+
```ts
|
|
907
|
+
{
|
|
908
|
+
type: 'shape', shape: 'circle', radius: 40,
|
|
909
|
+
colorSpace: 'oklch', // ← red → green via vibrant orange
|
|
910
|
+
initial: { fillColor: '#ff0000' },
|
|
911
|
+
keyframes: [
|
|
912
|
+
{ at: 1, to: { fillColor: '#00ff00' }, duration: 2, ease: 'sine.inOut' },
|
|
913
|
+
],
|
|
914
|
+
}
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
```ts
|
|
918
|
+
{
|
|
919
|
+
type: 'shape', shape: 'circle', radius: 40,
|
|
920
|
+
initial: { x: 'W/2', y: 'H/2', fillColor: '#ff5577' },
|
|
921
|
+
keyframes: [
|
|
922
|
+
{ at: 1, to: { fillColor: '#55ddaa' }, duration: 1.0, ease: 'sine.inOut' },
|
|
923
|
+
{ at: 2, to: { strokeColor: '#fff', strokeWidth: 6 }, duration: 0.5 },
|
|
924
|
+
],
|
|
925
|
+
}
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
**Transforms animate normally.** `x`, `y`, `scale`, `scaleX`, `scaleY`, `rotation`, `alpha` etc. all go through the standard keyframe pipeline.
|
|
929
|
+
|
|
930
|
+
```ts
|
|
931
|
+
// SVG-path heart that scale-pops on entry, then beats twice
|
|
932
|
+
{
|
|
933
|
+
type: 'shape', shape: 'path',
|
|
934
|
+
d: 'M 0 -20 C -30 -50 -70 -10 0 30 C 70 -10 30 -50 0 -20 Z',
|
|
935
|
+
initial: { x: 'W/2', y: 'H/2', fillColor: '#ff3366' },
|
|
936
|
+
keyframes: [
|
|
937
|
+
{ at: 0, from: { alpha: 0, scale: 0 },
|
|
938
|
+
to: { alpha: 1, scale: 1 },
|
|
939
|
+
duration: 0.5, ease: 'back.out(2.5)' },
|
|
940
|
+
{ at: 1.5, to: { scale: 1.2 }, duration: 0.3, ease: 'power2.out' },
|
|
941
|
+
{ at: 1.8, to: { scale: 1.0 }, duration: 0.3, ease: 'power2.in' },
|
|
942
|
+
],
|
|
943
|
+
}
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
**Scalar geometry is animatable.** `width`, `height`, `cornerRadius` (rect), `radius` (circle), `radiusX` / `radiusY` (ellipse) all flow through the same per-frame redraw and can be tweened via keyframes — useful for progress bars (animate `width`), pulsing icons (animate `radius`), or shape-morph callouts. Array geometry (polygon `points`, line endpoints, path `d`) is baked at build time; use `scale` / `scaleX` / `scaleY` for those.
|
|
947
|
+
|
|
948
|
+
`anchorX` / `anchorY` (default `0.5` each) control which point on the bbox sits at the local origin — `0` is left/top, `1` is right/bottom. They're animatable too. Critical for "grow from one edge" effects:
|
|
949
|
+
|
|
950
|
+
```ts
|
|
951
|
+
// Left-anchored progress bar — width animates 0 → W, left edge stays at x
|
|
952
|
+
{
|
|
953
|
+
type: 'shape', shape: 'rect', width: 0, height: 18, cornerRadius: 9,
|
|
954
|
+
anchorX: 0, // ← left edge at x
|
|
955
|
+
initial: { x: 0, y: 'H/2', fillColor: '#5599ff' },
|
|
956
|
+
keyframes: [
|
|
957
|
+
{ at: 0, to: { width: 'W' }, duration: 4, ease: 'sine.inOut' },
|
|
958
|
+
],
|
|
959
|
+
}
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
---
|
|
963
|
+
|
|
964
|
+
## 3D layers & camera
|
|
965
|
+
|
|
966
|
+
Place any 2D layer in depth and view it through a camera — After Effects style, no three.js. Everything uses the normal `initial` / `keyframes` / expression vocabulary.
|
|
967
|
+
|
|
968
|
+
### Conventions (read this first)
|
|
969
|
+
|
|
970
|
+
| Thing | Convention |
|
|
971
|
+
|---|---|
|
|
972
|
+
| Axes | `+x` right, `+y` down, **`+z` toward the viewer** (like CSS `translateZ`). Bigger `z` = nearer = larger on screen. |
|
|
973
|
+
| Units | Positions in composition pixels. **Rotations in degrees.** |
|
|
974
|
+
| Rotation signs | Same as CSS: `rotationY: 30` swings the right edge away; `rotationX: 30` brings the bottom edge toward you; `rotation` is the Z axis. |
|
|
975
|
+
| Rotation centre | The same point 2D `rotation` uses: `anchorX/anchorY` (sprites, text, shapes) or `pivotX/pivotY` (compositions). Set `anchorX: 0.5, anchorY: 0.5` to spin about the centre. |
|
|
976
|
+
| Default camera | Centred, looking straight at the `z = 0` plane, which maps 1:1 to pixels. A `threeD` layer at `z = 0` looks identical to a 2D layer. |
|
|
977
|
+
| Camera props | `x`, `y`, `z`, `lookAtX`, `lookAtY`, `lookAtZ`, `fov` — all in `initial` / `keyframes`, never on the camera itself. |
|
|
978
|
+
|
|
979
|
+
### `threeD` layers
|
|
980
|
+
|
|
981
|
+
Add `threeD: true` to any visual layer, then use `z`, `rotationX`, `rotationY`:
|
|
982
|
+
|
|
983
|
+
```json
|
|
984
|
+
{
|
|
985
|
+
"sequences": [
|
|
986
|
+
{ "type": "text", "text": "tilted", "threeD": true,
|
|
987
|
+
"style": { "fontSize": 64, "fill": "#ffffff" },
|
|
988
|
+
"initial": { "x": "GW/2", "y": "GH/2", "anchorX": 0.5, "anchorY": 0.5, "rotationY": -35 },
|
|
989
|
+
"keyframes": [{ "at": 0, "to": { "rotationY": 0 }, "duration": 1, "ease": "power2.out" }] }
|
|
990
|
+
]
|
|
991
|
+
}
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
`z`, `rotationX` and `rotationY` are ignored on a layer without `threeD: true` (a warning tells you). `threeD: false` layers ignore the camera and keep stack order.
|
|
995
|
+
|
|
996
|
+
### Camera
|
|
997
|
+
|
|
998
|
+
The camera is a layer: `{ "type": "camera" }`. It has no visuals. With nothing set it is the default camera.
|
|
999
|
+
|
|
1000
|
+
| Prop | Meaning | Default |
|
|
1001
|
+
|---|---|---|
|
|
1002
|
+
| `x`, `y` | camera position | `W/2`, `H/2` |
|
|
1003
|
+
| `z` | camera depth. **Auto:** if you never set it, it follows `fov` so the `z = 0` plane stays 1:1 | `(H/2) / tan(fov/2)` |
|
|
1004
|
+
| `lookAtX`, `lookAtY`, `lookAtZ` | the point it looks at | `W/2`, `H/2`, `0` |
|
|
1005
|
+
| `fov` | vertical field of view, degrees (clamped to 1–179) | 40 |
|
|
1006
|
+
|
|
1007
|
+
```json
|
|
1008
|
+
{
|
|
1009
|
+
"sequences": [
|
|
1010
|
+
{ "type": "camera", "name": "cam",
|
|
1011
|
+
"keyframes": [
|
|
1012
|
+
{ "at": 0, "from": { "x": "GW/2 - 260", "lookAtX": "GW/2 - 260" },
|
|
1013
|
+
"to": { "x": "GW/2 + 260", "lookAtX": "GW/2 + 260" },
|
|
1014
|
+
"duration": 4, "ease": "sine.inOut" }
|
|
1015
|
+
] },
|
|
1016
|
+
{ "type": "shape", "shape": "rect", "width": 300, "height": 200, "threeD": true,
|
|
1017
|
+
"initial": { "x": "GW/2", "y": "GH/2", "z": -400, "fillColor": "#3a6ea5" } },
|
|
1018
|
+
{ "type": "shape", "shape": "rect", "width": 300, "height": 200, "threeD": true,
|
|
1019
|
+
"initial": { "x": "GW/2", "y": "GH/2", "z": 250, "fillColor": "#d96a3a" } }
|
|
1020
|
+
]
|
|
1021
|
+
}
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
Moving the camera sideways makes the near rectangle slide faster than the far one (parallax). Animating only `fov` keeps the `z = 0` plane fixed and changes how strong the perspective is (a dolly zoom):
|
|
1025
|
+
|
|
1026
|
+
```json
|
|
1027
|
+
{
|
|
1028
|
+
"sequences": [
|
|
1029
|
+
{ "type": "camera",
|
|
1030
|
+
"keyframes": [{ "at": 0, "to": { "fov": 70 }, "duration": 3, "ease": "sine.inOut" }] },
|
|
1031
|
+
{ "type": "shape", "shape": "circle", "radius": 120, "threeD": true,
|
|
1032
|
+
"initial": { "x": "GW/2", "y": "GH/2", "z": 300, "fillColor": "#38a169" } }
|
|
1033
|
+
]
|
|
1034
|
+
}
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
- A camera affects the `threeD` layers that are its **siblings** (same composition). A nested composition has its own camera for its children, and is itself a layer in its parent's space when it has `threeD: true`.
|
|
1038
|
+
- Several cameras may exist if their lifespans (`at` / `duration`) do not overlap — each is a camera cut. Overlapping cameras warn; the last-listed one wins.
|
|
1039
|
+
- With no camera layer the default camera is used.
|
|
1040
|
+
|
|
1041
|
+
### Depth order and limits
|
|
1042
|
+
|
|
1043
|
+
- Consecutive `threeD` layers are drawn farthest-first. A non-`threeD` layer between them splits the group (like After Effects).
|
|
1044
|
+
- A layer at or behind the camera is hidden for that frame.
|
|
1045
|
+
- v1 limits: planes do not intersect (whole layers are sorted); masks, transitions and `filterArea` are not supported on `threeD` layers (a mask is ignored with a warning); each `threeD` layer costs one extra render pass per frame.
|
|
1046
|
+
- Wrong-but-likely names (`rotateY`, `translateZ`, `depth`, `perspective`, `zoom`) are not accepted; the console tells you the right name.
|
|
1047
|
+
|
|
1048
|
+
---
|
|
1049
|
+
|
|
1050
|
+
## three
|
|
1051
|
+
|
|
1052
|
+
Composites a [three.js](https://threejs.org/) scene as a normal layer. Each frame, the scene is rendered into an offscreen WebGL canvas and uploaded into the sequence's PIXI texture — so once built, the layer is a plain sprite: 2D `initial` / keyframe props, [masks](#masks), and [filters](#filters) all apply to it exactly like any other sequence type.
|
|
1053
|
+
|
|
1054
|
+
`type: 'three'` lives in the optional `pixi-effects/three` entry, not core `pixi-effects` — see [API reference](./api.md#pixi-effectsthree) for install/import details.
|
|
1055
|
+
|
|
1056
|
+
```ts
|
|
1057
|
+
import { registerThree, three } from 'pixi-effects/three';
|
|
1058
|
+
import * as THREE from 'three';
|
|
1059
|
+
|
|
1060
|
+
registerThree(); // once, before Movie.init
|
|
1061
|
+
|
|
1062
|
+
three({
|
|
1063
|
+
type: 'three',
|
|
1064
|
+
name: 'knot',
|
|
1065
|
+
width: 'GW * 0.6', height: 'GH * 0.6',
|
|
1066
|
+
initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
|
|
1067
|
+
setup: (ctx) => {
|
|
1068
|
+
const knot = new THREE.Mesh(
|
|
1069
|
+
new THREE.TorusKnotGeometry(1, 0.35, 128, 32),
|
|
1070
|
+
new THREE.MeshStandardMaterial({ color: 0x7fb4ff }),
|
|
1071
|
+
);
|
|
1072
|
+
ctx.scene.add(knot);
|
|
1073
|
+
ctx.camera.position.z = 4;
|
|
1074
|
+
return { objects: { knot } }; // exposes `knot` to keyframes, see below
|
|
1075
|
+
},
|
|
1076
|
+
keyframes: [
|
|
1077
|
+
{ at: 0, to: { 'three.knot.rotation.y': Math.PI * 2 }, duration: 6 },
|
|
1078
|
+
],
|
|
1079
|
+
})
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
### Spec fields
|
|
1083
|
+
|
|
1084
|
+
| Field | Type | Notes |
|
|
1085
|
+
| ------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
|
|
1086
|
+
| `width?` | `PropValue` | layer size in composition px; expressions allowed (e.g. `'W * 0.5'`). Default: parent composition size. |
|
|
1087
|
+
| `height?` | `PropValue` | see `width`. |
|
|
1088
|
+
| `resolution?` | `number` | supersampling factor for the offscreen canvas. Default `1`. |
|
|
1089
|
+
| `setup` | `(ctx: ThreeContext) => Promise<ThreeSetupResult \| void> \| ThreeSetupResult \| void` | builds the scene once. Add objects to `ctx.scene`, position `ctx.camera` (or replace it via `{ camera }`). Async, so `Movie.init` can await GLTF / texture loading. |
|
|
1090
|
+
| `update?` | `(t: number, ctx: ThreeContext) => void` | optional per-frame hook; `t` is sequence-local time in seconds. |
|
|
1091
|
+
| `dispose?` | `(ctx: ThreeContext) => void` | cleanup for user-created GPU resources (geometries, materials, textures). Called from `destroy()`. |
|
|
1092
|
+
|
|
1093
|
+
`ThreeContext` is `{ scene, camera, renderer, width, height }` — the three.js `Scene`, `Camera`, `WebGLRenderer`, and the resolved layer size in composition px.
|
|
1094
|
+
|
|
1095
|
+
### Keyframe paths: `three.<name>.<path>`
|
|
1096
|
+
|
|
1097
|
+
Objects returned from `setup`'s `{ objects }` are addressable from keyframes as `three.<name>.<path>`, using the same `from`/`to`/`set` vocabulary as every other prop:
|
|
1098
|
+
|
|
1099
|
+
```ts
|
|
1100
|
+
setup: (ctx) => {
|
|
1101
|
+
const knot = new THREE.Mesh(/* ... */);
|
|
1102
|
+
ctx.scene.add(knot);
|
|
1103
|
+
return { objects: { knot } };
|
|
1104
|
+
},
|
|
1105
|
+
keyframes: [
|
|
1106
|
+
{ at: 0, to: { 'three.knot.rotation.y': Math.PI * 2 }, duration: 6 },
|
|
1107
|
+
{ at: 0, to: { 'three.knot.position.x': 1.5 }, duration: 3 },
|
|
1108
|
+
],
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
`camera` is exposed implicitly as `three.camera.<path>` unless `setup`'s `objects` already defines a `camera` key.
|
|
1112
|
+
|
|
1113
|
+
### Rules
|
|
1114
|
+
|
|
1115
|
+
1. Call `registerThree()` before `Movie.init` builds a composition containing a `type: 'three'` sequence — the sequence type is looked up by name at build time.
|
|
1116
|
+
2. `update(t)` must derive all state purely from `t` — no wall clock, no unseeded randomness. Playback and export both seek arbitrarily; anything else in `update` means identical timestamps can render different pixels.
|
|
1117
|
+
3. Each three sequence owns one WebGL context (an offscreen canvas + renderer). Browsers cap live WebGL contexts at roughly 8–16 — budget accordingly if a composition uses several three layers.
|
|
1118
|
+
4. Like `custom` filters, the spec carries functions (`setup`, optionally `update` / `dispose`) and is **not** JSON-serializable.
|
|
1119
|
+
5. The scene renders into the layer rectangle: anything that projects outside it is clipped at the layer's edges. Frame the camera with margin for the whole animation — rotating objects often project taller/wider than their resting pose (a `TorusKnotGeometry(radius, tube)`, for instance, extends to `1.5 * radius + tube`, not `radius`).
|
|
1120
|
+
|
|
1121
|
+
---
|
|
1122
|
+
|
|
1123
|
+
## Masks
|
|
1124
|
+
|
|
1125
|
+
> A mask is a layer in the **parent's** coordinate space and does **not** follow the layer it masks: slide the content and the mask separately. Reveal by growing the mask's `width` from a fixed edge (`anchorX: 0`); exit by tweening `x` and `width` together.
|
|
1126
|
+
|
|
1127
|
+
Any sequence can carry an inline `mask` — itself a full sequence — that shapes which pixels of the maskee are visible. The mask runs in the same coordinate space as the maskee (added to the same parent composition) and shares its lifetime, so a circular avatar crop is just:
|
|
1128
|
+
|
|
1129
|
+
```ts
|
|
1130
|
+
{
|
|
1131
|
+
type: 'image', asset: 'photo',
|
|
1132
|
+
initial: { x: 'W/2', y: 'H/2', anchorX: 0.5, anchorY: 0.5 },
|
|
1133
|
+
mask: {
|
|
1134
|
+
type: 'shape', shape: 'circle', radius: 130,
|
|
1135
|
+
initial: { x: 'W/2', y: 'H/2', fillColor: '#ffffff' },
|
|
1136
|
+
},
|
|
1137
|
+
}
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
The mask is itself a sequence, so it can have `keyframes` of its own — useful for reveal animations (a circle growing from `scale: 0` to full size, a rect wiping across, …):
|
|
1141
|
+
|
|
1142
|
+
```ts
|
|
1143
|
+
// Iris reveal — image is wiped in by a growing circle
|
|
1144
|
+
{
|
|
1145
|
+
type: 'image', asset: 'photo',
|
|
1146
|
+
initial: { x: 'W/2', y: 'H/2', anchorX: 0.5, anchorY: 0.5 },
|
|
1147
|
+
mask: {
|
|
1148
|
+
type: 'shape', shape: 'circle', radius: 200,
|
|
1149
|
+
initial: { x: 'W/2', y: 'H/2', fillColor: '#ffffff', scale: 0 },
|
|
1150
|
+
keyframes: [
|
|
1151
|
+
{ at: 0, to: { scale: 1 }, duration: 1.0, ease: 'power2.out' },
|
|
1152
|
+
],
|
|
1153
|
+
},
|
|
1154
|
+
}
|
|
1155
|
+
```
|
|
1156
|
+
|
|
1157
|
+
Notes:
|
|
1158
|
+
- The mask sequence is rendered as a mask, not as a normal child — its `fillColor` / `strokeColor` only matter for which pixels are kept, not for the visible colour.
|
|
1159
|
+
- Any sequence type works as a mask (shape / image / text / nested composition); shapes are the natural choice for geometric reveals.
|
|
1160
|
+
- For a left-to-right wipe, give the mask `anchorX: 0` (rect/circle/ellipse) so `width: 0 → full` grows rightward from the left edge.
|
|
1161
|
+
|
|
1162
|
+
#### `maskInverted`
|
|
1163
|
+
|
|
1164
|
+
When `true`, flip the mask sense: pixels INSIDE the mask shape become transparent, pixels OUTSIDE stay visible. Useful for knockout / cutout effects (punch a circular hole through a panel, etc.).
|
|
1165
|
+
|
|
1166
|
+
```ts
|
|
1167
|
+
// Photo with a circular hole punched through the middle
|
|
1168
|
+
{
|
|
1169
|
+
type: 'image', asset: 'photo',
|
|
1170
|
+
initial: { x: 'W/2', y: 'H/2', anchorX: 0.5, anchorY: 0.5 },
|
|
1171
|
+
maskInverted: true,
|
|
1172
|
+
mask: {
|
|
1173
|
+
type: 'shape', shape: 'circle', radius: 80,
|
|
1174
|
+
initial: { x: 'W/2', y: 'H/2', fillColor: '#ffffff' },
|
|
1175
|
+
keyframes: [
|
|
1176
|
+
{ at: 0, to: { radius: 120 }, duration: 1.5, ease: 'sine.inOut' },
|
|
1177
|
+
{ at: 1.5, to: { radius: 80 }, duration: 1.5, ease: 'sine.inOut' },
|
|
1178
|
+
],
|
|
1179
|
+
},
|
|
1180
|
+
}
|
|
1181
|
+
```
|
|
1182
|
+
|
|
1183
|
+
Routed through PIXI v8's native `setMask({ inverse: true })`.
|
|
1184
|
+
|
|
1185
|
+
#### `blendMode`
|
|
1186
|
+
|
|
1187
|
+
How a layer blends with what is behind it: `'normal'` (default), `'add'`, `'screen'` or `'multiply'`. `add` and `screen` brighten, so overlapping glows, halos and light leaks add up instead of covering each other; `multiply` darkens. It works on every layer type, on `threeD` layers, and on a composition (its children inherit the mode, each blending with what is behind it). Any other value warns and is ignored.
|
|
1188
|
+
|
|
1189
|
+
```ts
|
|
1190
|
+
// A soft white glow that brightens whatever it overlaps
|
|
1191
|
+
{ type: 'shape', shape: 'circle', radius: 120, blendMode: 'add',
|
|
1192
|
+
fillGradient: { type: 'radial', stops: [[0, 'rgba(255,255,255,0.9)'], [1, 'rgba(255,255,255,0)']] },
|
|
1193
|
+
initial: { x: 'W/2', y: 'H/2' } }
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
---
|
|
1197
|
+
|
|
1198
|
+
## Assets
|
|
1199
|
+
|
|
1200
|
+
Pass a flat list of named assets to `Movie.init({ assets })`. Sequences refer to them by `name`.
|
|
1201
|
+
|
|
1202
|
+
```ts
|
|
1203
|
+
await movie.init({
|
|
1204
|
+
assets: [
|
|
1205
|
+
{ name: 'logo', src: '/img/logo.png' },
|
|
1206
|
+
{ name: 'bgm', src: '/audio/bgm.mp3' },
|
|
1207
|
+
{ name: 'green', src: '/video/green.mp4' },
|
|
1208
|
+
],
|
|
1209
|
+
composition: { /* sequences reference 'logo', 'bgm', 'green' */ },
|
|
1210
|
+
});
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
Supported formats are whatever PixiJS Assets and the browser's audio/video decoders accept (typically PNG/JPG/WebP for images; MP3/AAC/Opus for audio; MP4/WebM for video).
|
|
1214
|
+
|
|
1215
|
+
---
|
|
1216
|
+
|
|
1217
|
+
## Keyframes
|
|
1218
|
+
|
|
1219
|
+
```ts
|
|
1220
|
+
interface Keyframe {
|
|
1221
|
+
at?: number; // start, in seconds from the start of THIS sequence; negative = back from the sequence's end (-0.5 = 0.5 s before it ends)
|
|
1222
|
+
duration?: number; // seconds (default 0 — instantaneous)
|
|
1223
|
+
ease?: string; // GSAP easing name (default 'none')
|
|
1224
|
+
set?: Props; // jump to these values at `at`
|
|
1225
|
+
to?: Props; // animate from current to these values over `duration`
|
|
1226
|
+
from?: Props; // animate from these values to current
|
|
1227
|
+
// `from` + `to` together is a 'fromTo' tween
|
|
1228
|
+
}
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
The four kinds are mutually exclusive per keyframe:
|
|
1232
|
+
|
|
1233
|
+
- **`set`** — instantaneous property assignment at `at`.
|
|
1234
|
+
- **`to`** — tween from whatever the property is at `at` to the given values, over `duration`.
|
|
1235
|
+
- **`from`** — tween from the given values back to the current property, over `duration`.
|
|
1236
|
+
- **`from` + `to`** — full fromTo tween, with explicit start and end values.
|
|
1237
|
+
|
|
1238
|
+
### Repeating
|
|
1239
|
+
|
|
1240
|
+
`repeat` (extra plays, a finite whole number), `yoyo` (every other play runs backwards) and `repeatDelay` (seconds between plays) work on every kind of animation — position, alpha, colour, shape geometry, filters, text `fill`/`value`:
|
|
1241
|
+
|
|
1242
|
+
```ts
|
|
1243
|
+
{ at: 0, to: { scale: 1.15 }, duration: 0.5, ease: 'sine.inOut', repeat: 5, yoyo: true } // a heartbeat: 6 plays, 6 s total
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
Total time = `duration × (repeat + 1)` (+ delays). Endless repeats are not allowed (the timeline needs a fixed length); `repeat: -1` / `Infinity` is ignored with a warning — repeat as many times as the layer lasts.
|
|
1247
|
+
|
|
1248
|
+
### Negative `at`
|
|
1249
|
+
|
|
1250
|
+
If `at < 0`, it's interpreted as `duration + at` — measured back from the end of **the sequence the keyframe belongs to**. Useful for fade-outs:
|
|
1251
|
+
|
|
1252
|
+
```ts
|
|
1253
|
+
{ type: 'text', text: 'bye', at: 2, duration: 3,
|
|
1254
|
+
keyframes: [{ at: -0.5, to: { alpha: 0 }, duration: 0.5 }] } // fades during the last 500 ms of this text (global 4.5 s – 5 s)
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
### Easing
|
|
1258
|
+
|
|
1259
|
+
Standard GSAP easing strings: `'none'`, `'linear'`, `'power1.in'` ... `'power4.inOut'`, `'sine.in/out/inOut'`, `'expo.in/out/inOut'`, `'circ.in/out/inOut'`, `'back.in/out/inOut(overshoot)'`, `'elastic.in/out/inOut(amplitude, period)'`, `'bounce.in/out/inOut'`. See [GSAP easing docs](https://gsap.com/docs/v3/Eases/).
|
|
1260
|
+
|
|
1261
|
+
### PIXI shorthands
|
|
1262
|
+
|
|
1263
|
+
These keys are auto-routed through GSAP's PixiPlugin when used in `initial` / `set` / `to` / `from` / `keyframes`:
|
|
1264
|
+
|
|
1265
|
+
```
|
|
1266
|
+
scale, scaleX, scaleY
|
|
1267
|
+
anchor, anchorX, anchorY
|
|
1268
|
+
pivot, pivotX, pivotY
|
|
1269
|
+
skew, skewX, skewY
|
|
1270
|
+
position, positionX, positionY
|
|
1271
|
+
tilePosition, tilePositionX, tilePositionY
|
|
1272
|
+
tileScale, tileScaleX, tileScaleY
|
|
1273
|
+
tint, autoAlpha
|
|
1274
|
+
colorize, colorizeAmount, colorMatrixFilter
|
|
1275
|
+
blur, blurX, blurY, blurPadding
|
|
1276
|
+
lineColor, lineAlpha, fillColor, fillAlpha
|
|
1277
|
+
```
|
|
1278
|
+
|
|
1279
|
+
(In addition to plain DisplayObject props like `x`, `y`, `rotation`, `alpha`, `width`, `height`, `visible`.) **Angles are in degrees**: `rotation`, `skew` / `skewX` / `skewY` (PixiPlugin converts them), and `rotationX` / `rotationY` for 3D layers.
|
|
1280
|
+
|
|
1281
|
+
### Filter keyframe paths
|
|
1282
|
+
|
|
1283
|
+
Animate a named filter's parameter using a dot-path key:
|
|
1284
|
+
|
|
1285
|
+
```ts
|
|
1286
|
+
import { BlurFilter } from 'pixi.js';
|
|
1287
|
+
|
|
1288
|
+
{
|
|
1289
|
+
type: 'video',
|
|
1290
|
+
asset: 'green',
|
|
1291
|
+
filters: [
|
|
1292
|
+
{ name: 'k', type: 'chromaKey', keyColor: '#00ff00' },
|
|
1293
|
+
{ name: 'b', type: 'custom', filter: new BlurFilter({ strength: 0 }) },
|
|
1294
|
+
],
|
|
1295
|
+
keyframes: [
|
|
1296
|
+
{ at: 2, to: { 'filters.b.strength': 8 }, duration: 1 },
|
|
1297
|
+
{ at: 4, to: { 'filters.k.threshold': 0.5 }, duration: 1 },
|
|
1298
|
+
],
|
|
1299
|
+
}
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
The path is `filters.<filter-name>.<animatable-param>`. A filter must have a `name` to be addressable.
|
|
1303
|
+
|
|
1304
|
+
---
|
|
1305
|
+
|
|
1306
|
+
## Expressions
|
|
1307
|
+
|
|
1308
|
+
Any string `Props` value may be an arithmetic expression. Strings whose first character is a letter or digit are tried as expressions; verbatim strings (color hex, font names, etc.) are passed through when used in fields that don't expect a number.
|
|
1309
|
+
|
|
1310
|
+
The expression parser is in-tree (no eval, CSP-safe). See [`src/expr/Parser.ts`](../src/expr/Parser.ts).
|
|
1311
|
+
|
|
1312
|
+
### Operators and functions
|
|
1313
|
+
|
|
1314
|
+
| Form | Notes |
|
|
1315
|
+
| ----------------------- | ---------------------------------------------------- |
|
|
1316
|
+
| `+ - * /` | binary arithmetic |
|
|
1317
|
+
| `-x`, `+x` | unary |
|
|
1318
|
+
| `( ... )` | parens |
|
|
1319
|
+
| `1`, `1.5`, `.25` | decimals |
|
|
1320
|
+
| `min(a, b)`, `max(a, b)`| variadic |
|
|
1321
|
+
| `abs(x)` | |
|
|
1322
|
+
| `floor(x)`, `ceil(x)`, `round(x)` | |
|
|
1323
|
+
| `sqrt(x)` | |
|
|
1324
|
+
| `pow(a, b)` | power |
|
|
1325
|
+
| `sin(x)`, `cos(x)`, `tan(x)` | radians |
|
|
1326
|
+
|
|
1327
|
+
No comparison, conditional, bitwise, or string operators — keep it numeric.
|
|
1328
|
+
|
|
1329
|
+
### Scope variables
|
|
1330
|
+
|
|
1331
|
+
Each sequence has its own scope, computed at build time:
|
|
1332
|
+
|
|
1333
|
+
| Name | Meaning |
|
|
1334
|
+
| --------- | ------------------------------------------------------------------------------------- |
|
|
1335
|
+
| `w` | sequence intrinsic width (e.g. video natural width). 0 if not applicable. |
|
|
1336
|
+
| `h` | sequence intrinsic height. |
|
|
1337
|
+
| `W` | parent composition width (or root if no parent). |
|
|
1338
|
+
| `H` | parent composition height. |
|
|
1339
|
+
| `GW` | global (root) composition width. |
|
|
1340
|
+
| `GH` | global (root) composition height. |
|
|
1341
|
+
| `contain` | scale factor that makes the sequence fit inside the parent (preserve aspect, no crop) |
|
|
1342
|
+
| `cover` | scale factor that makes the sequence cover the parent (preserve aspect, may crop) |
|
|
1343
|
+
| `t` | sequence start time (the `at` after negative-`at` resolution), seconds |
|
|
1344
|
+
| `d` | sequence duration, seconds |
|
|
1345
|
+
| `T` | parent (or root) duration, seconds |
|
|
1346
|
+
|
|
1347
|
+
### Examples
|
|
1348
|
+
|
|
1349
|
+
```ts
|
|
1350
|
+
// Center an image
|
|
1351
|
+
initial: { x: 'GW/2 - w/2', y: 'GH/2 - h/2' }
|
|
1352
|
+
|
|
1353
|
+
// Fit a video without cropping
|
|
1354
|
+
initial: { x: 0, y: 0, scale: 'contain' }
|
|
1355
|
+
|
|
1356
|
+
// Fill a video, may crop
|
|
1357
|
+
initial: { x: 0, y: 0, scale: 'cover' }
|
|
1358
|
+
|
|
1359
|
+
// Responsive font size
|
|
1360
|
+
style: { fontSize: 'min(GW, GH) * 0.06' }
|
|
1361
|
+
|
|
1362
|
+
// Subtitle 4% above bottom
|
|
1363
|
+
initial: { x: 'GW/2', y: 'GH * 0.96', anchorX: 0.5, anchorY: 1 }
|
|
1364
|
+
```
|
|
1365
|
+
|
|
1366
|
+
---
|
|
1367
|
+
|
|
1368
|
+
## Transitions
|
|
1369
|
+
|
|
1370
|
+
A composition can declare scene-to-scene `transitions` that compress paired keyframes into a single line and add visual effects (mask wipes, iris reveals) that aren't expressible at the keyframe level.
|
|
1371
|
+
|
|
1372
|
+
```ts
|
|
1373
|
+
{
|
|
1374
|
+
sequences: [
|
|
1375
|
+
{ type: 'video', name: 'A', asset: 'a', at: 0, duration: 5 },
|
|
1376
|
+
{ type: 'video', name: 'B', asset: 'b', at: 4, duration: 5 },
|
|
1377
|
+
],
|
|
1378
|
+
transitions: [
|
|
1379
|
+
{ kind: 'crossfade', from: 'A', to: 'B', at: 4, duration: 1, ease: 'sine.inOut' },
|
|
1380
|
+
],
|
|
1381
|
+
}
|
|
1382
|
+
```
|
|
1383
|
+
|
|
1384
|
+
Common fields (`TransitionCommon`):
|
|
1385
|
+
|
|
1386
|
+
| Field | Type | Notes |
|
|
1387
|
+
| ---------- | ------- | -------------------------------------------------------------------------------------- |
|
|
1388
|
+
| `from` | string | sibling sequence's `name`. Must exist in the same composition. |
|
|
1389
|
+
| `to` | string | sibling sequence's `name`. Must be declared **after** `from` in `sequences[]`. |
|
|
1390
|
+
| `at` | number | start of the transition, in the **parent composition's** time (like a sequence's `at`, not sequence-local); negative = back from the parent's end. |
|
|
1391
|
+
| `duration` | number | seconds, must be > 0. |
|
|
1392
|
+
| `ease` | string? | GSAP easing name. Default `'none'` (linear). |
|
|
1393
|
+
|
|
1394
|
+
Validation runs at composition build time. Errors throw with the offending `transitions[<index>]` quoted in the message: missing names, `to` before `from`, transition window outside either sequence's lifespan, duplicate use of one sequence as `from`, `from === to`, `duration <= 0`.
|
|
1395
|
+
|
|
1396
|
+
### `crossfade`
|
|
1397
|
+
|
|
1398
|
+
Alpha cross-dissolve. `from` fades to `alpha: 0`, `to` starts at `alpha: 0` and fades to `1`, both over `[at, at + duration]`.
|
|
1399
|
+
|
|
1400
|
+
```ts
|
|
1401
|
+
{ kind: 'crossfade', from: 'A', to: 'B', at: 4, duration: 1, ease: 'sine.inOut' }
|
|
1402
|
+
```
|
|
1403
|
+
|
|
1404
|
+
If `to` already has an explicit `initial.alpha` (other than 0), the expander throws — remove the manual setting.
|
|
1405
|
+
|
|
1406
|
+
### `wipe`
|
|
1407
|
+
|
|
1408
|
+
A directional reveal. `to` is masked by a soft edge that travels across the screen.
|
|
1409
|
+
|
|
1410
|
+
```ts
|
|
1411
|
+
{
|
|
1412
|
+
kind: 'wipe', from: 'A', to: 'B', at: 4, duration: 1,
|
|
1413
|
+
direction: 'left' | 'right' | 'up' | 'down',
|
|
1414
|
+
smoothing: 0.04, // 0..1 edge softness (default 0.02)
|
|
1415
|
+
}
|
|
1416
|
+
```
|
|
1417
|
+
|
|
1418
|
+
`direction` is the direction the wipe edge travels — `'left'` means the edge moves leftward across the canvas, exposing B starting from the right side. Mirror that for `'right'` / `'up'` / `'down'`.
|
|
1419
|
+
|
|
1420
|
+
### `iris`
|
|
1421
|
+
|
|
1422
|
+
A circular reveal centered on the canvas.
|
|
1423
|
+
|
|
1424
|
+
```ts
|
|
1425
|
+
{
|
|
1426
|
+
kind: 'iris', from: 'A', to: 'B', at: 4, duration: 1,
|
|
1427
|
+
mode: 'in', // default — B opens up from a point. 'out' = A closes down to a point.
|
|
1428
|
+
smoothing: 0.03, // 0..1 edge softness (default 0.02)
|
|
1429
|
+
}
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
`mode: 'in'` (default): B emerges from the center and grows outward.
|
|
1433
|
+
`mode: 'out'`: A disappears from the outside in, exposing B.
|
|
1434
|
+
|
|
1435
|
+
### `slide`
|
|
1436
|
+
|
|
1437
|
+
Both sequences slide together; the new scene comes in from the opposite side.
|
|
1438
|
+
|
|
1439
|
+
```ts
|
|
1440
|
+
{
|
|
1441
|
+
kind: 'slide', from: 'A', to: 'B', at: 4, duration: 1,
|
|
1442
|
+
direction: 'left' | 'right' | 'up' | 'down',
|
|
1443
|
+
}
|
|
1444
|
+
```
|
|
1445
|
+
|
|
1446
|
+
`direction` is the direction of motion. `'left'` means A slides off to the left and B enters from the right.
|
|
1447
|
+
|
|
1448
|
+
The slide macro reads each sequence's existing `initial.x` / `initial.y` (if any) and treats it as the natural resting position. `B` is shifted off-screen by ±W or ±H from that position and slides back to it; `A` slides from its position to off-screen on the opposite side. So a centered text with `initial: { x: 'GW/2', anchorX: 0.5 }` ends the slide centered, not at `x: 0`.
|
|
1449
|
+
|
|
1450
|
+
If you've manually keyframed `x` / `y` on `A` or `B`, the slide expansion appends new keyframes alongside — your existing motion is not overwritten. Behavior with conflicting motion is the user's responsibility.
|
|
1451
|
+
|
|
1452
|
+
### `dip`
|
|
1453
|
+
|
|
1454
|
+
"Dip through": A fades out across the first half of the window and B fades in across the second half. The visible color during the dip is whatever sits behind A and B — set `Movie.background` (or place a persistent layer beneath them) for dip-to-black / dip-to-white / dip-to-color.
|
|
1455
|
+
|
|
1456
|
+
```ts
|
|
1457
|
+
{ kind: 'dip', from: 'A', to: 'B', at: 4, duration: 1, ease: 'sine.inOut' }
|
|
1458
|
+
```
|
|
1459
|
+
|
|
1460
|
+
If `to` already has a non-zero `initial.alpha`, the expander throws — remove the manual setting.
|
|
1461
|
+
|
|
1462
|
+
### `zoom`
|
|
1463
|
+
|
|
1464
|
+
A scaled punch-in / punch-out. By default `B` opens up: it starts large and zooms back to scale 1 while fading in; `A` simply fades. With `mode: 'out'` it's the opposite — `A` zooms outward as it fades, and `B` fades in at scale 1.
|
|
1465
|
+
|
|
1466
|
+
```ts
|
|
1467
|
+
{
|
|
1468
|
+
kind: 'zoom', from: 'A', to: 'B', at: 4, duration: 1,
|
|
1469
|
+
mode: 'in', // default — B opens up. 'out' = A closes outward.
|
|
1470
|
+
fromScale: 4, // starting scale of the zoomed sequence (default 4)
|
|
1471
|
+
ease: 'power2.out',
|
|
1472
|
+
}
|
|
1473
|
+
```
|
|
1474
|
+
|
|
1475
|
+
### `dissolve`
|
|
1476
|
+
|
|
1477
|
+
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.
|
|
1478
|
+
|
|
1479
|
+
```ts
|
|
1480
|
+
{
|
|
1481
|
+
kind: 'dissolve', from: 'A', to: 'B', at: 4, duration: 1,
|
|
1482
|
+
scale: 30, // pattern frequency (higher = finer grain). Default 30.
|
|
1483
|
+
seed: 0, // pattern offset. Different seeds → different reveal patterns.
|
|
1484
|
+
smoothing: 0.05, // edge softness within each chunk. Default 0.05.
|
|
1485
|
+
}
|
|
1486
|
+
```
|
|
1487
|
+
|
|
1488
|
+
---
|
|
1489
|
+
|
|
1490
|
+
## Filters
|
|
1491
|
+
|
|
1492
|
+
> **`filterArea`** (on any layer) is a rectangle in the layer's **own** coordinates — origin = its local origin, e.g. a circle's centre — that widens the area a filter may draw into. Without it a blur or glow is clipped to the layer's bounding box: `filterArea: { x: -(r + 260), y: -(r + 260), width: 2 * (r + 260), height: 2 * (r + 260) }` for a circle of radius `r` blurred by up to 260 px. `threeD` layers add the filters' padding automatically.
|
|
1493
|
+
|
|
1494
|
+
Filters are named, ordered, and per-sequence. Animate parameters via `'filters.<name>.<param>'` keyframe paths.
|
|
1495
|
+
|
|
1496
|
+
### `chromaKey`
|
|
1497
|
+
|
|
1498
|
+
Removes a key color from the source. Works on video, image, or composition layers.
|
|
1499
|
+
|
|
1500
|
+
```ts
|
|
1501
|
+
{
|
|
1502
|
+
name: 'k',
|
|
1503
|
+
type: 'chromaKey',
|
|
1504
|
+
keyColor?: string | [number, number, number], // hex '#00ff00' or RGB 0..1; default green
|
|
1505
|
+
threshold?: number, // default 0.4 — distance from key color counted as transparent
|
|
1506
|
+
smoothing?: number, // default 0.1 — softness of the cutoff edge
|
|
1507
|
+
spill?: number, // default 0.2 — green-tint suppression
|
|
1508
|
+
}
|
|
1509
|
+
```
|
|
1510
|
+
|
|
1511
|
+
Animatable: `threshold`, `smoothing`, `spill`. `keyColor` is set at build time.
|
|
1512
|
+
|
|
1513
|
+
### `custom`
|
|
1514
|
+
|
|
1515
|
+
Escape hatch for any PIXI `Filter` instance — including PIXI's own built-ins (`BlurFilter`, `ColorMatrixFilter`, `NoiseFilter`, etc.), [pixi-filters](https://github.com/pixijs/filters), community packages, or your own `Filter` subclass. The instance is used as-is; animation works the same way as for `chromaKey` as long as the filter has writable scalar properties at the addressed paths.
|
|
1516
|
+
|
|
1517
|
+
```ts
|
|
1518
|
+
import { BlurFilter } from 'pixi.js';
|
|
1519
|
+
import { GlowFilter, OldFilmFilter } from 'pixi-filters';
|
|
1520
|
+
|
|
1521
|
+
{
|
|
1522
|
+
type: 'image',
|
|
1523
|
+
asset: 'photo',
|
|
1524
|
+
filters: [
|
|
1525
|
+
{ type: 'custom', name: 'b', filter: new BlurFilter({ strength: 0 }) },
|
|
1526
|
+
{ type: 'custom', name: 'glow', filter: new GlowFilter({ outerStrength: 1, color: 0xffaa00 }) },
|
|
1527
|
+
{ type: 'custom', name: 'film', filter: new OldFilmFilter() },
|
|
1528
|
+
],
|
|
1529
|
+
keyframes: [
|
|
1530
|
+
{ at: 1, to: { 'filters.b.strength': 8 }, duration: 0.5 },
|
|
1531
|
+
{ at: 2, to: { 'filters.glow.outerStrength': 4 }, duration: 1 },
|
|
1532
|
+
{ at: -0.5, to: { 'filters.film.noise': 0 }, duration: 0.5 },
|
|
1533
|
+
],
|
|
1534
|
+
}
|
|
1535
|
+
```
|
|
1536
|
+
|
|
1537
|
+
Notes:
|
|
1538
|
+
|
|
1539
|
+
- `filter` must be a `Filter` instance (constructor must have run on the consumer side).
|
|
1540
|
+
- 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.
|
|
1541
|
+
- Without a `name`, the filter still applies but cannot be addressed via `filters.<name>.<prop>` keyframe paths.
|
|
1542
|
+
|
|
1543
|
+
Notes:
|
|
1544
|
+
|
|
1545
|
+
- `filter` must be a PIXI `Filter` instance (constructor must have run on the consumer side). Plain object literals throw.
|
|
1546
|
+
- `pixi-filters` is **not** a dependency of pixi-effects — install it on your side if you want to use it.
|
|
1547
|
+
- Without a `name`, the filter still applies but cannot be addressed via `filters.<name>.<prop>` keyframe paths.
|
|
1548
|
+
|
|
1549
|
+
---
|
|
1550
|
+
|
|
1551
|
+
## Presets
|
|
1552
|
+
|
|
1553
|
+
Presets are pure helpers that return a ready-to-use `SequenceSpec`. They expand into the existing keyframe / initial primitives — no engine surgery — so anything you can do with a preset you can also write by hand.
|
|
1554
|
+
|
|
1555
|
+
### `kenBurns`
|
|
1556
|
+
|
|
1557
|
+
Per-image motion preset for slideshows. Returns an `ImageSequenceSpec`; drop the result straight into `sequences[]`. Pair with `crossfade` / `dip` etc. transitions for the cuts between images — `kenBurns` itself emits no fade.
|
|
1558
|
+
|
|
1559
|
+
```ts
|
|
1560
|
+
import { kenBurns } from 'pixi-effects';
|
|
1561
|
+
|
|
1562
|
+
sequences: [
|
|
1563
|
+
kenBurns({ asset: 'photo1', name: 'p1', at: 0, duration: 6, motion: 'scale', origin: [0.25, 0.25], zoom: 1.2 }),
|
|
1564
|
+
kenBurns({ asset: 'photo2', name: 'p2', at: 5, duration: 6, motion: 'rotation', angle: 6 }),
|
|
1565
|
+
kenBurns({ asset: 'photo3', name: 'p3', at: 10, duration: 6, motion: 'position', from: [0, 0], to: [1, 1] }),
|
|
1566
|
+
kenBurns({ asset: 'photo4', name: 'p4', at: 15, duration: 6, motion: 'still' }),
|
|
1567
|
+
],
|
|
1568
|
+
```
|
|
1569
|
+
|
|
1570
|
+
The image is centred on the canvas; `fit` (default `'cover'`) controls how the texture is scaled to fill. The fitted scale is computed at runtime from the texture's intrinsic size, so you don't pass `imageWidth` / `imageHeight`.
|
|
1571
|
+
|
|
1572
|
+
#### Common fields
|
|
1573
|
+
|
|
1574
|
+
| Field | Type | Notes |
|
|
1575
|
+
| ---------- | --------------------- | ------------------------------------------------------------------------------------ |
|
|
1576
|
+
| `asset` | string | image asset name (registered via `Movie.init({ assets })`) |
|
|
1577
|
+
| `duration` | number | seconds of animation (required) |
|
|
1578
|
+
| `name?` | string | sequence name so transitions can reference it |
|
|
1579
|
+
| `at?` | number | start time, parent-relative seconds |
|
|
1580
|
+
| `fit?` | `'cover'` \| `'contain'` | how the texture fills the canvas. Default `'cover'`. |
|
|
1581
|
+
| `ease?` | string | GSAP easing name. Default `'sine.inOut'`. |
|
|
1582
|
+
|
|
1583
|
+
#### `motion: 'still'`
|
|
1584
|
+
|
|
1585
|
+
Image sits at the canvas centre, fitted but unanimated. Useful as a stable "rest" in between motion-heavy frames.
|
|
1586
|
+
|
|
1587
|
+
#### `motion: 'scale'`
|
|
1588
|
+
|
|
1589
|
+
Zoom in or out around an arbitrary 9-point pivot.
|
|
1590
|
+
|
|
1591
|
+
| Field | Type | Notes |
|
|
1592
|
+
| ------------ | -------------------------- | ------------------------------------------------------------------------------------------- |
|
|
1593
|
+
| `origin?` | `[number, number]` | pivot in [0..1] image coords. Default `[0.5, 0.5]` (centre). The original convention uses the 9-point grid `0.25 / 0.5 / 0.75`. |
|
|
1594
|
+
| `zoom?` | number | zoom factor relative to the fitted base. Default `1.15`. |
|
|
1595
|
+
| `direction?` | `'in'` \| `'out'` | `'in'`: 1 → zoom (default). `'out'`: zoom → 1. |
|
|
1596
|
+
|
|
1597
|
+
The pivot point stays pinned at its world position; the rest of the image grows / shrinks around it. This is the "focal-point" zoom you want for ken-burns slideshows — the eye anchors on the pivot while the surrounding pixels move.
|
|
1598
|
+
|
|
1599
|
+
#### `motion: 'rotation'`
|
|
1600
|
+
|
|
1601
|
+
Gentle rotation while keeping the image filling the canvas.
|
|
1602
|
+
|
|
1603
|
+
| Field | Type | Notes |
|
|
1604
|
+
| ------------ | ----------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
1605
|
+
| `angle?` | number | total rotation in degrees. Default `8`. Capped at 30. |
|
|
1606
|
+
| `direction?` | `'cw'` \| `'ccw'` \| `'through'` | `'cw'` (default) = 0 → +angle. `'ccw'` = 0 → −angle. `'through'` = −angle/2 → +angle/2. |
|
|
1607
|
+
|
|
1608
|
+
The scale is over-set so the rotated bounding box still covers the canvas — no background gaps as the image tilts.
|
|
1609
|
+
|
|
1610
|
+
#### `motion: 'position'`
|
|
1611
|
+
|
|
1612
|
+
Pan the image between two points within its over-scaled bounds.
|
|
1613
|
+
|
|
1614
|
+
| Field | Type | Notes |
|
|
1615
|
+
| ------- | ------------------- | -------------------------------------------------------------------------------------- |
|
|
1616
|
+
| `from?` | `[number, number]` | start position in [0..1] of the over-scaled bounds. Default `[0.25, 0.25]`. |
|
|
1617
|
+
| `to?` | `[number, number]` | end position. Default `[0.75, 0.75]`. |
|
|
1618
|
+
| `zoom?` | number | over-scale factor (must be > 1 for any pan to be visible). Default `1.15`. |
|
|
1619
|
+
|
|
1620
|
+
`[0, 0]` looks at the top-left of the image; `[1, 1]` looks at the bottom-right. The default pans diagonally across the upper-left and lower-right quarters of the over-scaled image (matches the Yajima-Motion preset).
|
|
1621
|
+
|
|
1622
|
+
### `orbit`
|
|
1623
|
+
|
|
1624
|
+
A camera that circles a point. There is no per-frame expression in the DSL, so the circle is sampled into short linear keyframes (one per 0.1 s; the easing is applied to the angle). Returns a `camera` layer for `sequences[]`.
|
|
1625
|
+
|
|
1626
|
+
```ts
|
|
1627
|
+
import { orbit } from 'pixi-effects';
|
|
1628
|
+
|
|
1629
|
+
sequences: [
|
|
1630
|
+
orbit({ duration: 6, degrees: 40 }), // ±20° around the centre of a 1280×720 canvas
|
|
1631
|
+
// …threeD layers…
|
|
1632
|
+
]
|
|
1633
|
+
```
|
|
1634
|
+
|
|
1635
|
+
| Option | Notes |
|
|
1636
|
+
|---|---|
|
|
1637
|
+
| `duration`, `degrees` | required. `degrees` is the total sweep (negative = the other way) |
|
|
1638
|
+
| `start` | start angle in degrees; default `-degrees / 2` (0 = straight in front of the centre) |
|
|
1639
|
+
| `radius` | distance from the centre; default = the default camera distance for `height` and `fov`, so `z = 0` is 1:1 at angle 0 |
|
|
1640
|
+
| `center` | `[x, y]` to circle and look at; default the canvas centre |
|
|
1641
|
+
| `width`, `height` | canvas size for the defaults (1280 × 720) |
|
|
1642
|
+
| `fov`, `ease`, `at`, `name`, `stepsPerSecond` | `ease` default `'sine.inOut'` |
|
|
1643
|
+
|
|
1644
|
+
`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.
|
|
1645
|
+
|
|
1646
|
+
### `withFade`
|
|
1647
|
+
|
|
1648
|
+
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[]`.
|
|
1649
|
+
|
|
1650
|
+
```ts
|
|
1651
|
+
import { kenBurns, withFade } from 'pixi-effects';
|
|
1652
|
+
|
|
1653
|
+
sequences: [
|
|
1654
|
+
withFade(kenBurns({ asset: 'photo', at: 0, duration: 6, motion: 'scale' }), { in: 0.5, out: 0.5 }),
|
|
1655
|
+
withFade({ type: 'text', text: 'hi', at: 5, duration: 3, initial: { x: 'GW/2' } }, { in: 0.4 }),
|
|
1656
|
+
]
|
|
1657
|
+
```
|
|
1658
|
+
|
|
1659
|
+
| Option | Type | Notes |
|
|
1660
|
+
| ------ | ------ | --------------------------------------------------------------------------------------------- |
|
|
1661
|
+
| `in?` | number | fade-in length in seconds, at the start of the sequence. Sets `initial.alpha = 0`. |
|
|
1662
|
+
| `out?` | number | fade-out length in seconds, anchored to `at + duration`. **Requires `duration` on the spec** (throws otherwise). |
|
|
1663
|
+
|
|
1664
|
+
The added keyframes are layered on top of any keyframes the spec already has. `withFade` is available since `0.2.0`.
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
<!-- ===== docs/api.md ===== -->
|
|
1668
|
+
|
|
1669
|
+
# API Reference
|
|
1670
|
+
|
|
1671
|
+
- [`Movie`](#movie) — composition runtime: init, playback, render
|
|
1672
|
+
- [`Controller`](#controller) — drop-in player UI overlay
|
|
1673
|
+
- [Helpers](#helpers) — pure utilities exported from `pixi-effects/controller`
|
|
1674
|
+
- [`pixi-effects/three`](#pixi-effectsthree) — optional three.js integration
|
|
1675
|
+
|
|
1676
|
+
For DSL types (composition spec, sequences, filters, keyframes, expressions), see [DSL reference](./dsl.md).
|
|
1677
|
+
|
|
1678
|
+
---
|
|
1679
|
+
|
|
1680
|
+
## `Movie`
|
|
1681
|
+
|
|
1682
|
+
Imported from `pixi-effects`.
|
|
1683
|
+
|
|
1684
|
+
```ts
|
|
1685
|
+
import { Movie } from 'pixi-effects';
|
|
1686
|
+
|
|
1687
|
+
const movie = new Movie();
|
|
1688
|
+
await movie.init({ /* ... */ });
|
|
1689
|
+
movie.play();
|
|
1690
|
+
```
|
|
1691
|
+
|
|
1692
|
+
### Constructor
|
|
1693
|
+
|
|
1694
|
+
```ts
|
|
1695
|
+
new Movie()
|
|
1696
|
+
```
|
|
1697
|
+
|
|
1698
|
+
No arguments. State is fully populated by `init()`.
|
|
1699
|
+
|
|
1700
|
+
### `movie.init(options): Promise<void>`
|
|
1701
|
+
|
|
1702
|
+
Loads assets, builds the composition tree, mixes audio, and renders frame 0.
|
|
1703
|
+
|
|
1704
|
+
```ts
|
|
1705
|
+
interface MovieOptions {
|
|
1706
|
+
width?: number; // canvas pixels (default 1920)
|
|
1707
|
+
height?: number; // canvas pixels (default 1080)
|
|
1708
|
+
duration?: number; // seconds (default 10)
|
|
1709
|
+
frameRate?: number; // fps (default 30)
|
|
1710
|
+
background?: string; // CSS color hex (default '#000000')
|
|
1711
|
+
canvas?: HTMLCanvasElement; // existing canvas to render into; otherwise PixiJS creates one
|
|
1712
|
+
assets?: AssetSpec[]; // [{ name, src }]
|
|
1713
|
+
composition?: CompositionSpec; // root composition (see DSL reference)
|
|
1714
|
+
}
|
|
1715
|
+
```
|
|
1716
|
+
|
|
1717
|
+
Resolves once the composition is ready and the first frame has been rendered. Emits the `ready` event.
|
|
1718
|
+
|
|
1719
|
+
### `movie.play(): void`
|
|
1720
|
+
|
|
1721
|
+
Starts the requestAnimationFrame loop that drives `gotoFrame()` per tick. If `currentFrame >= totalFrames`, restarts from 0.
|
|
1722
|
+
|
|
1723
|
+
If audio sources exist, schedules them on the AudioContext at the appropriate offsets.
|
|
1724
|
+
|
|
1725
|
+
### `movie.pause(): void`
|
|
1726
|
+
|
|
1727
|
+
Stops the rAF loop and any playing audio. Emits `'pause'` only when the previous state was playing (so calling `pause()` on an already-paused movie is a no-op for listeners).
|
|
1728
|
+
|
|
1729
|
+
### `movie.gotoFrame(frame, force?): Promise<void>`
|
|
1730
|
+
|
|
1731
|
+
```ts
|
|
1732
|
+
gotoFrame(frame: number, force?: boolean): Promise<void>
|
|
1733
|
+
```
|
|
1734
|
+
|
|
1735
|
+
Seeks to a specific frame. Pauses if currently playing? **No** — does not change `isPlaying`. Updates `timeline.time()`, awaits any video frame readiness, renders, and emits `'frame'`.
|
|
1736
|
+
|
|
1737
|
+
`force=true` skips the early-return when the requested frame equals `currentFrame`. Use it after a composition rebuild.
|
|
1738
|
+
|
|
1739
|
+
### `movie.snapshot(frame?, options?): Promise<Blob | string>`
|
|
1740
|
+
|
|
1741
|
+
```ts
|
|
1742
|
+
snapshot(frame?: number, options?: { scale?: number; type?: 'image/png' | 'image/jpeg'; as?: 'blob' | 'dataURL' }): Promise<Blob | string>
|
|
1743
|
+
```
|
|
1744
|
+
|
|
1745
|
+
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.
|
|
1746
|
+
|
|
1747
|
+
### `movie.contactSheet(options?): Promise<Blob | string>`
|
|
1748
|
+
|
|
1749
|
+
```ts
|
|
1750
|
+
contactSheet(options?: {
|
|
1751
|
+
frames?: number[]; times?: number[]; count?: number; // which frames: explicit, in seconds, or `count` evenly spread (default 6)
|
|
1752
|
+
columns?: number; // default 3
|
|
1753
|
+
cellWidth?: number; // picture width in px, default 480
|
|
1754
|
+
as?: 'blob' | 'dataURL';
|
|
1755
|
+
}): Promise<Blob | string>
|
|
1756
|
+
```
|
|
1757
|
+
|
|
1758
|
+
Many frames on **one** PNG, each labelled `frame N · T s`. The cheapest way to check a whole animation by eye (include the middle of every transition and the last second). Restores the current frame afterwards.
|
|
1759
|
+
|
|
1760
|
+
### `movie.inspect(frame?, options?): Promise<InspectReport>`
|
|
1761
|
+
|
|
1762
|
+
Where every layer is drawn at `frame`, as JSON — for checking layout without eyes. Seeks there and stays there. `options.layers`: `'visible'` (default, only layers drawn at this frame), `'all'`, or `'none'` (just `summary` and `issues`). Faint layers (alpha < 0.3) and text whose x / y is animated (tickers) are not reported.
|
|
1763
|
+
|
|
1764
|
+
```ts
|
|
1765
|
+
interface InspectReport {
|
|
1766
|
+
frame: number; time: number; canvas: { width: number; height: number };
|
|
1767
|
+
summary: { layers: number; visible: number };
|
|
1768
|
+
issues: string[]; // text off the canvas / cut by an edge / empty / overlapping other text — read this first
|
|
1769
|
+
layers: Array<{
|
|
1770
|
+
path: string; // names (or type#index) from the root, joined by '/'
|
|
1771
|
+
name?: string; type: string; threeD: boolean;
|
|
1772
|
+
visible: boolean; // alive at this frame, not hidden by an ancestor, alpha > 0
|
|
1773
|
+
alpha: number;
|
|
1774
|
+
bounds: { x: number; y: number; width: number; height: number } | null; // canvas pixels; null inside a threeD layer
|
|
1775
|
+
onCanvas: 'full' | 'partial' | 'none' | null;
|
|
1776
|
+
}>;
|
|
1777
|
+
}
|
|
1778
|
+
```
|
|
1779
|
+
|
|
1780
|
+
### `movie.render(options?): Promise<Blob>`
|
|
1781
|
+
|
|
1782
|
+
Renders the entire timeline to a single video file. Pauses playback first.
|
|
1783
|
+
|
|
1784
|
+
```ts
|
|
1785
|
+
interface RenderOptions {
|
|
1786
|
+
format?: 'mp4' | 'mov' | 'webm' | 'mkv'; // default 'mp4'
|
|
1787
|
+
video?: {
|
|
1788
|
+
codec?: string; // default per format (mp4/mov→avc, webm/mkv→vp9)
|
|
1789
|
+
bitrate?: 'very-low' | 'low' | 'medium' | 'high' | 'very-high'; // default 'high'
|
|
1790
|
+
};
|
|
1791
|
+
audio?: {
|
|
1792
|
+
codec?: string; // default per format (mp4/mov→aac, webm/mkv→opus)
|
|
1793
|
+
bitrate?: 'very-low' | 'low' | 'medium' | 'high' | 'very-high'; // default 'high'
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
```
|
|
1797
|
+
|
|
1798
|
+
Returns a `Blob` whose `type` is the container's MIME (e.g. `video/mp4`). Emits `'progress'` repeatedly during the render.
|
|
1799
|
+
|
|
1800
|
+
The renderer also forces a keyframe every ~2 seconds so the resulting file scrubs efficiently in standard players.
|
|
1801
|
+
|
|
1802
|
+
### `movie.destroy(): Promise<void>`
|
|
1803
|
+
|
|
1804
|
+
Pauses playback, destroys the underlying PIXI Application, releases audio buffers and AudioContext, and marks the instance unusable.
|
|
1805
|
+
|
|
1806
|
+
### Events
|
|
1807
|
+
|
|
1808
|
+
```ts
|
|
1809
|
+
movie.on(event, fn): this
|
|
1810
|
+
movie.off(event, fn): this
|
|
1811
|
+
```
|
|
1812
|
+
|
|
1813
|
+
| Event | Payload | Fired |
|
|
1814
|
+
| ----------- | ------------------------------------------------- | ----------------------------------------------------- |
|
|
1815
|
+
| `'ready'` | none | once, after `init()` resolves |
|
|
1816
|
+
| `'frame'` | `{ frame: number; totalFrames: number }` | every `gotoFrame` (so once per playback frame too) |
|
|
1817
|
+
| `'pause'` | none | when `pause()` actually transitions from playing |
|
|
1818
|
+
| `'progress'`| `{ progress: number; frame: number; totalFrames: number }` | during `render()`, once per encoded frame |
|
|
1819
|
+
|
|
1820
|
+
`progress` is `0..100` (rounded integer percent).
|
|
1821
|
+
|
|
1822
|
+
### Public properties
|
|
1823
|
+
|
|
1824
|
+
| Property | Type | Notes |
|
|
1825
|
+
| ---------------- | --------- | ------------------------------------------------------- |
|
|
1826
|
+
| `isPlaying` | boolean | true while the rAF loop is active |
|
|
1827
|
+
| `currentFrame` | number | 0-based current frame |
|
|
1828
|
+
| `totalFrames` | number | `Math.round(duration * frameRate)` |
|
|
1829
|
+
| `frameRate` | number | from `init` |
|
|
1830
|
+
| `duration` | number | seconds |
|
|
1831
|
+
| `width`, `height`| number | canvas pixels |
|
|
1832
|
+
| `background` | string | CSS color |
|
|
1833
|
+
| `volume` | number | 0..1 getter/setter; immediate. Setter clamps and applies to active audio |
|
|
1834
|
+
| `muted` | boolean | getter/setter; immediate |
|
|
1835
|
+
| `app` | `pixi.js Application \| null` | PIXI Application instance (advanced/escape hatch) |
|
|
1836
|
+
| `timeline` | GSAP Timeline `\| null` | underlying GSAP timeline (advanced) |
|
|
1837
|
+
|
|
1838
|
+
### `movie.toggleMute(): boolean`
|
|
1839
|
+
|
|
1840
|
+
Flips `muted` and returns the new value.
|
|
1841
|
+
|
|
1842
|
+
---
|
|
1843
|
+
|
|
1844
|
+
## `Controller`
|
|
1845
|
+
|
|
1846
|
+
Imported from `pixi-effects/controller`.
|
|
1847
|
+
|
|
1848
|
+
```ts
|
|
1849
|
+
import { Controller } from 'pixi-effects/controller';
|
|
1850
|
+
|
|
1851
|
+
const ctrl = new Controller(movie, { canvas });
|
|
1852
|
+
// later:
|
|
1853
|
+
ctrl.destroy();
|
|
1854
|
+
```
|
|
1855
|
+
|
|
1856
|
+
A YouTube-style overlay anchored to the canvas:
|
|
1857
|
+
|
|
1858
|
+
```
|
|
1859
|
+
[▶] [🔉━━━] 0:00 / 0:08 ··· [⬇] [⛶]
|
|
1860
|
+
└── play └── volume └── time └── export └── fullscreen
|
|
1861
|
+
```
|
|
1862
|
+
|
|
1863
|
+
The bar auto-hides 2.5s after pointer activity stops (in both playing and paused state) and reappears on pointer move. Clicking the download icon opens a popover with format/quality selectors and a `Download` confirm button. Fullscreen scales the canvas + bar to the viewport.
|
|
1864
|
+
|
|
1865
|
+
### Constructor
|
|
1866
|
+
|
|
1867
|
+
```ts
|
|
1868
|
+
new Controller(movie: Movie, options: ControllerOptions)
|
|
1869
|
+
|
|
1870
|
+
interface ControllerOptions {
|
|
1871
|
+
canvas: HTMLCanvasElement; // required
|
|
1872
|
+
showExportButton?: boolean; // default true; hides ⬇ + popover
|
|
1873
|
+
enableKeyboardShortcuts?: boolean; // default true
|
|
1874
|
+
className?: string; // default 'movie-controller'
|
|
1875
|
+
}
|
|
1876
|
+
```
|
|
1877
|
+
|
|
1878
|
+
Mounting strategy:
|
|
1879
|
+
|
|
1880
|
+
- If `canvas.parentElement` already has a non-static `position`, the controller is appended directly into it.
|
|
1881
|
+
- Otherwise the canvas is wrapped in a `<div class="movie-controller-wrap">` (with `position: relative`). The wrapper is removed on `destroy()`.
|
|
1882
|
+
|
|
1883
|
+
### `controller.destroy(): void`
|
|
1884
|
+
|
|
1885
|
+
Idempotent. Removes:
|
|
1886
|
+
|
|
1887
|
+
- the controller bar DOM
|
|
1888
|
+
- all listeners (Movie events, document keydown/pointerdown/fullscreenchange, wrapper pointermove/mouseleave)
|
|
1889
|
+
- the auto-hide timer
|
|
1890
|
+
- the wrapper, if it was created here
|
|
1891
|
+
- the injected stylesheet (ref-counted across multiple controllers)
|
|
1892
|
+
|
|
1893
|
+
If the controller still owns `document.fullscreenElement`, it calls `exitFullscreen()`.
|
|
1894
|
+
|
|
1895
|
+
### Export popover
|
|
1896
|
+
|
|
1897
|
+
Format options: **MP4**, **WebM**, **MOV** (mp4 ↔ avc/aac, webm ↔ vp9/opus, mov ↔ avc/aac).
|
|
1898
|
+
|
|
1899
|
+
Quality options: **Low**, **Medium**, **High** (default), **Very High**.
|
|
1900
|
+
|
|
1901
|
+
These map directly to `Movie.render()`'s `format` and `video.bitrate` / `audio.bitrate` parameters. Selection persists for the lifetime of the controller instance (no localStorage).
|
|
1902
|
+
|
|
1903
|
+
The download is triggered by an in-page `<a download>` click, so the file lands in the browser's default download location with a name like `movie-{YYYYMMDD-HHMMSS}.{ext}`.
|
|
1904
|
+
|
|
1905
|
+
### Keyboard shortcuts
|
|
1906
|
+
|
|
1907
|
+
Active when `enableKeyboardShortcuts: true` and the key target is not `<input>`/`<textarea>`/`<select>`/contenteditable.
|
|
1908
|
+
|
|
1909
|
+
| Key | Action |
|
|
1910
|
+
| ----------- | --------------------------------------------------- |
|
|
1911
|
+
| `Space` | play / pause |
|
|
1912
|
+
| `←` / `→` | step ±1 frame |
|
|
1913
|
+
| `↑` / `↓` | volume ±5% (clears mute when increasing past zero) |
|
|
1914
|
+
| `M` | toggle mute |
|
|
1915
|
+
| `Shift+E` | export with current settings (skips the popover) |
|
|
1916
|
+
| `F` | toggle fullscreen |
|
|
1917
|
+
| `Esc` | close the export popover or exit fullscreen (browser) |
|
|
1918
|
+
|
|
1919
|
+
### Theming
|
|
1920
|
+
|
|
1921
|
+
The bar uses fixed colors (`#007AFF` for the active track / fill / confirm button, white for icons, `rgba(0,0,0,0.75)` gradient background). Override by adding stricter CSS rules under `.movie-controller`. A theming API (CSS custom properties) is on the roadmap.
|
|
1922
|
+
|
|
1923
|
+
---
|
|
1924
|
+
|
|
1925
|
+
## Helpers
|
|
1926
|
+
|
|
1927
|
+
These pure functions are exported from `pixi-effects/controller` for consumers who want to build custom controls or reuse the parsing utilities. All are side-effect-free.
|
|
1928
|
+
|
|
1929
|
+
```ts
|
|
1930
|
+
import { formatTime, frameToPercent, pxToFrame, pxToFraction, extensionForMimeType }
|
|
1931
|
+
from 'pixi-effects/controller';
|
|
1932
|
+
```
|
|
1933
|
+
|
|
1934
|
+
### `formatTime(seconds: number): string`
|
|
1935
|
+
|
|
1936
|
+
Returns `M:SS` (no leading zero on minutes). `formatTime(125)` → `"2:05"`. Negatives clamp to zero.
|
|
1937
|
+
|
|
1938
|
+
### `frameToPercent(frame: number, totalFrames: number): number`
|
|
1939
|
+
|
|
1940
|
+
Returns 0..100 (clamped). `totalFrames <= 0` returns 0.
|
|
1941
|
+
|
|
1942
|
+
### `pxToFrame(clientX, rect, totalFrames): number`
|
|
1943
|
+
|
|
1944
|
+
Maps a pointer X coordinate (relative to viewport) inside a `DOMRect`-shaped object to a frame index 0..totalFrames. Rounded.
|
|
1945
|
+
|
|
1946
|
+
```ts
|
|
1947
|
+
pxToFrame(150, { left: 100, width: 200 } as DOMRect, 100) // 25
|
|
1948
|
+
```
|
|
1949
|
+
|
|
1950
|
+
### `pxToFraction(clientX, rect, inset?): number`
|
|
1951
|
+
|
|
1952
|
+
Same as `pxToFrame` but returns a normalized 0..1 fraction. The optional `inset` shrinks the active range by that many pixels on each side (used internally for the volume slider).
|
|
1953
|
+
|
|
1954
|
+
### `extensionForMimeType(mime: string): string`
|
|
1955
|
+
|
|
1956
|
+
Maps common video MIME types to file extensions. Falls back to `'mp4'`.
|
|
1957
|
+
|
|
1958
|
+
| MIME contains | Extension |
|
|
1959
|
+
| ------------------- | --------- |
|
|
1960
|
+
| `webm` | `webm` |
|
|
1961
|
+
| `quicktime` / `mov` | `mov` |
|
|
1962
|
+
| `matroska` / `mkv` | `mkv` |
|
|
1963
|
+
| anything else | `mp4` |
|
|
1964
|
+
|
|
1965
|
+
---
|
|
1966
|
+
|
|
1967
|
+
## `pixi-effects/three`
|
|
1968
|
+
|
|
1969
|
+
Optional three.js integration, imported from its own entry so the core `pixi-effects` entry never touches three.js:
|
|
1970
|
+
|
|
1971
|
+
```ts
|
|
1972
|
+
import { registerThree, three, ThreeSequence } from 'pixi-effects/three';
|
|
1973
|
+
```
|
|
1974
|
+
|
|
1975
|
+
**Install:** `npm i three`. `three` is a peer dependency marked optional (`peerDependenciesMeta.three.optional = true`) — consumers who never import `pixi-effects/three` are unaffected either way.
|
|
1976
|
+
|
|
1977
|
+
For the `type: 'three'` spec shape (fields, keyframe paths, rules), see [DSL reference § three](./dsl.md#three).
|
|
1978
|
+
|
|
1979
|
+
### `registerThree(): void`
|
|
1980
|
+
|
|
1981
|
+
Registers the `'three'` sequence type with the composition builder. Call once, before `Movie.init()` builds a composition containing a `type: 'three'` sequence. Idempotent.
|
|
1982
|
+
|
|
1983
|
+
### `three(spec: ThreeSequenceSpec): SequenceSpec`
|
|
1984
|
+
|
|
1985
|
+
Typing helper — accepts a strongly-typed three spec and returns it as a plain `SequenceSpec`, so it drops straight into `composition.sequences` alongside `text` / `image` / etc. A cast only; not required for the sequence to work, but gives editor autocomplete on `setup` / `update` / `dispose`.
|
|
1986
|
+
|
|
1987
|
+
### `ThreeSequence`
|
|
1988
|
+
|
|
1989
|
+
The `Sequence` subclass that backs `type: 'three'`. Exported for advanced use (e.g. `instanceof` checks); most consumers only need `registerThree()` and `three()`.
|
|
1990
|
+
|
|
1991
|
+
### Exported types
|
|
1992
|
+
|
|
1993
|
+
```ts
|
|
1994
|
+
import type { ThreeContext, ThreeSetupResult, ThreeSequenceSpec } from 'pixi-effects/three';
|
|
1995
|
+
```
|
|
1996
|
+
|
|
1997
|
+
| Type | Notes |
|
|
1998
|
+
| ------------------- | ------------------------------------------------------------------------------- |
|
|
1999
|
+
| `ThreeContext` | `{ scene, camera, renderer, width, height }` handed to `setup` / `update` / `dispose`. |
|
|
2000
|
+
| `ThreeSetupResult` | `{ objects?, camera? }` returned from `setup`. |
|
|
2001
|
+
| `ThreeSequenceSpec` | the `type: 'three'` sequence spec. |
|