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
|
@@ -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
|
+
```
|
package/ai/template.html
ADDED
|
@@ -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-
|
|
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-
|
|
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-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-D7RCAGEA.cjs"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-RGTRZ2JX.js"}
|
package/dist/Controller.d.cts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { M as Movie } from './Movie-
|
|
1
|
+
import { M as Movie } from './Movie-CghFXprP.cjs';
|
|
2
2
|
import 'pixi.js';
|
|
3
3
|
import 'gsap';
|
|
4
|
-
import './types-
|
|
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;
|
package/dist/Controller.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { M as Movie } from './Movie-
|
|
1
|
+
import { M as Movie } from './Movie-DRZtIGU7.js';
|
|
2
2
|
import 'pixi.js';
|
|
3
3
|
import 'gsap';
|
|
4
|
-
import './types-
|
|
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;
|