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.
Files changed (51) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +16 -6
  3. package/ai/SKILL.md +46 -0
  4. package/ai/reference/cheatsheet.md +121 -0
  5. package/ai/reference/pitfalls.md +75 -0
  6. package/ai/reference/recipes.md +375 -0
  7. package/ai/template.html +82 -0
  8. package/ai/tools/save-image.py +24 -0
  9. package/dist/{Base-Bh_VSusi.d.ts → Base-5R7C5pHR.d.cts} +7 -1
  10. package/dist/{Base-B1Tdn0Cv.d.cts → Base-DhY9oMxr.d.ts} +7 -1
  11. package/dist/Composition-D7RCAGEA.cjs +13 -0
  12. package/dist/{Composition-BI5HZJFL.cjs.map → Composition-D7RCAGEA.cjs.map} +1 -1
  13. package/dist/Composition-RGTRZ2JX.js +4 -0
  14. package/dist/{Composition-IO7ZN32J.js.map → Composition-RGTRZ2JX.js.map} +1 -1
  15. package/dist/Controller.d.cts +2 -2
  16. package/dist/Controller.d.ts +2 -2
  17. package/dist/Movie-CghFXprP.d.cts +179 -0
  18. package/dist/Movie-DRZtIGU7.d.ts +179 -0
  19. package/dist/{chunk-7OIWYXGV.cjs → chunk-6BQ6IEW2.cjs} +363 -91
  20. package/dist/chunk-6BQ6IEW2.cjs.map +1 -0
  21. package/dist/{chunk-H55V3U56.js → chunk-A574IA4F.js} +68 -13
  22. package/dist/chunk-A574IA4F.js.map +1 -0
  23. package/dist/{chunk-64IHCYYN.cjs → chunk-QGBOC5YL.cjs} +76 -21
  24. package/dist/chunk-QGBOC5YL.cjs.map +1 -0
  25. package/dist/{chunk-VJCDG6YG.js → chunk-ZQX7WIYL.js} +359 -93
  26. package/dist/chunk-ZQX7WIYL.js.map +1 -0
  27. package/dist/index.cjs +366 -24
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +61 -31
  30. package/dist/index.d.ts +61 -31
  31. package/dist/index.js +363 -22
  32. package/dist/index.js.map +1 -1
  33. package/dist/three.cjs +5 -5
  34. package/dist/three.d.cts +2 -2
  35. package/dist/three.d.ts +2 -2
  36. package/dist/three.js +1 -1
  37. package/dist/{types-CNBilhpz.d.cts → types-CRE9WKd4.d.cts} +59 -6
  38. package/dist/{types-CNBilhpz.d.ts → types-CRE9WKd4.d.ts} +59 -6
  39. package/docs/api.md +333 -0
  40. package/docs/dsl.md +1028 -0
  41. package/llms-full.txt +2001 -0
  42. package/llms.txt +30 -0
  43. package/package.json +5 -3
  44. package/dist/Composition-BI5HZJFL.cjs +0 -13
  45. package/dist/Composition-IO7ZN32J.js +0 -4
  46. package/dist/Movie-CcR6h2jO.d.cts +0 -82
  47. package/dist/Movie-D-n8glA6.d.ts +0 -82
  48. package/dist/chunk-64IHCYYN.cjs.map +0 -1
  49. package/dist/chunk-7OIWYXGV.cjs.map +0 -1
  50. package/dist/chunk-H55V3U56.js.map +0 -1
  51. package/dist/chunk-VJCDG6YG.js.map +0 -1
@@ -0,0 +1,375 @@
1
+ # pixi-effects recipes
2
+
3
+ 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.
4
+
5
+ Assumed canvas: 1280×720 @ 30 fps. `kenBurns`, `withFade` and `orbit` come from `pixi-effects`.
6
+
7
+ ---
8
+
9
+ ## Words that slam in on the beat (kinetic type)
10
+
11
+ 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`.
12
+
13
+ ```js
14
+ // @recipe slam-words
15
+ const WORDS = [['KAFFE', '#ffd166'], ['NORD', '#ef476f'], ['ROAST', '#06d6a0']];
16
+ const BEAT = 0.5; // seconds per beat
17
+ const sequences = [
18
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#111111' } },
19
+ ];
20
+ WORDS.forEach(([word, color], i) => {
21
+ const at = i * BEAT * 2;
22
+ sequences.push({
23
+ type: 'text', text: word, at, duration: BEAT * 2,
24
+ style: { fontSize: 'GH * 0.4', fontWeight: '900', fill: color, fontFamily: 'Arial Black, Arial, sans-serif' },
25
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
26
+ keyframes: [{ at: 0, from: { scale: 2.6, rotation: -5, alpha: 0 }, to: { scale: 1, rotation: 0, alpha: 1 }, duration: 0.16, ease: 'expo.out' }],
27
+ });
28
+ sequences.push({ // white flash on the beat
29
+ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', at, duration: 0.3,
30
+ initial: { x: 'GW/2', y: 'GH/2', fillColor: '#ffffff', alpha: 0.8 },
31
+ keyframes: [{ at: 0, to: { alpha: 0 }, duration: 0.28 }],
32
+ });
33
+ });
34
+ return sequences;
35
+ ```
36
+
37
+ Size text by eye: Arial Black glyphs are ~0.8 × fontSize wide, monospace ~0.6 ×. Leave ~7 % of the frame for a hold-scale.
38
+
39
+ ---
40
+
41
+ ## Lower-third revealed by a wipe mask
42
+
43
+ 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`.
44
+
45
+ ```js
46
+ // @recipe lower-third
47
+ const bar = (name, y, w, h, color, at) => ({
48
+ type: 'shape', shape: 'rect', name, width: w, height: h, anchorX: 0, at, duration: 3.6,
49
+ initial: { x: 80, y, fillColor: color },
50
+ mask: {
51
+ type: 'shape', shape: 'rect', width: 0, height: h, anchorX: 0,
52
+ initial: { x: 80, y, fillColor: '#ffffff' },
53
+ keyframes: [
54
+ { at: 0, to: { width: w }, duration: 0.5, ease: 'power3.out' }, // reveal
55
+ { at: -0.5, to: { x: 80 + w, width: 0 }, duration: 0.5, ease: 'power3.in' }, // exit
56
+ ],
57
+ },
58
+ });
59
+ return [
60
+ bar('name-bar', 600, 520, 56, '#e63946', 0.2),
61
+ bar('title-bar', 656, 420, 40, '#1d3557', 0.35),
62
+ { type: 'text', text: 'JANE DOE', at: 0.5, duration: 3, style: { fontSize: 34, fontWeight: 'bold', fill: '#ffffff' },
63
+ initial: { x: 100, y: 600, anchorX: 0, anchorY: 0.5, alpha: 0 },
64
+ keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }, { at: -0.4, to: { alpha: 0 }, duration: 0.3 }] },
65
+ ];
66
+ ```
67
+
68
+ ---
69
+
70
+ ## A ticker that scrolls out completely (`w` = the text's own width)
71
+
72
+ `'-w'` is the exact x at which the text has left the screen on the left (`w` is measured after the style is applied).
73
+
74
+ ```js
75
+ // @recipe marquee
76
+ return [{
77
+ type: 'text', text: 'BREAKING NEWS • MARKETS RALLY • NEW RECORD SET', duration: 8,
78
+ style: { fontSize: 30, fontWeight: 'bold', fill: '#ffffff' },
79
+ initial: { x: 'GW', y: 670, anchorX: 0, anchorY: 0.5 },
80
+ keyframes: [{ at: 0, to: { x: '-w' }, duration: 8, ease: 'none' }],
81
+ }];
82
+ ```
83
+
84
+ ---
85
+
86
+ ## Numbers that count up
87
+
88
+ A text layer has an animatable number, `value`, printed wherever the text has `{value}`. Animate it with ordinary keyframes (ease, `from`/`to`, `repeat`).
89
+
90
+ ```js
91
+ // @recipe count-up
92
+ return [{
93
+ type: 'text', text: '{value}', format: { grouping: true }, // → "2,480" (decimals: 0 by default)
94
+ style: { fontSize: 96, fontWeight: 'bold', fill: '#ffffff' },
95
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5, value: 0 },
96
+ keyframes: [{ at: 1, to: { value: 2480 }, duration: 1.2, ease: 'power3.out' }],
97
+ }];
98
+ ```
99
+
100
+ 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.
101
+
102
+ ---
103
+
104
+ ## Data-driven bar chart
105
+
106
+ 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.
107
+
108
+ ```js
109
+ // @recipe bar-chart
110
+ const data = [['Mon', 12], ['Tue', 30], ['Wed', 22], ['Thu', 41], ['Fri', 35]];
111
+ const max = Math.max(...data.map(d => d[1]));
112
+ const step = [1, 2, 2.5, 5, 10].map(m => m * 10 ** Math.floor(Math.log10(max / 4))).find(v => max / v <= 5) ?? 10;
113
+ const NICE = Math.ceil(max / step) * step; // axis top, so the chart survives changed data
114
+ const BASE = 600, CHART_H = 380, BAR_W = 90, GAP = 40, DUR = 0.9, EASE = 'power3.out';
115
+ const X0 = (1280 - (data.length * BAR_W + (data.length - 1) * GAP)) / 2, X1 = X0 + data.length * BAR_W + (data.length - 1) * GAP;
116
+ const sequences = [];
117
+ for (let v = 0; v <= NICE; v += step) { // grid lines + tick labels
118
+ const y = BASE - CHART_H * v / NICE;
119
+ sequences.push({ type: 'shape', shape: 'line', from: [X0 - 20, y], to: [X1 + 20, y], initial: { strokeColor: '#33405c', strokeWidth: 1 } });
120
+ sequences.push({ type: 'text', text: String(v), style: { fontSize: 20, fill: '#7f8bb0' }, initial: { x: X0 - 32, y, anchorX: 1, anchorY: 0.5 } });
121
+ }
122
+ const best = data.findIndex(d => d[1] === max);
123
+ data.forEach(([label, value], i) => {
124
+ const x = X0 + i * (BAR_W + GAP) + BAR_W / 2;
125
+ const at = 0.4 + i * 0.12;
126
+ const h = CHART_H * value / NICE;
127
+ sequences.push({
128
+ type: 'shape', shape: 'rect', width: BAR_W, height: 0, cornerRadius: 8, anchorY: 1, at, colorSpace: 'oklab',
129
+ initial: { x, y: BASE, fillColor: '#4f6df5' },
130
+ keyframes: [
131
+ { at: 0, to: { height: h }, duration: DUR, ease: EASE },
132
+ ...(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
133
+ ],
134
+ });
135
+ sequences.push({ // value label: counts up while riding the bar's top
136
+ type: 'text', text: '{value}', at,
137
+ style: { fontSize: 28, fontWeight: 'bold', fill: '#ffffff' },
138
+ initial: { x, y: BASE - 14, anchorX: 0.5, anchorY: 1, value: 0 },
139
+ keyframes: [{ at: 0, to: { value, y: BASE - h - 14 }, duration: DUR, ease: EASE }],
140
+ });
141
+ sequences.push({ type: 'text', text: label, at, style: { fontSize: 26, fill: '#aab4d4' }, initial: { x, y: BASE + 16, anchorX: 0.5, anchorY: 0 } });
142
+ });
143
+ // callout: a pill sized from its text (~0.58 x fontSize per glyph + padding), a pointer triangle, clamped inside the plot
144
+ const text = 'Peak: ' + data[best][0] + ' ' + max, FS = 26;
145
+ 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;
146
+ sequences.push({ type: 'shape', shape: 'rect', width: PW, height: 52, cornerRadius: 26, at: 4.2, initial: { x: cx, y: cy, fillColor: '#ffd166', alpha: 0 },
147
+ keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
148
+ 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 },
149
+ keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
150
+ 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 },
151
+ keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }] });
152
+ return sequences;
153
+ ```
154
+
155
+ ---
156
+
157
+ ## Letters that fly in from depth (2.5D title)
158
+
159
+ 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).
160
+
161
+ ```js
162
+ // @recipe depth-title
163
+ const TEXT = 'PIXI EFFECTS', SIZE = 118, ADV = SIZE * 0.602;
164
+ const letters = [...TEXT].flatMap((ch, i) => ch === ' ' ? [] : [{
165
+ type: 'text', text: ch, threeD: true,
166
+ style: {
167
+ fontSize: SIZE, fontWeight: '800', fill: '#3de0ff',
168
+ fontFamily: "ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
169
+ dropShadow: { color: '#5b6cff', blur: 20, distance: 0, alpha: 0.9 }, padding: 48,
170
+ },
171
+ 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 },
172
+ keyframes: [
173
+ { at: 0.55 + i * 0.075, to: { z: 0, rotationY: 0, rotationX: 0 }, duration: 1.0, ease: 'expo.out' },
174
+ { at: 0.55 + i * 0.075, to: { alpha: 1 }, duration: 0.3 },
175
+ { at: 0.7 + i * 0.075, to: { fill: '#f4f6ff' }, duration: 0.8, ease: 'sine.out' }, // colour flash on landing
176
+ ],
177
+ }]);
178
+ return [
179
+ { type: 'camera', initial: { fov: 48 }, keyframes: [{ at: 0, to: { fov: 34 }, duration: 6.5, ease: 'sine.inOut' }] },
180
+ ...letters,
181
+ ];
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Camera orbit (+ optional dolly zoom)
187
+
188
+ `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.
189
+
190
+ ```js
191
+ // @recipe camera-orbit
192
+ const card = (x, z, color) => ({
193
+ type: 'shape', shape: 'rect', width: 300, height: 200, cornerRadius: 20, threeD: true,
194
+ initial: { x, y: 360, z, fillColor: color },
195
+ });
196
+ return [
197
+ orbit({ duration: 6, degrees: 50, dollyZoom: { from: 38, to: 62 } }), // options: radius (not with dollyZoom), center, start, fov, ease
198
+ card(240, -300, '#3a6ea5'), card(640, 0, '#d96a3a'), card(990, 60, '#38a169'), // near cards stay at small z
199
+ ];
200
+ ```
201
+
202
+ 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.
203
+
204
+ ---
205
+
206
+ ## A card in depth (a composition with `threeD: true`)
207
+
208
+ 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`.
209
+
210
+ ```js
211
+ // @recipe depth-cards
212
+ const card = (title, color, x, y, z, at) => ({
213
+ type: 'composition', name: title, at, width: 300, height: 190, threeD: true,
214
+ initial: { x, y, z, pivotX: 150, pivotY: 95 }, // pivot = the centre, so x,y is where the centre sits
215
+ sequences: [
216
+ { type: 'shape', shape: 'rect', width: 300, height: 190, cornerRadius: 24, initial: { x: 150, y: 95, fillColor: color } },
217
+ { type: 'text', text: title, style: { fontSize: 34, fontWeight: 'bold', fill: '#ffffff' }, initial: { x: 150, y: 95, anchorX: 0.5, anchorY: 0.5 } },
218
+ ],
219
+ keyframes: [
220
+ { at: 0, from: { alpha: 0, z: z - 400, rotationY: 55 }, to: { alpha: 1, z, rotationY: 0 }, duration: 0.9, ease: 'expo.out' },
221
+ { at: 1, to: { y: y - 14 }, duration: 1.2, ease: 'sine.inOut', repeat: 3, yoyo: true },
222
+ ],
223
+ });
224
+ return [
225
+ { type: 'camera', initial: { fov: 42 } },
226
+ card('Battery', '#3a6ea5', 220, 300, -280, 0.2), // far cards at the outer x
227
+ card('Sound', '#d96a3a', 1060, 260, -200, 0.4),
228
+ card('Comfort', '#38a169', 640, 380, 0, 0.6),
229
+ ];
230
+ ```
231
+
232
+ ---
233
+
234
+ ## Cut to the next scene with an expanding circle
235
+
236
+ 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`.
237
+
238
+ ```js
239
+ // @recipe scene-flood
240
+ const FLOOD_AT = 3, FLOOD = 0.6, END = 6;
241
+ return [
242
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', duration: FLOOD_AT + FLOOD, initial: { x: 'GW/2', y: 'GH/2', fillColor: '#1d2b53' } },
243
+ { 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 } },
244
+ { type: 'shape', shape: 'circle', radius: 0, at: FLOOD_AT, duration: END - FLOOD_AT, initial: { x: 'GW/2', y: 'GH/2', fillColor: '#ffd166' },
245
+ keyframes: [{ at: 0, to: { radius: 820 }, duration: FLOOD, ease: 'power2.in' }] },
246
+ { 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 },
247
+ keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.25 }] },
248
+ ];
249
+ ```
250
+
251
+ ---
252
+
253
+ ## Slideshow with transitions and background music
254
+
255
+ 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.
256
+
257
+ ```js
258
+ // @recipe slideshow
259
+ const photos = ['p1', 'p2', 'p3'], L = 4, T = 1, STEP = L - T;
260
+ const kinds = ['crossfade', 'wipe', 'zoom'];
261
+ const total = photos.length * STEP + T;
262
+ const sequences = photos.map((asset, i) =>
263
+ kenBurns({ asset, name: 'scene' + i, at: i * STEP, duration: L, motion: i % 2 ? 'position' : 'scale' }));
264
+ const transitions = photos.slice(1).map((_, i) => ({
265
+ kind: kinds[i % kinds.length], from: 'scene' + i, to: 'scene' + (i + 1), at: (i + 1) * STEP, duration: T,
266
+ ...(kinds[i % kinds.length] === 'wipe' ? { direction: 'left' } : {}),
267
+ }));
268
+ photos.forEach((_, i) => sequences.push(withFade({
269
+ type: 'text', text: 'Scene ' + (i + 1), at: i * STEP + 0.5, duration: L - 1.5,
270
+ style: { fontSize: 44, fill: '#ffffff', dropShadow: { color: '#000000', blur: 6, distance: 2, alpha: 0.7 } },
271
+ initial: { x: 'GW/2', y: 600, anchorX: 0.5, anchorY: 0.5 },
272
+ }, { in: 0.4, out: 0.4 })));
273
+ sequences.push({ type: 'audio', asset: 'bgm', loop: true, volume: 0, duration: total,
274
+ keyframes: [{ at: 0, to: { volume: 0.8 }, duration: 2 }, { at: -2, to: { volume: 0 }, duration: 2 }] });
275
+ return { sequences, transitions, duration: total };
276
+ ```
277
+
278
+ Source images should be at least canvas-sized (`kenBurns` zooms in): draw generated images at 1920×1080 for a 1280×720 movie.
279
+
280
+ ---
281
+
282
+ ## Looping motion
283
+
284
+ `repeat` (a finite count) and `yoyo` go on any keyframe. Total time = `duration × (repeat + 1)`.
285
+
286
+ ```js
287
+ // @recipe pulse
288
+ return [{
289
+ type: 'shape', shape: 'circle', radius: 40, duration: 6,
290
+ initial: { x: 'GW/2', y: 'GH/2', fillColor: '#ff3b3b' },
291
+ 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
292
+ }];
293
+ ```
294
+
295
+ ---
296
+
297
+ ## Particles (seeded, so every run and export is identical)
298
+
299
+ ```js
300
+ // @recipe particles
301
+ let seed = 7;
302
+ const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
303
+ return Array.from({ length: 40 }, () => {
304
+ const x = rnd() * 1280, y = 100 + rnd() * 620;
305
+ return {
306
+ type: 'shape', shape: 'circle', radius: 2 + rnd() * 4, duration: 6,
307
+ initial: { x, y, fillColor: '#ffffff', fillAlpha: 0.3 + rnd() * 0.5 },
308
+ keyframes: [{ at: 0, to: { y: y - 80 }, duration: 6, ease: 'none' }],
309
+ };
310
+ });
311
+ ```
312
+
313
+ ---
314
+
315
+ ## Gradients and vignettes
316
+
317
+ `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.
318
+
319
+ ```js
320
+ // @recipe gradient-background
321
+ return [
322
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2' },
323
+ fillGradient: { stops: [[0, '#1b2a6b'], [0.6, '#7b3fe4'], [1, '#ff6a88']] } }, // top → bottom
324
+ { type: 'shape', shape: 'rect', width: 520, height: 200, cornerRadius: 30, initial: { x: 'GW/2', y: 'GH/2' },
325
+ fillGradient: { angle: 0, stops: [[0, '#00f5a0'], [1, '#00d9f5']] } }, // left → right
326
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2' },
327
+ 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
328
+ ];
329
+ ```
330
+
331
+ ---
332
+
333
+ ## Generated placeholder images (no photos available)
334
+
335
+ 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.
336
+
337
+ ```js
338
+ // @docs-only
339
+ function placeholderPhoto(name, hueA, hueB, w = 1920, h = 1080) {
340
+ const c = Object.assign(document.createElement('canvas'), { width: w, height: h });
341
+ const g = c.getContext('2d');
342
+ const grad = g.createLinearGradient(0, 0, w, h);
343
+ grad.addColorStop(0, `hsl(${hueA} 70% 45%)`); grad.addColorStop(1, `hsl(${hueB} 70% 25%)`);
344
+ g.fillStyle = grad; g.fillRect(0, 0, w, h);
345
+ return { name, src: c.toDataURL() };
346
+ }
347
+ // await movie.init({ assets: [placeholderPhoto('p1', 210, 280), placeholderPhoto('p2', 10, 60)], composition: { … } })
348
+ ```
349
+
350
+ ---
351
+
352
+ ## Metallic three.js object
353
+
354
+ `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).
355
+
356
+ ```js
357
+ // @docs-only
358
+ // registerThree(); const THREE = await import('three');
359
+ three({
360
+ type: 'three', width: 'GW * 0.56', height: 'GH * 0.9', initial: { x: 'GW * 0.77', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
361
+ setup: (ctx) => {
362
+ const env = new THREE.Scene();
363
+ for (const [x, y, z] of [[4, 4, 4], [-4, 2, -4], [0, -4, 4]]) {
364
+ const lamp = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshBasicMaterial({ color: 0xffffff }));
365
+ lamp.position.set(x, y, z); env.add(lamp);
366
+ }
367
+ ctx.scene.environment = new THREE.PMREMGenerator(ctx.renderer).fromScene(env).texture;
368
+ const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(1, 0.32, 128, 32), new THREE.MeshStandardMaterial({ color: 0xcfd8ff, metalness: 1, roughness: 0.25 }));
369
+ ctx.scene.add(knot);
370
+ ctx.camera.position.z = 5.4;
371
+ return { objects: { knot } };
372
+ },
373
+ keyframes: [{ at: 0, to: { 'three.knot.rotation.y': Math.PI * 2 }, duration: 6 }],
374
+ })
375
+ ```
@@ -0,0 +1,82 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>pixi-effects video</title>
7
+ <style>
8
+ body { background: #0d1220; margin: 0; padding: 16px; }
9
+ canvas { width: min(960px, 100%); border-radius: 8px; }
10
+ /* Screenshots: the player bar overlays the bottom ~60px of the canvas (it is NOT part of the exported video).
11
+ Uncomment to hide it while you check the layout: .movie-controller { display: none !important; } */
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <canvas id="stage" width="1280" height="720"></canvas>
16
+
17
+ <script>
18
+ // Harness: every console warning/error is collected. pixi-effects warnings are INSTRUCTIONS — read them.
19
+ // agent-browser eval "JSON.stringify({ ready: window.__ready, logs: window.__logs })"
20
+ window.__logs = [];
21
+ for (const k of ['warn', 'error']) {
22
+ const orig = console[k].bind(console);
23
+ console[k] = (...a) => { window.__logs.push(k + ': ' + a.map(x => (x && x.message) || String(x)).join(' ')); orig(...a); };
24
+ }
25
+ window.addEventListener('error', e => window.__logs.push('uncaught: ' + e.message));
26
+ window.addEventListener('unhandledrejection', e => window.__logs.push('unhandled: ' + (e.reason && e.reason.message || e.reason)));
27
+ </script>
28
+
29
+ <script type="importmap">
30
+ {
31
+ "imports": {
32
+ "pixi.js": "https://esm.sh/pixi.js@8.22.0?bundle-deps",
33
+ "gsap": "https://esm.sh/gsap@3.12.5",
34
+ "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
35
+ "mediabunny": "https://esm.sh/mediabunny",
36
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.4.0/dist/index.js",
37
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.4.0/dist/Controller.js",
38
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.4.0/dist/three.js",
39
+ "three": "https://esm.sh/three@0.178.0"
40
+ }
41
+ }
42
+ </script>
43
+
44
+ <script type="module">
45
+ import { Movie, kenBurns, withFade } from 'pixi-effects';
46
+ import { Controller } from 'pixi-effects/controller';
47
+ // import { registerThree, three } from 'pixi-effects/three'; // only for a three.js layer; call registerThree() before init
48
+ // import * as THREE from 'three';
49
+
50
+ const W = 1280, H = 720, FPS = 30, DURATION = 6;
51
+
52
+ // Build the composition as DATA. Repeated structure = a JS function / loop returning specs.
53
+ const sequences = [
54
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0a0a0f' } },
55
+ {
56
+ type: 'text', text: 'Hello', at: 0.5, duration: DURATION - 0.5,
57
+ style: { fontSize: 120, fontWeight: 'bold', fill: '#ffffff' },
58
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5, alpha: 0 },
59
+ // keyframe `at` is measured from the START OF THIS LAYER (0 = when it appears)
60
+ keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.6, ease: 'power2.out' }],
61
+ },
62
+ ];
63
+
64
+ const movie = new Movie();
65
+ window.movie = movie; // so a script can seek / screenshot / render
66
+ new Controller(movie, { canvas: document.getElementById('stage') });
67
+
68
+ try {
69
+ await movie.init({
70
+ canvas: document.getElementById('stage'),
71
+ width: W, height: H, duration: DURATION, frameRate: FPS,
72
+ background: '#0a0a0f',
73
+ // assets: [{ name: 'photo', src: './photo.jpg' }, { name: 'bgm', src: './bgm.mp3' }],
74
+ composition: { sequences },
75
+ });
76
+ window.__ready = true;
77
+ } catch (e) {
78
+ window.__logs.push('init failed: ' + (e && e.message || e)); // keep this try/catch: it makes failures visible
79
+ }
80
+ </script>
81
+ </body>
82
+ </html>
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env python3
2
+ """Save a `data:` URL image returned by movie.contactSheet / movie.snapshot (as: 'dataURL') to a PNG file.
3
+
4
+ agent-browser prints the eval result as a JSON string; a raw data URL also works:
5
+
6
+ agent-browser eval "movie.contactSheet({ count: 6, as: 'dataURL' })" | python3 ai/tools/save-image.py /abs/path/sheet.png
7
+
8
+ Use an ABSOLUTE output path. Prints the path and byte size.
9
+ """
10
+ import base64, json, sys
11
+
12
+ if len(sys.argv) != 2:
13
+ sys.exit('usage: save-image.py /absolute/output.png (data URL or JSON string on stdin)')
14
+ raw = sys.stdin.read().strip()
15
+ try:
16
+ value = json.loads(raw)
17
+ except ValueError:
18
+ value = raw
19
+ if not isinstance(value, str) or not value.startswith('data:') or ',' not in value:
20
+ sys.exit('stdin is not a data: URL — got: ' + raw[:120])
21
+ data = base64.b64decode(value.split(',', 1)[1])
22
+ with open(sys.argv[1], 'wb') as f:
23
+ f.write(data)
24
+ print(f'{sys.argv[1]} ({len(data)} bytes)')
@@ -1,5 +1,5 @@
1
1
  import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape, t as AudioDescriptor } from './types-CNBilhpz.js';
2
+ import { S as SequenceSpec, C as CompositionShape, t as AudioDescriptor } from './types-CRE9WKd4.cjs';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -86,6 +86,12 @@ declare abstract class Sequence {
86
86
  * project their `threeD` layers; everything else has nothing to do.
87
87
  */
88
88
  updateSpace(_t: number, _host: SpaceHost): void;
89
+ /**
90
+ * Runs once per rendered frame, after the timeline seek and before the cull. Layers whose drawn
91
+ * geometry follows tweened state (shapes) redraw here, so the culler measures where the layer is NOW
92
+ * and not where it was drawn at the previous render.
93
+ */
94
+ syncFrame(): void;
89
95
  destroy(): void;
90
96
  }
91
97
 
@@ -1,5 +1,5 @@
1
1
  import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape, t as AudioDescriptor } from './types-CNBilhpz.cjs';
2
+ import { S as SequenceSpec, C as CompositionShape, t as AudioDescriptor } from './types-CRE9WKd4.js';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -86,6 +86,12 @@ declare abstract class Sequence {
86
86
  * project their `threeD` layers; everything else has nothing to do.
87
87
  */
88
88
  updateSpace(_t: number, _host: SpaceHost): void;
89
+ /**
90
+ * Runs once per rendered frame, after the timeline seek and before the cull. Layers whose drawn
91
+ * geometry follows tweened state (shapes) redraw here, so the culler measures where the layer is NOW
92
+ * and not where it was drawn at the previous render.
93
+ */
94
+ syncFrame(): void;
89
95
  destroy(): void;
90
96
  }
91
97
 
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ var chunkQGBOC5YL_cjs = require('./chunk-QGBOC5YL.cjs');
4
+ require('./chunk-6BQ6IEW2.cjs');
5
+
6
+
7
+
8
+ Object.defineProperty(exports, "CompositionSequence", {
9
+ enumerable: true,
10
+ get: function () { return chunkQGBOC5YL_cjs.CompositionSequence; }
11
+ });
12
+ //# sourceMappingURL=Composition-D7RCAGEA.cjs.map
13
+ //# sourceMappingURL=Composition-D7RCAGEA.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-BI5HZJFL.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-D7RCAGEA.cjs"}
@@ -0,0 +1,4 @@
1
+ export { CompositionSequence } from './chunk-A574IA4F.js';
2
+ import './chunk-ZQX7WIYL.js';
3
+ //# sourceMappingURL=Composition-RGTRZ2JX.js.map
4
+ //# sourceMappingURL=Composition-RGTRZ2JX.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-IO7ZN32J.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-RGTRZ2JX.js"}
@@ -1,7 +1,7 @@
1
- import { M as Movie } from './Movie-CcR6h2jO.cjs';
1
+ import { M as Movie } from './Movie-CghFXprP.cjs';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
- import './types-CNBilhpz.cjs';
4
+ import './types-CRE9WKd4.cjs';
5
5
 
6
6
  declare function formatTime(seconds: number): string;
7
7
  declare function frameToPercent(frame: number, totalFrames: number): number;
@@ -1,7 +1,7 @@
1
- import { M as Movie } from './Movie-D-n8glA6.js';
1
+ import { M as Movie } from './Movie-DRZtIGU7.js';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
- import './types-CNBilhpz.js';
4
+ import './types-CRE9WKd4.js';
5
5
 
6
6
  declare function formatTime(seconds: number): string;
7
7
  declare function frameToPercent(frame: number, totalFrames: number): number;