pixi-effects 0.21.0 → 0.25.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 +46 -0
- package/README.md +9 -7
- package/ai/SKILL.md +5 -3
- package/ai/chat-template.html +3 -3
- package/ai/reference/cheatsheet.md +10 -7
- package/ai/reference/pitfalls.md +19 -3
- package/ai/reference/recipes.md +161 -0
- package/ai/template.html +5 -5
- package/ai/tools/check.mjs +21 -3
- package/dist/{Base-CzfLGoDr.d.ts → Base-BgnRXbtJ.d.ts} +1 -1
- package/dist/{Base-wYJQ2g5Q.d.cts → Base-DHVKqA46.d.cts} +1 -1
- package/dist/Composition-2GJSCHPP.cjs +16 -0
- package/dist/{Composition-2UFPSNVN.cjs.map → Composition-2GJSCHPP.cjs.map} +1 -1
- package/dist/Composition-C4FSWJZF.js +7 -0
- package/dist/{Composition-BVI236ZR.js.map → Composition-C4FSWJZF.js.map} +1 -1
- package/dist/Controller.cjs +2 -2
- package/dist/Controller.d.cts +2 -2
- package/dist/Controller.d.ts +2 -2
- package/dist/Controller.js +1 -1
- package/dist/{Movie-mtX9AkQ-.d.cts → Movie-CTpN1wYL.d.cts} +3 -1
- package/dist/{Movie-CgYwa5QG.d.ts → Movie-FM5m67V0.d.ts} +3 -1
- package/dist/Presenter.cjs +2 -2
- package/dist/Presenter.d.cts +2 -2
- package/dist/Presenter.d.ts +2 -2
- package/dist/Presenter.js +1 -1
- package/dist/blendModes-G3NOAU3T.js +90 -0
- package/dist/blendModes-G3NOAU3T.js.map +1 -0
- package/dist/blendModes-YV43K6E7.cjs +94 -0
- package/dist/blendModes-YV43K6E7.cjs.map +1 -0
- package/dist/chunk-3DLYAPK7.cjs +141 -0
- package/dist/chunk-3DLYAPK7.cjs.map +1 -0
- package/dist/chunk-AXZUKNXF.js +132 -0
- package/dist/chunk-AXZUKNXF.js.map +1 -0
- package/dist/{chunk-MNM56B3X.js → chunk-BXOCEZ4U.js} +22 -3
- package/dist/chunk-BXOCEZ4U.js.map +1 -0
- package/dist/{chunk-BPW42X7C.cjs → chunk-DP37TTMO.cjs} +863 -314
- package/dist/chunk-DP37TTMO.cjs.map +1 -0
- package/dist/chunk-ECA2WNRX.cjs +289 -0
- package/dist/chunk-ECA2WNRX.cjs.map +1 -0
- package/dist/{chunk-DDIXIWXK.js → chunk-PRZ4U6JN.js} +835 -290
- package/dist/chunk-PRZ4U6JN.js.map +1 -0
- package/dist/chunk-SY27HZC5.cjs +1727 -0
- package/dist/chunk-SY27HZC5.cjs.map +1 -0
- package/dist/chunk-TLYTYBWX.js +282 -0
- package/dist/chunk-TLYTYBWX.js.map +1 -0
- package/dist/{chunk-WTRKDIKH.cjs → chunk-TWZNOUM7.cjs} +22 -3
- package/dist/chunk-TWZNOUM7.cjs.map +1 -0
- package/dist/chunk-XS6GSJCN.js +1724 -0
- package/dist/chunk-XS6GSJCN.js.map +1 -0
- package/dist/index.cjs +227 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +167 -14
- package/dist/index.js.map +1 -1
- package/dist/loader.css +1 -1
- package/dist/{music-CFEC7HKL.js → music-AF4VTFAF.js} +3 -3
- package/dist/{music-CFEC7HKL.js.map → music-AF4VTFAF.js.map} +1 -1
- package/dist/{music-UAN7DXK4.cjs → music-KB7YZLHP.cjs} +7 -7
- package/dist/{music-UAN7DXK4.cjs.map → music-KB7YZLHP.cjs.map} +1 -1
- package/dist/{sfx-EX4PUBMV.cjs → sfx-2O6UQEUP.cjs} +8 -8
- package/dist/{sfx-EX4PUBMV.cjs.map → sfx-2O6UQEUP.cjs.map} +1 -1
- package/dist/{sfx-U3GM2MED.js → sfx-VUKARHDX.js} +3 -3
- package/dist/{sfx-U3GM2MED.js.map → sfx-VUKARHDX.js.map} +1 -1
- package/dist/three.cjs +7 -6
- package/dist/three.cjs.map +1 -1
- package/dist/three.d.cts +2 -2
- package/dist/three.d.ts +2 -2
- package/dist/three.js +3 -2
- package/dist/three.js.map +1 -1
- package/dist/{types-YmUaViL1.d.ts → types-CoT0ypcx.d.cts} +66 -7
- package/dist/{types-YmUaViL1.d.cts → types-CoT0ypcx.d.ts} +66 -7
- package/docs/dsl.md +180 -6
- package/llms-full.txt +375 -19
- package/package.json +1 -1
- package/dist/Composition-2UFPSNVN.cjs +0 -14
- package/dist/Composition-BVI236ZR.js +0 -5
- package/dist/chunk-6JJC37ZD.cjs +0 -790
- package/dist/chunk-6JJC37ZD.cjs.map +0 -1
- package/dist/chunk-BPW42X7C.cjs.map +0 -1
- package/dist/chunk-DDIXIWXK.js.map +0 -1
- package/dist/chunk-MNM56B3X.js.map +0 -1
- package/dist/chunk-NGYGFGDS.js +0 -788
- package/dist/chunk-NGYGFGDS.js.map +0 -1
- package/dist/chunk-WTRKDIKH.cjs.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
|
|
5
|
+
One release holds three milestones that were built one after the other: **composition** (named mattes, the luma wipe), **light** (lights, shadows, fog) and **freedom of drawing** (the shader layer, the `warp` filter). Wipe, iris and dissolve transitions now also work on WebGL (they drew nothing there before).
|
|
6
|
+
|
|
7
|
+
**Added**
|
|
8
|
+
|
|
9
|
+
- **Light, shadows and fog for 2.5D.** A `{ type: 'light', kind: 'ambient' | 'point' | 'spot' | 'parallel' }` layer (a carrier like the camera: its numbers go in `initial` / `keyframes`) lights every `threeD` layer of its composition, per pixel, in the layer's own mesh shader (no extra pass; GLSL and WGSL): Lambert shading with `falloff` (`none`, `smooth`, `inverseSquare`), a spot's cone and feather, a sun. `lit: false` opts a layer out. Shadows need `castsShadows` on the light AND on the layer: hard by default, `shadowDiffusion` for a penumbra that widens with distance, up to 4 casters per receiver, the strongest wins. The camera takes `fogNear` / `fogFar` / `fogColor` / `fogAmount` for depth fog. A composition with no light and no fog builds nothing and draws the same picture at the same cost: frames of all ten `threeD` pages of the repository are pixel-identical before and after. Checked against an independent JS reference (every pixel within 2/255) on WebGPU and WebGL for every kind, the falloffs, a card turned on two axes, a card seen from its back, fog, shadows (hard, soft, two and four casters, a turned caster), and together with depth of field, blend modes, the layer's own filters, cards, nested and time-remapped compositions and colour keyframes. Cost at 1080p on one machine: lights add 1 to 2.6 ms; soft shadows are the expensive part (3 soft lights over 20 layers: about 20 ms). Warnings, said once at build with a fix: no ambient light, a half-set `castsShadows`, too many lights or casters (which are dropped), 3 or more lights with soft shadows, misplaced keys and aliases (`target` → `lookAtX/Y/Z`, `angle` → `coneAngle`, `castShadows` → `castsShadows`, `intensity: 80`), `fogFar` not beyond `fogNear`. `movie.inspect` gives each lit layer a `light` number and `check --at` prints them most lit first.
|
|
10
|
+
- **Shader layer.** `{ type: 'shader', fragment, uniforms, width, height, resolution, transparent }` draws a Shadertoy `mainImage` (GLSL ES 3.00) as a layer: `iResolution`, `iTime` (the layer's own seconds; a time-remapped composition's clock inside it) and `iFrame` are declared for you, and so is every `uniforms` entry (a number is a `float`, 2 to 4 numbers a vector, a hex colour a `vec3`); keyframes move them as `'uniforms.speed'` and `'uniforms.tint.0'`. It is a layer like any other: masks, named mattes (it can be a matte too), filters, blend modes, `threeD` and lights work on it. Every shader layer draws with ONE shared WebGL2 context (a context per layer would hit the browser's limit of about 16) and copies the result into a canvas of its own; a lost context is made again on the next frame. A compile error is reported in the author's own line numbers (`line 2: 'x' : undeclared identifier`) and the layer is a magenta checkerboard, never a silent blank. Static warnings with a fix: no `mainImage`, `gl_FragColor`, `texture2D`, `varying`, `#version`, a declared built-in, `iMouse` / `iChannel` / `iDate` (not provided), a `while` loop or a `for` with a bound that is not a constant, a bad `uniforms` value or name, `resolution`, `transparent`. Checked on WebGPU and WebGL: pixels against the formula, every seek order identical, error lines, transparency (premultiplied), layers of different sizes on one context, a rebuilt movie, a lost context, masks, mattes, filters, blend, `threeD`, lights, export. Cost in a prototype at 1080p on one machine: 20 small heavy shaders +11 to +17 ms a frame, one full-size shader about +10 ms. No `iChannel` textures, mouse or feedback in this version, and a shader cannot read what is behind it (it is a layer, not a filter).
|
|
11
|
+
- **`warp` filter.** `{ type: 'warp', kind: 'wave' | 'haze', strength, scale, speed, angle, seed }` bends a layer's picture: a travelling wave (water, a flag) or drifting noise (heat haze). Its own GLSL and WGSL, a pure function of the frame's time through the same `TimeFilter` hook as `grain` (the sub-frames of a motion-blurred frame share one time), padded by `strength` so a shifted edge is not cut, options animated by the filter's name. Checked stripe by stripe against a JS reference on both backends (a worked-by-hand wave and constants from an independent implementation of the integer hash for `haze`). Aliases `distort`, `displace`, `ripple`, `wavy`, `heatHaze` point to it.
|
|
12
|
+
- **Docs and examples:** `docs/dsl.md` (`shader`, the filter table and a distortion paragraph), the cheatsheet, SKILL, recipes `shader-plasma` and `warp-water`, pitfalls 89–92, the guide "Shaders and warps", Playground example 20, and a gallery piece (50 now): `shader-garden`, four formulas and no pictures (a flowing field under a title bent like water, contour lines of a noise terrain seen only through letters, ripples through a disc), made by a Claude Sonnet 5.5 subagent from the docs alone (its notes are in `examples/gallery/_notes/`; the two gaps it found are fixed: a mask cuts what the shader drew and does not move the pattern, and a small shader layer sits by its top-left corner).
|
|
13
|
+
- **Docs and examples:** `docs/dsl.md` (Lights, Shadows, Fog), the cheatsheet, SKILL, recipes `spot-room` and `fog-depth`, pitfalls 85–88, the 2.5D guide, Playground example 19, and a gallery piece (49 now): `spotlight-room`, a dark room where one tight lamp picks out three pictures and they throw shadows on the wall, with slow camera and depth fog (made by a Claude Sonnet 5.5 subagent from the docs alone; its stumble notes are in `examples/gallery/_notes/`, and the gaps it found are fixed: the size of a spot's pool and the card that hides it, a hard shadow reading as a slab, how to build a floor, the `check` light line cut at 8 layers).
|
|
14
|
+
|
|
15
|
+
- **Named mattes.** A layer can be named and used as the `mask` of any number of layers in the same composition: `mask: 'band'` (the matte reads its opacity), `mask: { layer: 'band', channel: 'luma', invert: true }` (its brightness, Rec. 709; the opposite) and a list `mask: ['panel', { layer: 'hole', invert: true }]` (an intersection; subtracting a matte is inverting it). The matte layer itself is not drawn: it is drawn once per frame into a texture (so three layers sharing a moving matte cost one pass for the matte) and a `MatteFilter` cuts each layer that names it. `blendMode` works with every matte. Warnings, said once at build with a fix: a name nobody has (did-you-mean), the same name twice, a matte that is not alive for as long as its users (they would vanish), a `threeD` matte or a masked `threeD` layer, a matte with a mask of its own, `maskInverted` written with a named matte, a `channel` that is not `alpha` / `luma` (`luminance` → `luma`), a masked layer with a `parent`, and more than 8 mattes in one composition (each is a texture the size of the composition: about 8 MiB at 1080p). `inspect` skips matte layers.
|
|
16
|
+
- **Luma wipe.** The transition `{ kind: 'luma', from, to, at, duration, map, softness, flip }` follows a brightness map: dark parts change first. `map` is `'linear'`, `'diagonal'`, `'radial'` (computed on the GPU from the position on screen, a circle whatever the aspect ratio: no image, no canvas) or the name of a grayscale image asset; `softness` is the soft band (0.1), `flip` reverses it. An unknown map name warns once with the closest built-in. A layer a transition wraps keeps its named matte.
|
|
17
|
+
- **A gallery piece for mattes and the luma wipe** (48 now): `matte-reel`, one moving matte shared by gradient "photographs", hairlines and captions, an inverted hole, a headline that blends in through a luma ramp, a multiply band, and a luma wipe (with a venetian-blind map drawn on a canvas) to an end card (made by a Claude Sonnet 5.5 subagent from the docs alone; its stumble notes are in `examples/gallery/_notes/`, and the two gaps it found are fixed in the docs: outside the matte layer's shape the matte is 0 for `luma` too, and a whole composition can be the masked layer).
|
|
18
|
+
- **Docs and examples:** `docs/dsl.md` (Named mattes and luma, `luma` transition, blend rule 10), the cheatsheet, SKILL, recipes `shared-matte` and `luma-wipe`, pitfalls 81–84, the transitions and images guides, and Playground example 18.
|
|
19
|
+
|
|
20
|
+
**Changed**
|
|
21
|
+
|
|
22
|
+
- **Inverted masks (`maskInverted`) and text, image and video masks now go through the matte filter** (the same one as a named matte); a plain shape mask that is not inverted stays the stencil (the cheapest, hard-edged). The `AlphaMask` code, the `erase` wrapper group of 0.22 and the warning "a text mask with a blend loses the blend" are gone: **`blendMode` now works with every kind of mask**. Measured before and after on the 16 gallery and example pages that use masks (frames at 0, 25, 50, 75 and 99 %): 14 are pixel-identical (largest difference 0 to 1 of 255), `ma` differs in 10 edge pixels (largest 11 of 255), and the `05-composition-mask` example now draws its `maskInverted` hole correctly inside a nested composition (it was cut off at the bottom, and missing in the last frames).
|
|
23
|
+
|
|
24
|
+
**Fixed**
|
|
25
|
+
|
|
26
|
+
- **Wipe, iris and dissolve transitions did nothing on WebGL**: their fragment shader declared the frame uniforms without `highp`, so it did not link ("Precisions of uniform 'uOutputFrame' differ between VERTEX and FRAGMENT shaders") and the transition drew nothing (WebGPU, the default, was fine). The transitions are now tested on both backends.
|
|
27
|
+
|
|
28
|
+
## 0.22.0
|
|
29
|
+
|
|
30
|
+
**Added**
|
|
31
|
+
|
|
32
|
+
- **Blend modes: 18, the CSS names.** `blendMode` takes `overlay`, `soft-light`, `hard-light`, `color-dodge`, `color-burn`, `darken`, `lighten`, `difference`, `exclusion`, `hue`, `saturation`, `color`, `luminosity` and `linear-burn` besides `normal`, `add`, `screen` and `multiply`. They are our own blend filters, checked against the W3C formulas on WebGPU and WebGL (within 2/255: measured worst 0.5/255 at alpha 1 and 1.5/255 at alpha 0.5), in a separate chunk that a movie loads only when a layer uses one (nothing changes for the others), and they work on a layer with filters, on a `threeD` layer (after the depth-of-field blur) and on a composition (each child blends). A typo (`softlight`, `soft_light`) says what was meant; 10 or more on screen at once warns (about 2 ms each on WebGL in the author's measurement). The recipe `light-leak`, pitfalls 78–80, Playground example 17, and the gallery piece `light-leaks` (47 now: a dusk city told by five blend modes, made by a Claude Sonnet 5.5 subagent from the docs alone; its stumble notes added to the docs that `soft-light` / `overlay` need a mid-tone backdrop, that `color-dodge` over dark gives a saturated colour and that `blendMode` is not animatable).
|
|
33
|
+
|
|
34
|
+
**Changed**
|
|
35
|
+
|
|
36
|
+
- Blend modes: the same blend-mode mistake in many layers (particles make hundreds) is one warning with a count, not hundreds; an advanced mode while the blend filters are not registered warns on a layer with no filters too (Pixi's own message used to be the only one); if the `blendModes-*.js` chunk cannot be loaded the movie still plays (a warning, the advanced blends drawn normal) instead of failing to load; `linear-dodge` / `plus-lighter` (add), `luminance` (luminosity) and `exclude` (exclusion) are understood in the did-you-mean; the many-blends warning follows a negative `at` correctly and counts through nested compositions (a card with three blends in it, five cards on screen together: fifteen); the blend filters, groups and carriers a composition made are destroyed with it.
|
|
37
|
+
|
|
38
|
+
**Fixed**
|
|
39
|
+
|
|
40
|
+
- `blendMode` together with `maskInverted` rendered the layer black (the layer is drawn into a texture for the alpha mask, and its blend applied inside that texture against nothing; wrapping it in a group with a blend filter did not help either, because Pixi 8.22 skips a parent's filter when an alpha-masked layer is inside). A layer with both is now built as a group whose filter blends, with the mask shape drawn over the layer with the `erase` blend; it works with every mode on WebGPU and WebGL. A **text, image or video** layer as a (not inverted) mask still cannot carry a blend: it warns and draws the layer normal. A mask with filters of its own (a feathered hole) works too.
|
|
41
|
+
- Depth of field: a layer that was hidden (its lifespan over, `alpha` 0) kept the depth of the last frame it was drawn, so it was still counted as blurred: the "N layers are blurred" warning could fire for a movie that had only two layers on screen, and `inspect` returned a different `depthBlur` for the same frame depending on how you had got there. And `focus` could not name a layer called `hero card` or `3d-title` (only names shaped like a word): any name a sibling layer has now works.
|
|
42
|
+
|
|
43
|
+
**Changed**
|
|
44
|
+
|
|
45
|
+
- Depth of field warns about more mistakes: a negative or non-numeric `aperture` (it was silently off), two layers with the name `focus` points at (the first `threeD` one is used, as before, and now the check and the build agree on it), and `focus` / `aperture` written on a layer that is not a camera.
|
|
46
|
+
- `check.mjs --help` prints the usage (it was rejected as an unknown option), and `--at` prints each threeD layer's blur at every moment you name (`depth 2.40s — title 0 px, badge 10.12 px, …`; all of it is in `report.json` as `at[].depthBlur`).
|
|
47
|
+
- **Docs:** a dolly with no typed home distance (`offsetZ` on an automatic `z`, or the expression `H / 2 / tan(20 * PI / 180)`), how big a bokeh card must be to fill the frame at a depth (`(W / s) × (H / s)`), pitfall 77.
|
|
48
|
+
|
|
3
49
|
## 0.21.0
|
|
4
50
|
|
|
5
51
|
**Added**
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# pixi-effects
|
|
2
2
|
|
|
3
|
-
> **Status**: experimental — current release `0.
|
|
3
|
+
> **Status**: experimental — current release `0.25.0`. The API may still change between minor versions; see the [CHANGELOG](./CHANGELOG.md) for what each release changed.
|
|
4
4
|
|
|
5
|
-
**[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** ·
|
|
5
|
+
**[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 50 portfolio pieces written as plain data by AI models · **[Examples →](https://yjmtmtk.github.io/pixi-effects/examples/)** · 15 numbered examples, a music lab + an in-browser playground.
|
|
6
6
|
|
|
7
7
|
## Make a video by asking an AI
|
|
8
8
|
|
|
@@ -51,8 +51,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
|
|
|
51
51
|
"gsap": "https://esm.sh/gsap@3.12.5",
|
|
52
52
|
"gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
|
|
53
53
|
"mediabunny": "https://esm.sh/mediabunny",
|
|
54
|
-
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
55
|
-
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
54
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.25.0/dist/index.js",
|
|
55
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.25.0/dist/Controller.js"
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
</script>
|
|
@@ -63,7 +63,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
|
|
|
63
63
|
</script>
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.
|
|
66
|
+
Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.25.0/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
|
|
67
67
|
|
|
68
68
|
> **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
|
|
69
69
|
>
|
|
@@ -79,7 +79,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
|
|
|
79
79
|
"imports": {
|
|
80
80
|
"...": "(everything from the importmap above)",
|
|
81
81
|
"three": "https://esm.sh/three@0.178.0",
|
|
82
|
-
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
82
|
+
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.25.0/dist/three.js"
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
</script>
|
|
@@ -166,7 +166,7 @@ sequences: [
|
|
|
166
166
|
]
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
-
`+z` is toward the viewer, rotations are degrees (CSS signs), and with `z = 0` and the default camera a `threeD` layer looks identical to a 2D one. See [DSL reference § 3D layers & camera](./docs/dsl.md#3d-layers--camera) and [`examples/11-depth.html`](./examples/11-depth.html).
|
|
169
|
+
`+z` is toward the viewer, rotations are degrees (CSS signs), and with `z = 0` and the default camera a `threeD` layer looks identical to a 2D one. Add `{ type: 'light', kind: 'spot', … }` layers for shading and shadows, and `fogNear` / `fogFar` on the camera for depth fog (nothing changes in a composition with neither). See [DSL reference § 3D layers & camera](./docs/dsl.md#3d-layers--camera) and [`examples/11-depth.html`](./examples/11-depth.html).
|
|
170
170
|
|
|
171
171
|
## For AI agents
|
|
172
172
|
|
|
@@ -217,6 +217,8 @@ Rendering uses the [WebCodecs API](https://developer.mozilla.org/docs/Web/API/We
|
|
|
217
217
|
| WebM (vp9)| ✅ | ✅ | ✅ | ✅ |
|
|
218
218
|
| MOV (avc) | ✅ | ✅ | ✅ | ✅ |
|
|
219
219
|
|
|
220
|
+
**Renderer:** the movie tries WebGPU first and falls back to WebGL (with a console warning) when WebGPU is missing or its device cannot start, so a page needs no setting. Every filter the library ships has a GLSL and a WGSL version. A frame is the same picture however it was reached **on one renderer**; the two renderers can differ in a few edge pixels, so do not compare an export from a WebGPU machine and one from a WebGL machine byte for byte. Tested on Chrome with an Apple GPU only (WebGPU on Metal, WebGL through ANGLE); other GPUs, Windows and Linux are untested.
|
|
221
|
+
|
|
220
222
|
Some codecs (notably `aac` audio in older Firefox) may need the [mediabunny polyfill encoders](https://mediabunny.dev/) (`@mediabunny/aac-encoder`, etc.).
|
|
221
223
|
|
|
222
224
|
## License
|
package/ai/SKILL.md
CHANGED
|
@@ -31,12 +31,14 @@ You describe a video as a tree of plain objects; the library plays it in a canva
|
|
|
31
31
|
- **Angles are degrees. `+z` is toward the viewer. Numbers can be expressions** (`'GW/2 - w/2'`; no `pi`; no per-frame variable).
|
|
32
32
|
- **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`.
|
|
33
33
|
- **Sound effects need no files:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` — presets `click pop swoosh swipe hit riser chime beep coin glitch typewriter`; knobs `pitch`, `brightness`, `seed`. The layer lasts as long as the sound (`duration` stretches it); a `riser` is loudest at its end (`at = hit − duration`), a `swoosh` 0.16 s in. A music file shorter than its layer goes silent — `loop: true`.
|
|
34
|
-
- **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.**
|
|
34
|
+
- **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.** **One mask for several layers: name the layer and write its name as the `mask`** (`mask: 'band'`; `{ layer, channel: 'luma', invert: true }`; a list intersects); the named layer is not drawn and must live as long as its users. A **luma wipe** is a transition: `{ kind: 'luma', map: 'radial' | 'linear' | 'diagonal' | '<grayscale asset>', softness, flip }`.
|
|
35
35
|
- **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; **depth of field** is two camera props, `focus` (a threeD layer's name or a z) and `aperture` (30 default, 0 off), and a rack focus is a keyframe on `focus`.
|
|
36
36
|
- **Group / parent layers = `{ type: 'null', name: 'rig' }`** (draws nothing, only moves) + `parent: 'rig'` on the layers that should follow it: their `x`/`y` are then measured from the null's origin, and the null's position / rotation / scale / alpha carry them (orbits, clock hands, grouped fades). Nulls chain. Only `null` layers can be parents; not for `threeD` layers.
|
|
37
37
|
- **Time:** `speed` (slow motion, backward) and `time` (a curve of the layer's own seconds: freeze, rewind, ramp) on video, audio-file and composition layers; put what you want to rewind in a composition. Children of such a composition are in ITS seconds. `ease: 'cubic-bezier(.4, 0, .2, 1)'` is the CSS curve. See the recipe `rewind-and-replay` and pitfalls 69–73.
|
|
38
|
-
- **Built in:** `animateText()` (per-letter / per-word / per-line entrances and exits in one call: `...animateText('Title', style, { x, y, align: 'center', at: 0.5, duration: 5, in: 'rise', out: 'fade', stagger: { each: 0.05 } })`), `splitText()` / `measureText()` (per-letter / per-word layers, exact positions, by hand), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`, seeded randomness (`rand(seed)` / `noise(x, seed)` in expressions, `random(seed)` in JS, never `Math.random()`) `wiggle()` (a seeded shake as keyframes; on a camera put it on `offsetX` / `offsetY` so it rides on the move) `cameraPath()` (a camera flight through `[x, y, z]` points as keyframes; layers it passes: `hideBehindCamera: true`) depth of field (camera `focus` + `aperture`: `{ type: 'camera', initial: { focus: 'title' } }` blurs the other threeD layers by depth; a rack focus is a keyframe on `focus`) `music` (a tune with no file: `{ type: 'audio', music: { bpm, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4' }, { inst: 'bass', notes: 'c2:2 g1:2' }], drums: { from: 8, kick: 'x...x...' } } }`: notes and chords as text, counted in beats; instruments `keys pluck pad bass sub lead bell musicbox`; `musicEnvelope(music)` feeds `react()`), `audioEnvelope()` / `bpmEnvelope()` + `react()` (visuals that follow music: `const env = await audioEnvelope('music.mp3', { frameRate })` before `movie.init`, then `keyframes: react(env, { duration, props: { scale: { base: 1, amount: 0.4, band: 'bass', release: 0.2 } } })`; `beats: true` pulses on the beats; `bpmEnvelope(120, …)` when there is no file), `deck()` + `Presenter` (a talk as one movie: pages, steps, stops that pause playback, arrow keys / click / swipe to move on: `...deck({ transition: { kind: 'slide', duration: 0.6 }, pages: [{ name, duration, stops, sequences }] })`, `new Presenter(movie, { canvas })` from `pixi-effects/presenter`; G = page overview, P = presenter view with notes and a timer; `pixi-effects-render talk.html -o talk.pdf` = a PDF of the pages), `particles()` (bursts, snow, confetti, sparks as seeded layers: `...particles({ count: 90, at: 2, life: [1, 1.8], area: { x: 640, y: 330 }, speed: [120, 420], gravity: 240, fade: { out: 0.6 } })`), `followPath()` (a layer along an SVG path: `keyframes: followPath({ d, duration, orient: true })`), path morphing (`{ shape: 'path', d, morphTo, initial/keyframes: morph 0 → 1 }`) and `stagger()` (delays for a wave of layers: `stagger(letters, { each: 0.05, from: 'center' })`, or `stagger(n, …)` for numbers; GSAP-style `each` / `amount` / `from` / `grid` / `ease`). **Not in the DSL (hand-roll with a JS loop):** other per-frame curves (waves, spirals: sample them into short linear keyframes)
|
|
39
|
-
- **
|
|
38
|
+
- **Built in:** `animateText()` (per-letter / per-word / per-line entrances and exits in one call: `...animateText('Title', style, { x, y, align: 'center', at: 0.5, duration: 5, in: 'rise', out: 'fade', stagger: { each: 0.05 } })`), `splitText()` / `measureText()` (per-letter / per-word layers, exact positions, by hand), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`, seeded randomness (`rand(seed)` / `noise(x, seed)` in expressions, `random(seed)` in JS, never `Math.random()`) `wiggle()` (a seeded shake as keyframes; on a camera put it on `offsetX` / `offsetY` so it rides on the move) `cameraPath()` (a camera flight through `[x, y, z]` points as keyframes; layers it passes: `hideBehindCamera: true`) depth of field (camera `focus` + `aperture`: `{ type: 'camera', initial: { focus: 'title' } }` blurs the other threeD layers by depth; a rack focus is a keyframe on `focus`) `music` (a tune with no file: `{ type: 'audio', music: { bpm, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4' }, { inst: 'bass', notes: 'c2:2 g1:2' }], drums: { from: 8, kick: 'x...x...' } } }`: notes and chords as text, counted in beats; instruments `keys pluck pad bass sub lead bell musicbox`; `musicEnvelope(music)` feeds `react()`), `audioEnvelope()` / `bpmEnvelope()` + `react()` (visuals that follow music: `const env = await audioEnvelope('music.mp3', { frameRate })` before `movie.init`, then `keyframes: react(env, { duration, props: { scale: { base: 1, amount: 0.4, band: 'bass', release: 0.2 } } })`; `beats: true` pulses on the beats; `bpmEnvelope(120, …)` when there is no file), `deck()` + `Presenter` (a talk as one movie: pages, steps, stops that pause playback, arrow keys / click / swipe to move on: `...deck({ transition: { kind: 'slide', duration: 0.6 }, pages: [{ name, duration, stops, sequences }] })`, `new Presenter(movie, { canvas })` from `pixi-effects/presenter`; G = page overview, P = presenter view with notes and a timer; `pixi-effects-render talk.html -o talk.pdf` = a PDF of the pages), `particles()` (bursts, snow, confetti, sparks as seeded layers: `...particles({ count: 90, at: 2, life: [1, 1.8], area: { x: 640, y: 330 }, speed: [120, 420], gravity: 240, fade: { out: 0.6 } })`), `followPath()` (a layer along an SVG path: `keyframes: followPath({ d, duration, orient: true })`), path morphing (`{ shape: 'path', d, morphTo, initial/keyframes: morph 0 → 1 }`) and `stagger()` (delays for a wave of layers: `stagger(letters, { each: 0.05, from: 'center' })`, or `stagger(n, …)` for numbers; GSAP-style `each` / `amount` / `from` / `grid` / `ease`). **Not in the DSL (hand-roll with a JS loop):** other per-frame curves (waves, spirals: sample them into short linear keyframes). (Draw-on strokes `trimEnd`, text that changes over time `set: { text }` / `visibleChars`, shake `wiggle()` and particles are built in.)
|
|
39
|
+
- **Shader layer and warp:** `{ type: 'shader', fragment: 'void mainImage(out vec4 fragColor, in vec2 fragCoord) { … }', uniforms: { speed: 1, tint: '#ff8040' } }` draws a Shadertoy-style formula (GLSL ES 3.00; `iTime` `iResolution` `iFrame` are declared; keyframes move `'uniforms.speed'`; opaque by default; it cannot read the layers behind it; a compile error comes back in your own line numbers). `filters: [{ type: 'warp', kind: 'wave' | 'haze', strength: 6 }]` bends a layer's picture like water or heat.
|
|
40
|
+
- **Light, shadow, fog (2.5D):** a `{ type: 'light', kind: 'ambient', initial: { intensity: 0.2 } }` plus a `spot` / `point` / `parallel` light (`castsShadows: true` on the light AND on the `threeD` layers that cast) shades every `threeD` layer by angle and distance; `lit: false` opts a layer out; camera `fogNear` / `fogFar` / `fogColor` fade what is far. What no light reaches is black (add the ambient light); layers are drawn in distance order (no depth buffer). `check --at` prints how lit each layer is.
|
|
41
|
+
- **Blend modes:** `blendMode` takes 18 CSS names (`add` `screen` `multiply` `overlay` `soft-light` `hard-light` `color-dodge` `color-burn` `darken` `lighten` `difference` `exclusion` `hue` `saturation` `color` `luminosity` `linear-burn`, hyphenated) on any layer: glows and light leaks (`add`, `color-dodge`), colour casts and contrast (`soft-light`, `overlay`), vignettes (`multiply`). It blends with everything below the layer; on a composition each child blends (as one picture: `threeD: true` or a filter); 10+ of the new modes on screen at once warn; it works with every mask.
|
|
40
42
|
- **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.
|
|
41
43
|
|
|
42
44
|
## Layout sanity (the cheap bugs a screenshot catches)
|
package/ai/chat-template.html
CHANGED
|
@@ -61,9 +61,9 @@
|
|
|
61
61
|
"gsap": "https://cdn.jsdelivr.net/npm/gsap@3.12.5/+esm",
|
|
62
62
|
"gsap/PixiPlugin": "https://cdn.jsdelivr.net/npm/gsap@3.12.5/PixiPlugin/+esm",
|
|
63
63
|
"mediabunny": "https://cdn.jsdelivr.net/npm/mediabunny@1.61.3/+esm",
|
|
64
|
-
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
65
|
-
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
66
|
-
"pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
64
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.25.0/dist/index.js",
|
|
65
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.25.0/dist/Controller.js",
|
|
66
|
+
"pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.25.0/dist/Presenter.js"
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
</script>
|
|
@@ -37,10 +37,10 @@ name, at, duration,
|
|
|
37
37
|
initial: { ...props applied before any keyframe },
|
|
38
38
|
keyframes: [ { at, duration, ease, set | to | from | (from + to), repeat?, yoyo?, repeatDelay? } ],
|
|
39
39
|
filters: [ { type: 'chromaKey', keyColor, threshold, smoothing, spill } | { type: 'glow', name?, outerStrength: 3, color: '#ffd166' } /* any named filter, see below */ | { type: 'custom', name, filter: <Pixi Filter> } ],
|
|
40
|
-
mask: <a layer spec
|
|
40
|
+
mask: <a layer spec> | 'matteName' | { layer: 'matteName', channel: 'alpha' | 'luma', invert: true } | [ …refs ], maskInverted, // an inline 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. A NAME points at a sibling layer (the matte, shared by any number of layers, NOT drawn itself, alive for its own `at`/`duration`); a list is an intersection; subtract = `invert: true`
|
|
41
41
|
filterArea: { x, y, width, height } // in the layer's OWN coordinates; lets blur/glow draw past the layer's bounds
|
|
42
42
|
threeD: true // opt into 2.5D (see below)
|
|
43
|
-
blendMode: 'normal' | 'add' | 'screen' | 'multiply' // add / screen brighten (glows, light
|
|
43
|
+
blendMode: 'normal' | 'add' | 'screen' | 'multiply' | 'overlay' | 'soft-light' | 'hard-light' | 'color-dodge' | 'color-burn' | 'darken' | 'lighten' | 'difference' | 'exclusion' | 'hue' | 'saturation' | 'color' | 'luminosity' | 'linear-burn' // CSS names (hyphenated); blends with EVERYTHING BELOW the layer; add / screen brighten (glows), multiply darkens, soft-light / overlay for colour casts and contrast, color-dodge for light sources; on a composition the children each blend (to blend it as one picture: threeD: true or a filter); the 14 new ones cost ~2 ms a layer on WebGL (10+ on screen warns)
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
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>'`. **These go in `initial` / keyframes, never at the top level of a shape or text layer** (top-level `x`, `rotation`, `strokeColor`, `fillAlpha` each warn "is not a … key"): the top level of a shape holds its geometry (`width height radius d startAngle endAngle cornerRadius anchorX anchorY`), `trimStart` / `trimEnd`, `strokeCap` / `strokeJoin`, `fillGradient`.
|
|
@@ -77,9 +77,11 @@ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha stro
|
|
|
77
77
|
|
|
78
78
|
**composition** — `width height duration sequences transitions`, **`speed`** (the composition's own time: its content plays backward, in slow motion, … — `time` keyframes give a freeze, a rewind, a ramp; a composition's sound and sfx follow; its own x / alpha keyframes stay in the outer time; **children are placed in the composition's local seconds**; the content is as long as the larger of `duration` and the farthest `time` it reaches (`speed: 2` → twice `duration`; `time` keyframes → their largest number), and children default to that length. A child whose life ends exactly where a held `time` stands is hidden during the hold (a life is [at, at + duration)): give it a `duration` that reaches past. `time` keyframes and ordinary ones (`x`, `alpha`, a `wiggle`) may share one `keyframes` list. A sound inside goes silent while the clock stands still). With `threeD: true` it is a **card**: its children are drawn into one texture (content outside its `width × height` is clipped: size it for a soft shadow too; a plain 2D composition does not clip, but is skipped when its whole rectangle is off screen) and move / rotate in depth together. Children use the composition's local coordinates and times. Position/rotate/scale it as a unit; to rotate/scale about its centre set `pivotX/pivotY` to the centre and `x/y` to where that point should sit.
|
|
79
79
|
|
|
80
|
+
**shader** — `{ type: 'shader', fragment: 'void mainImage(out vec4 fragColor, in vec2 fragCoord) { … }', uniforms: { speed: 1, tint: '#ff8040', pos: [0.5, 0.5] }, width?, height?, resolution?: 0.5, transparent?: false }` — a Shadertoy fragment shader drawn into a layer (GLSL ES 3.00): `iResolution` `iTime` (the layer's own seconds) `iFrame` and your uniforms are declared for you; keyframes move uniforms as `'uniforms.speed'` and components as `'uniforms.tint.0'`; opaque unless `transparent: true`; it cannot read what is behind it (no `iChannel` / `iMouse` / `iDate`); no `while` or non-constant `for`; a compile error is reported in your own line numbers and the layer is a checkerboard.
|
|
81
|
+
|
|
80
82
|
**null + `parent`** — `{ type: 'null', name: 'rig', initial: { x, y }, keyframes: [...] }` draws nothing; layers with `parent: 'rig'` are drawn inside it and follow its `x y rotation scale skew alpha pivotX pivotY`. Their `x / y` are measured **from the null's origin** (null at (640,360), child `x: 200` → drawn at 840), so rotating the null makes the child circle it. Nulls chain (`parent` on a null). Children are drawn at the null's place in the stack, together. Only `null` layers can be parents; not for `threeD` layers; a mask follows its layer.
|
|
81
83
|
|
|
82
|
-
**camera** (2.5D) — props in `initial`/keyframes: `x y z lookAtX lookAtY lookAtZ fov focus aperture` (defaults: centred, looking at the z = 0 plane, `fov` 40, `z` auto = `(H/2)/tan(fov/2)` ≈ 989 at 720p). **Depth of field:** `focus` = the sharp plane (a `threeD` layer's name = its first z, or a z number), `aperture` = the lens diameter in px (30 default, 0 off); writing either blurs the other `threeD` layers by their distance from the focus (a rack focus is a keyframe on `focus`; recipe `rack-focus`; `inspect` reports `depthBlur`). 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`.
|
|
84
|
+
**camera** (2.5D) — props in `initial`/keyframes: `x y z lookAtX lookAtY lookAtZ fov focus aperture` (defaults: centred, looking at the z = 0 plane, `fov` 40, `z` auto = `(H/2)/tan(fov/2)` ≈ 989 at 720p). **Depth of field:** `focus` = the sharp plane (a `threeD` layer's name = its first z, or a z number), `aperture` = the lens diameter in px (30 default, 0 off); writing either blurs the other `threeD` layers by their distance from the focus (a rack focus is a keyframe on `focus`; recipe `rack-focus`; `inspect` reports `depthBlur`, and `check --at 2.4` prints each layer's blur). A dolly with no typed distance: leave `z` automatic and animate `offsetZ`. 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`.
|
|
83
85
|
|
|
84
86
|
**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`.
|
|
85
87
|
|
|
@@ -89,7 +91,7 @@ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha stro
|
|
|
89
91
|
|
|
90
92
|
## 2.5D in one paragraph
|
|
91
93
|
|
|
92
|
-
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`.
|
|
94
|
+
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`. **Light:** `{ type: 'light', kind: 'ambient' | 'point' | 'spot' | 'parallel', falloff?: 'none' | 'smooth' | 'inverseSquare', castsShadows?, initial: { x, y, z, lookAtX, lookAtY, lookAtZ, intensity, color, coneAngle, coneFeather, radius, falloffDistance, shadowDarkness, shadowDiffusion } }` lights every `threeD` layer of the composition (`lit: false` opts out; what no light reaches is black, so add `kind: 'ambient'`); a shadow needs `castsShadows: true` on the light AND on the layer (hard by default, `shadowDiffusion` softens; up to 4 casters per receiver). **Fog:** camera `initial: { fogNear, fogFar, fogColor, fogAmount }` (any one turns it on).
|
|
93
95
|
|
|
94
96
|
## Poster
|
|
95
97
|
|
|
@@ -101,15 +103,16 @@ Add `threeD: true` to any visual layer, then use `z`, `rotationX`, `rotationY` (
|
|
|
101
103
|
|
|
102
104
|
## Filters by name (no import)
|
|
103
105
|
|
|
104
|
-
`filters: [{ type: 'glow', name: 'halo', outerStrength: 3, color: '#ffd166' }]` — `type` = the pixi / pixi-filters class name without `Filter`, camelCase, or this library's own **`grain`** (film grain: `{ type: 'grain', amount: 0.08, size: 1.5, seed: 0, fps: 24, color: 0 }`, seeded, new every 1/fps s, mid-tone weighted, same grain pattern on every GPU (values within 1/255), anchored to the filter's frame (it moves with a layer), no pixi-filters needed; on a layer or on `composition.filters`; it costs video bitrate; `noise` is NOT film grain: it is frozen and lifts blacks) (`glow dropShadow outline blur noise alpha adjustment hslAdjustment grayscale pixelate crt rgbSplit oldFilm glitch bulgePinch twist zoomBlur kawaseBlur radialBlur motionBlur emboss ascii colorOverlay advancedBloom bevel dot crossHatch tiltShift …`), the other keys are its options. `colorMatrix` takes `preset: 'sepia'|'grayscale'|'negative'|'polaroid'|'technicolor'|'vintage'|'kodachrome'|'browni'`. Name it to animate: `'filters.halo.outerStrength'` (scalars only; `pixelate` via `sizeX`/`sizeY`). Options: `glow` distance outerStrength innerStrength color alpha · `dropShadow` offset{x,y} blur alpha color · `outline` thickness color · `blur` strength · `adjustment` gamma contrast saturation brightness · `hslAdjustment` hue lightness saturation · `pixelate` size · `crt` curvature lineWidth noise vignetting · `rgbSplit` red/green/blue {x,y} · `oldFilm` sepia noise scratch vignetting · `glitch` slices offset · `twist` radius angle offset · `bulgePinch` center radius strength · `zoomBlur` strength center innerRadius · `colorOverlay` color alpha · `colorGradient` gradientType('linear'|'radial'|'conic') stops[{offset,color,alpha}] angle · `colorReplace` originalColor targetColor tolerance · `convolution` matrix(9) · `shockwave` / `godray` are driven by `time` (animate it) · `colorMap` / `simpleLightmap` need a texture (use `custom`). **Centres/offsets are CANVAS pixels** (default top-left corner): put them on the layer. Glow / shadow / blur draw outside the layer: widen `filterArea` (not for `grayscale` / `oldFilm`, which fill the margin black). Full table: `docs/dsl.md` → Named filters.
|
|
106
|
+
`filters: [{ type: 'glow', name: 'halo', outerStrength: 3, color: '#ffd166' }]` — `type` = the pixi / pixi-filters class name without `Filter`, camelCase, or this library's own **`warp`** (bends the picture: `{ type: 'warp', kind: 'wave' | 'haze', strength: 6 (px), scale: 80 (px), speed: 0.5, angle: 0, seed: 0 }`; `wave` = a travelling wave, `haze` = drifting noise; moves with the frame's time by itself; options animate by name) or **`grain`** (film grain: `{ type: 'grain', amount: 0.08, size: 1.5, seed: 0, fps: 24, color: 0 }`, seeded, new every 1/fps s, mid-tone weighted, same grain pattern on every GPU (values within 1/255), anchored to the filter's frame (it moves with a layer), no pixi-filters needed; on a layer or on `composition.filters`; it costs video bitrate; `noise` is NOT film grain: it is frozen and lifts blacks) (`glow dropShadow outline blur noise alpha adjustment hslAdjustment grayscale pixelate crt rgbSplit oldFilm glitch bulgePinch twist zoomBlur kawaseBlur radialBlur motionBlur emboss ascii colorOverlay advancedBloom bevel dot crossHatch tiltShift …`), the other keys are its options. `colorMatrix` takes `preset: 'sepia'|'grayscale'|'negative'|'polaroid'|'technicolor'|'vintage'|'kodachrome'|'browni'`. Name it to animate: `'filters.halo.outerStrength'` (scalars only; `pixelate` via `sizeX`/`sizeY`). Options: `glow` distance outerStrength innerStrength color alpha · `dropShadow` offset{x,y} blur alpha color · `outline` thickness color · `blur` strength · `adjustment` gamma contrast saturation brightness · `hslAdjustment` hue lightness saturation · `pixelate` size · `crt` curvature lineWidth noise vignetting · `rgbSplit` red/green/blue {x,y} · `oldFilm` sepia noise scratch vignetting · `glitch` slices offset · `twist` radius angle offset · `bulgePinch` center radius strength · `zoomBlur` strength center innerRadius · `colorOverlay` color alpha · `colorGradient` gradientType('linear'|'radial'|'conic') stops[{offset,color,alpha}] angle · `colorReplace` originalColor targetColor tolerance · `convolution` matrix(9) · `shockwave` / `godray` are driven by `time` (animate it) · `colorMap` / `simpleLightmap` need a texture (use `custom`). **Centres/offsets are CANVAS pixels** (default top-left corner): put them on the layer. Glow / shadow / blur draw outside the layer: widen `filterArea` (not for `grayscale` / `oldFilm`, which fill the margin black). Full table: `docs/dsl.md` → Named filters.
|
|
105
107
|
|
|
106
108
|
## Transitions (on the parent composition)
|
|
107
109
|
|
|
108
110
|
```js
|
|
109
|
-
transitions: [{ kind: 'crossfade' | 'wipe' | 'iris' | 'slide' | 'dip' | 'zoom' | 'dissolve',
|
|
111
|
+
transitions: [{ kind: 'crossfade' | 'wipe' | 'iris' | 'slide' | 'dip' | 'zoom' | 'dissolve' | 'luma',
|
|
110
112
|
from: 'nameA', to: 'nameB', at: 3, duration: 1, ease: 'none',
|
|
111
113
|
direction: 'left|right|up|down' /* wipe, slide */, mode: 'in|out' /* iris, zoom */,
|
|
112
|
-
smoothing, fromScale /* zoom */, scale, seed /* dissolve
|
|
114
|
+
smoothing, fromScale /* zoom */, scale, seed /* dissolve */,
|
|
115
|
+
map: 'linear' | 'diagonal' | 'radial' | '<grayscale image asset>', softness, flip /* luma */ }]
|
|
113
116
|
```
|
|
114
117
|
`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.
|
|
115
118
|
|
package/ai/reference/pitfalls.md
CHANGED
|
@@ -72,8 +72,8 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
|
|
|
72
72
|
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)*
|
|
73
73
|
44. **Zero-pad a counter with `format: { pad: 2 }`** (`18:42:{value}` shows `18:42:05`). A `{value}` placeholder that nothing sets or animates prints 0 and now warns: `{value}` is reserved, there is no way to print it literally. *(fixed)*
|
|
74
74
|
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)*
|
|
75
|
-
46. **Still not in the DSL (hand-roll):**
|
|
76
|
-
46b. **Blend modes exist now** (`blendMode
|
|
75
|
+
46. **Still not in the DSL (hand-roll):** `zIndex` (stack order is list order). *(Earlier round-two notes listed draw-on strokes, changing text, jitter and particle emitters here: they are built in now: `trimEnd`, `set: { text }` / `visibleChars`, `wiggle()`, `particles()`.)*
|
|
76
|
+
46b. **Blend modes exist now** (`blendMode`: `add` `screen` `multiply` and, since 0.22, 15 more CSS names (pitfalls 78–80), on any layer, threeD layers and compositions included; a composition's children inherit it). Additive white cores no longer read grey on a coloured background. It also works on a layer with `filters` (a blurred glow with `add` / `multiply`): the mode is applied by the last filter, because a container's blend inside a filter pass multiplied against nothing and rendered black. With `add`, use dim saturated colours: overlaps sum, and near-white sums to pure white. *(fixed)*
|
|
77
77
|
47. **Browser tooling: prefer `node ai/tools/check.mjs page.html`** — it runs its own private Chrome, so none of the following can bite. If you drive a browser by hand instead: the `agent-browser` daemon is shared, so a `contactSheet` can come back from another session: after saving an image, look at it and confirm it is YOUR piece. In zsh a variable holding `agent-browser --session x` is not split into words (use a small wrapper script). `inspect` now clips overlaps to the visible area, ignores the two scenes of a running transition, treats text inside a moving (panned) composition as moving, and does not report text scaled to 0 as "no size"; it still does not compare text with picture content or shapes.
|
|
78
78
|
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)*
|
|
79
79
|
49. **sfx timing and level.** An sfx layer's `duration` is the sound's length (it stretches, it does not trim). Align the loud point: a `riser` peaks at its end, a `swoosh` 0.16 s in. Many sounds at the same instant add up: above 0.9 the mix is limited and a warning names the layers to turn down. Preset names, knobs and voice fields are closed lists — a guess (`whoosh`, `ding`, `impact`, `sawtooth`, `lp`, `decay`) warns with the right word. *(fixed: warns)*
|
|
@@ -101,6 +101,22 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
|
|
|
101
101
|
71. **Sound follows `speed` like a tape: the pitch moves with it.** An audio file or a video's sound at `speed: 2` is an octave up; backward plays reversed. Not for `sfx` / `music` layers (warns): change an sfx with `pitch` (semitones), or put the sfx **inside a remapped composition** and it follows that clock (slower and lower, or backward).
|
|
102
102
|
72. **A video at `speed: 2` with no `duration` is half as long; with a `duration` longer than the file it holds the last frame** (add `loop: true` to repeat). A negative `time` counts back from the end of the file (`initial: { time: -2 }` = two seconds before the end).
|
|
103
103
|
73. **Know the shape of the curve before you time a beat to it.** `cubic-bezier(.2, .8, .2, 1)` does half of its change in the first 13 % of the time and 99 % by 74 % (not slow motion: over 6 s the first 2 s of content pass in 0.6 s); `.2, .8, .5, 1` has a shorter tail. GSAP's `power2` is a cubic (`power1` = t², `power3` = t⁴): `power2.in` has done 12.5 % at half time and 42 % at three quarters, so a rewind with it dismantles most of the title in the last third.
|
|
104
|
-
74. **`focus` as a layer name means that layer's FIRST `z
|
|
104
|
+
74. **`focus` as a layer name means that layer's FIRST `z`, and the name must be unique.** (Two layers with one name: the first `threeD` one is used and it warns.) The camera does not follow a layer whose `z` moves (it warns): to follow it, animate `focus` itself with numbers. A name no layer has, or a layer that is not `threeD`, warns and the `z = 0` plane is used (did-you-mean for typos).
|
|
105
105
|
75. **Depth of field blurs `threeD` layers only, and the layer's own depth decides it.** A 2D layer never blurs; a `threeD` layer at the focus depth stays sharp; the depth is measured along the camera's view direction, so with an orbiting or tilted camera a layer off to the side of the look-at point is a little deeper than its `z` says and blurs a little even at the focus `z`; one blur for the whole layer, so a tilted floor is one blur (split it into layers).
|
|
106
106
|
76. **`aperture` is the lens diameter in px, not a 0–1 strength.** 30 is the default (clearly shallow), 0 turns it off, 60 is strong, 100 is extreme (small bright objects speckle); the blur never exceeds 32 px. 20 or more blurred layers at once warns (one filter pass each, more on WebGPU).
|
|
107
|
+
77. **A dolly does not need the camera's home distance typed in:** keep `z` automatic and animate `offsetZ` (negative = closer). Writing `z` yourself means typing `H / 2 / tan(fov / 2)` (about 989 at 720p, fov 40; as an expression `H / 2 / tan(20 * PI / 180)`). A bokeh field is a few big cards, not dozens of layers: a card at depth `z` fills the frame at size `(W / s) × (H / s)`, `s = camZ / (camZ − z)`.
|
|
108
|
+
78. **A blend mode mixes the layer with everything BELOW it, in the order of the array.** The backdrop goes first, the blended layer after it; a layer placed before its backdrop blends with nothing. Names are CSS names with the hyphen: `soft-light`, not `softLight` (the warning says which you meant).
|
|
109
|
+
79. **On a plain `composition` a `blendMode` makes each child blend by itself, not the composition as one picture.** To blend the whole group give the composition `threeD: true` (z 0, default camera: nothing else changes) or a filter. A text, image or video layer as the (not inverted) `mask` cannot carry a blend (it warns, draws normal); a shape mask and `maskInverted` work.
|
|
110
|
+
80. **`soft-light` / `overlay` do almost nothing over near-black or near-white; `color-dodge` over dark gives a saturated colour, not white; `blendMode` cannot be keyframed** (use a second short-lived layer). **The 14 new modes cost about 2 ms a layer on WebGL; 10 or more on screen at once warns.** A glow, a haze or a colour cast is ONE layer with a gradient (`fillGradient`), not dozens of circles; keep `add` / `screen` / `multiply` for big piles of particles.
|
|
111
|
+
81. **A layer named in `mask` is a matte: it is not drawn, and it must be alive whenever the layers it cuts are.** `{ name: 'band', … }` plus `mask: 'band'` on the text hides the band itself; the matte's time is the composition's time (its own `at` / `duration`). Where the matte is missing the layers it cuts are invisible: the build warns when its lifespan does not cover its users. To show the band as well, add a second layer that looks the same.
|
|
112
|
+
82. **A `mask` name works inside one composition only.** The matte and the layers that name it are siblings in the same `sequences`. A layer inside a nested composition cannot name a matte outside it (warns, with the closest name if there is one, and the layer is drawn without the matte); a layer that a transition wraps keeps its matte. Put the matte inside the nested composition, or the layer next to it.
|
|
113
|
+
83. **`channel: 'luma'` reads brightness, `'alpha'` (the default) reads opacity.** A white shape is 1 for both, a black shape is 0 for luma and 1 for alpha, a grey `#808080` is 0.5 luma. Outside the matte layer's own shape both are 0, so a wide gradient rectangle slid across the layers is a soft moving reveal. Use `luma` with a grayscale image or a gradient layer; use the default for shapes and text. `luminance`, `brightness` and `grayscale` are not channel names: they warn ("did you mean luma?") and alpha is used.
|
|
114
|
+
84. **Subtract with `invert: true` in a list; a list multiplies.** `mask: ['panel', { layer: 'hole', invert: true }]` is "inside `panel`, outside `hole`". `maskInverted: true` is for an inline mask only: with a named matte it warns and tells you to write `invert: true`. A luma wipe's `map` is a built-in name (`linear` `diagonal` `radial`) or a grayscale image asset; an unknown name warns once and wipes as if the map were white.
|
|
115
|
+
85. **One light layer lights every `threeD` layer, and what no light reaches is black.** Add `{ type: 'light', kind: 'ambient', initial: { intensity: 0.2 } }` (a white ambient light at 1 is the same as no light). A layer you want flat-lit is `lit: false`. Layers without `threeD` are never lit, and a light only counts while it is alive (give it the scene's `at` / `duration`): with no light alive the layers are drawn unlit.
|
|
116
|
+
86. **A shadow needs `castsShadows: true` on the light AND on the layer.** Either alone shows nothing (the build says which half is missing). The caster has to be a `threeD` layer in front of the receiver (between it and the light); a receiver takes at most 4 casters. A hard shadow can look like a dark slab: `shadowDiffusion: 10`–`20` softens it, and soft shadows cost the most, so use them on one or two lights. A spot's pool on the wall has radius about `distance × tan(coneAngle / 2)` and can hide behind the card that stands in front of it.
|
|
117
|
+
87. **The light's numbers go in `initial` / `keyframes`; `kind`, `falloff` and `castsShadows` are on the layer.** `{ type: 'light', intensity: 1 }` warns; so do `target` / `pointOfInterest` (use `lookAtX / lookAtY / lookAtZ`), `angle` (`coneAngle`), `castShadows` (`castsShadows`) and `intensity: 80` (it is a multiplier, 1 = full, not a percentage). A spot needs a look-at point (it defaults to the middle of the picture).
|
|
118
|
+
88. **A room is layers in distance order, not geometry.** There is no depth buffer: a floor or wall is a big `threeD` layer whose origin is at its far edge (`anchorY: 0` / a `z` behind what stands on it), and planes that cross cannot be drawn. Each composition has its own lights: a light in a nested composition lights only that composition's layers. Fog is the camera's (`fogNear` / `fogFar` / `fogColor`), fades `threeD` layers only, so draw the background in the fog colour.
|
|
119
|
+
89. **A shader layer is written like Shadertoy, in GLSL ES 3.00: `mainImage` and `fragColor`.** Not `void main()`, `gl_FragColor`, `texture2D`, `varying`, `#version` or a `uniform float iTime;` (each warns with what to write). `iTime` is the layer's own seconds from its start (not the movie's), `fragCoord` starts at the bottom left, the colour is opaque unless `transparent: true`, and the layer cannot read what is behind it (it is a layer, not a filter): there is no `iChannel0`, `iMouse` or `iDate` in this version.
|
|
120
|
+
90. **`uniforms` are declared for you; keyframes name them `'uniforms.speed'`, and vectors and colours by component `'uniforms.tint.0'`.** `uniforms: { tint: '#ff8040' }` is a `vec3` of 0..1, so animate its channels, not the string. Declare every uniform you animate with a starting value (a keyframe on a name that is not there warns with the likely name). Do not write `uniform float speed;` yourself.
|
|
121
|
+
91. **A shader that does not compile is a magenta checkerboard, and the warning has your line numbers.** Fix the line it names. Loops need a constant bound (`for (int i = 0; i < 8; i++)`): a `while`, or a bound that is a variable, can hang the GPU and warns. A heavy shader is cheaper with `resolution: 0.5`; many small shader layers cost more than one big one. A mask or matte cuts what the shader drew, it does not move the pattern: put the pattern's centre (a `vec2` uniform) where the matte is. A shader layer with a small `width` / `height` sits by its top-left corner at `initial.x / y`. With `resolution` below 1 the shader's own pixels are fewer: write positions as `fragCoord / iResolution.xy`, or multiply pixel uniforms by `resolution`. A shader can itself be a mask (`mask: { type: 'shader', transparent: true, fragment }`: its alpha is the mask).
|
|
122
|
+
92. **`warp` is for bending a layer's picture, a `shader` layer is for drawing something new.** `warp` takes `strength` in px (a ripple is 4 to 10, over 200 is refused) and `scale` in px; `speed: 0` stands still. It follows the time of the frame by itself: do not keyframe a time. Its options animate by the filter's name (`'filters.ripple.strength'`). For a swirl or a lens use `twist` or `bulgePinch`.
|
package/ai/reference/recipes.md
CHANGED
|
@@ -775,6 +775,167 @@ return [
|
|
|
775
775
|
|
|
776
776
|
---
|
|
777
777
|
|
|
778
|
+
## Light leaks and colour casts (blend modes)
|
|
779
|
+
|
|
780
|
+
`blendMode` mixes a layer with everything **below** it. A colour cast is one `soft-light` gradient over the whole picture, a light leak is a `color-dodge` glow that drifts across it, a vignette is a `multiply` radial gradient that goes from clear to dark. Each is ONE layer (a gradient), not a pile of shapes: the blend modes beyond `add` / `screen` / `multiply` cost a full-frame pass each.
|
|
781
|
+
|
|
782
|
+
```js
|
|
783
|
+
// @recipe light-leak
|
|
784
|
+
const skyline = Array.from({ length: 9 }, (_, i) => ({
|
|
785
|
+
type: 'shape', shape: 'rect', name: 'block-' + i, width: 90 + (i % 3) * 30, height: 160 + ((i * 53) % 190), anchorX: 0, anchorY: 1,
|
|
786
|
+
initial: { x: 40 + i * 140, y: 650, fillColor: '#0d1220' },
|
|
787
|
+
}));
|
|
788
|
+
return { duration: 6, sequences: [
|
|
789
|
+
{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', anchorX: 0, anchorY: 0, initial: { x: 0, y: 0 },
|
|
790
|
+
fillGradient: { stops: [[0, '#2a3b66'], [0.55, '#8a5a78'], [1, '#e8a060']] } },
|
|
791
|
+
...skyline,
|
|
792
|
+
// a colour cast: orange at the top, blue at the bottom, over everything above
|
|
793
|
+
{ type: 'shape', shape: 'rect', name: 'cast', width: 'GW', height: 'GH', anchorX: 0, anchorY: 0, blendMode: 'soft-light', initial: { x: 0, y: 0, alpha: 0 },
|
|
794
|
+
fillGradient: { stops: [[0, '#ff9a3c'], [1, '#3c5bff']] },
|
|
795
|
+
keyframes: [{ at: 0.5, to: { alpha: 0.9 }, duration: 2, ease: 'sine.inOut' }] },
|
|
796
|
+
// a light leak: a strong source burning out toward white as it drifts in from the right
|
|
797
|
+
{ type: 'shape', shape: 'circle', name: 'leak', radius: 380, blendMode: 'color-dodge', initial: { x: 1500, y: 140 },
|
|
798
|
+
fillGradient: { type: 'radial', stops: [[0, 'rgba(255,120,30,0.9)'], [1, 'rgba(255,120,30,0)']] },
|
|
799
|
+
keyframes: [{ at: 1, to: { x: 900 }, duration: 3.5, ease: 'power2.out' }] },
|
|
800
|
+
// a vignette: clear in the middle, dark at the corners
|
|
801
|
+
{ type: 'shape', shape: 'rect', name: 'vignette', width: 'GW', height: 'GH', anchorX: 0, anchorY: 0, blendMode: 'multiply', initial: { x: 0, y: 0 },
|
|
802
|
+
fillGradient: { type: 'radial', radius: 0.8, stops: [[0.5, 'rgba(255,255,255,1)'], [1, 'rgba(60,60,80,1)']] } },
|
|
803
|
+
] };
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
---
|
|
807
|
+
|
|
808
|
+
## A matte shared by several layers (`mask: 'name'`)
|
|
809
|
+
|
|
810
|
+
Name a layer and write its name as the `mask` of the layers it should cut: the matte is drawn once and **not shown itself**. Here one band sweeps across three lines of text; one line is cut by brightness (`luma`) and one by the opposite of the band (`invert`), so it fills in where the others are cut.
|
|
811
|
+
|
|
812
|
+
```js
|
|
813
|
+
// @recipe shared-matte
|
|
814
|
+
const line = (y, text, mask, extra = {}) => ({
|
|
815
|
+
type: 'text', text, mask, style: { fontSize: 120, fill: '#ffffff', fontWeight: 'bold' },
|
|
816
|
+
initial: { x: 640, y, anchorX: 0.5, anchorY: 0.5 }, ...extra,
|
|
817
|
+
});
|
|
818
|
+
return { duration: 5, sequences: [
|
|
819
|
+
{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', anchorX: 0, anchorY: 0, initial: { x: 0, y: 0, fillColor: '#101626' } },
|
|
820
|
+
// the matte: a bright band that grows to the right; it lives as long as the layers it cuts
|
|
821
|
+
{ type: 'shape', shape: 'rect', name: 'band', anchorX: 0, anchorY: 0, width: 0, height: 720,
|
|
822
|
+
initial: { x: 140, y: 0, fillColor: '#ffffff' },
|
|
823
|
+
keyframes: [{ at: 0.4, to: { width: 1000 }, duration: 2, ease: 'power2.inOut' }] },
|
|
824
|
+
line(200, 'ONE MATTE', 'band'),
|
|
825
|
+
line(360, 'SHARED BY', { layer: 'band', channel: 'luma' }),
|
|
826
|
+
line(520, 'THREE LAYERS', { layer: 'band', invert: true }, { initial: { x: 640, y: 520, anchorX: 0.5, anchorY: 0.5, fillColor: '#ffd166' } }),
|
|
827
|
+
] };
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
---
|
|
831
|
+
|
|
832
|
+
## A luma wipe (a transition that follows a brightness map)
|
|
833
|
+
|
|
834
|
+
`{ kind: 'luma', map: 'radial' }` opens the next scene from the middle; `'linear'` sweeps left to right, `'diagonal'` corner to corner, or give the name of a grayscale image asset (dark parts change first). `softness` is the width of the soft edge and `flip` reverses the direction.
|
|
835
|
+
|
|
836
|
+
```js
|
|
837
|
+
// @recipe luma-wipe
|
|
838
|
+
const scene = (name, at, color, label) => ({
|
|
839
|
+
type: 'composition', name, at, duration: 4, width: 1280, height: 720,
|
|
840
|
+
sequences: [
|
|
841
|
+
{ type: 'shape', shape: 'rect', width: 1280, height: 720, initial: { x: 640, y: 360, fillColor: color } },
|
|
842
|
+
{ type: 'text', text: label, style: { fontSize: 120, fill: '#ffffff', fontWeight: 'bold' }, initial: { x: 640, y: 360, anchorX: 0.5, anchorY: 0.5 } },
|
|
843
|
+
],
|
|
844
|
+
});
|
|
845
|
+
return {
|
|
846
|
+
sequences: [scene('a', 0, '#3b5bdb', 'One'), scene('b', 3, '#e64980', 'Two')], // they overlap for 1 s: 3 to 4
|
|
847
|
+
transitions: [{ kind: 'luma', from: 'a', to: 'b', at: 3, duration: 1, map: 'radial', softness: 0.15 }],
|
|
848
|
+
duration: 7,
|
|
849
|
+
};
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
---
|
|
853
|
+
|
|
854
|
+
## A spotlight across a dark room (light and shadow)
|
|
855
|
+
|
|
856
|
+
An ambient light keeps the room from going black, a `spot` light sweeps across three cards and they throw shadows on the wall behind them. The shadow needs `castsShadows` on the light **and** on each card; `lookAtX` moves the beam and `x` moves the lamp with it.
|
|
857
|
+
|
|
858
|
+
```js
|
|
859
|
+
// @recipe spot-room
|
|
860
|
+
const card = (name, x, fill) => ({
|
|
861
|
+
type: 'shape', shape: 'rect', name, width: 300, height: 200, anchorX: 0.5, anchorY: 0.5, threeD: true, castsShadows: true,
|
|
862
|
+
initial: { x, y: 300, z: 0, fillColor: fill },
|
|
863
|
+
});
|
|
864
|
+
return { duration: 6, sequences: [
|
|
865
|
+
{ type: 'light', kind: 'ambient', initial: { intensity: 0.18 } },
|
|
866
|
+
{ type: 'light', kind: 'spot', castsShadows: true,
|
|
867
|
+
initial: { x: 160, y: -120, z: 700, lookAtX: 260, lookAtY: 330, lookAtZ: 0, coneAngle: 34, coneFeather: 0.7, intensity: 1.1, shadowDiffusion: 14 },
|
|
868
|
+
keyframes: [{ at: 0.5, to: { x: 1100, lookAtX: 1020 }, duration: 4.5, ease: 'sine.inOut' }] },
|
|
869
|
+
// the wall, big and behind the cards (a room is layers in distance order: no depth buffer)
|
|
870
|
+
{ type: 'shape', shape: 'rect', name: 'wall', width: 1800, height: 900, anchorX: 0.5, anchorY: 0.5, threeD: true, initial: { x: 640, y: 300, z: -260, fillColor: '#6b7280' } },
|
|
871
|
+
card('a', 300, '#e0a458'), card('b', 640, '#58a4e0'), card('c', 980, '#e05876'),
|
|
872
|
+
] };
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
---
|
|
876
|
+
|
|
877
|
+
## Fog that thickens with distance (camera fog)
|
|
878
|
+
|
|
879
|
+
Fog is the camera's (`fogNear`, `fogFar`, `fogColor`, `fogAmount`): `threeD` layers are mixed toward the fog colour by how far they are. 2D layers are not fogged, so the background is drawn in the fog colour and what is far dissolves into it.
|
|
880
|
+
|
|
881
|
+
```js
|
|
882
|
+
// @recipe fog-depth
|
|
883
|
+
const posts = Array.from({ length: 10 }, (_, i) => ({
|
|
884
|
+
type: 'shape', shape: 'rect', name: 'post-' + i, width: 80, height: 420, anchorX: 0.5, anchorY: 1, threeD: true,
|
|
885
|
+
initial: { x: 200 + (i % 2) * 880, y: 620, z: -i * 220, fillColor: '#1f2a44' },
|
|
886
|
+
}));
|
|
887
|
+
return { duration: 6, sequences: [
|
|
888
|
+
{ type: 'shape', shape: 'rect', name: 'sky', width: 'GW', height: 'GH', anchorX: 0, anchorY: 0, initial: { x: 0, y: 0, fillColor: '#9fb4d6' } },
|
|
889
|
+
{ type: 'camera', initial: { fogNear: 900, fogFar: 2600, fogColor: '#9fb4d6', fogAmount: 0.95 },
|
|
890
|
+
keyframes: [{ at: 0, to: { offsetZ: -700 }, duration: 6, ease: 'sine.inOut' }] }, // dolly in: the nearer posts come out of the mist
|
|
891
|
+
...posts,
|
|
892
|
+
] };
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
---
|
|
896
|
+
|
|
897
|
+
## A flowing colour field (a `shader` layer)
|
|
898
|
+
|
|
899
|
+
A shader is a layer drawn by a formula: write a Shadertoy `mainImage`. `iTime` is the layer's own clock, the `uniforms` are declared for you and keyframes move them (`'uniforms.speed'`, and a component of a colour as `'uniforms.tint.2'`). Opaque by default.
|
|
900
|
+
|
|
901
|
+
```js
|
|
902
|
+
// @recipe shader-plasma
|
|
903
|
+
return { duration: 6, sequences: [
|
|
904
|
+
{ type: 'shader', name: 'plasma',
|
|
905
|
+
fragment: `
|
|
906
|
+
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
|
907
|
+
vec2 uv = fragCoord / iResolution.xy;
|
|
908
|
+
float v = sin(uv.x * 9.0 + iTime * speed) + sin(uv.y * 7.0 - iTime * speed * 1.3) + sin((uv.x + uv.y) * 5.0 + iTime);
|
|
909
|
+
vec3 deep = vec3(0.04, 0.02, 0.18);
|
|
910
|
+
fragColor = vec4(mix(deep, tint, 0.5 + 0.25 * v), 1.0);
|
|
911
|
+
}`,
|
|
912
|
+
uniforms: { tint: '#ff7a3d', speed: 0.6 },
|
|
913
|
+
keyframes: [{ at: 0, to: { 'uniforms.speed': 2, 'uniforms.tint.2': 0.9 }, duration: 6, ease: 'sine.inOut' }] },
|
|
914
|
+
{ type: 'text', text: 'PLASMA', style: { fontSize: 140, fill: '#ffffff', fontWeight: 'bold', letterSpacing: 12 }, initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 } },
|
|
915
|
+
] };
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
---
|
|
919
|
+
|
|
920
|
+
## Text seen through water (the `warp` filter)
|
|
921
|
+
|
|
922
|
+
`warp` bends a layer's picture: `kind: 'wave'` is a travelling wave (the surface of water, a flag), `kind: 'haze'` is drifting noise (heat shimmer). `strength` is the largest shift in px, `scale` the wavelength in px, `speed` how fast it moves (0 stands still). It follows the frame's time by itself, and its options move with keyframes by the filter's name.
|
|
923
|
+
|
|
924
|
+
```js
|
|
925
|
+
// @recipe warp-water
|
|
926
|
+
return { duration: 6, sequences: [
|
|
927
|
+
{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', anchorX: 0, anchorY: 0, initial: { x: 0, y: 0 },
|
|
928
|
+
fillGradient: { stops: [[0, '#0b3a5c'], [1, '#021a2e']] } },
|
|
929
|
+
{ type: 'text', text: 'UNDER WATER', style: { fontSize: 120, fill: '#d8f3ff', fontWeight: 'bold', letterSpacing: 6 },
|
|
930
|
+
initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
|
|
931
|
+
filters: [{ type: 'warp', name: 'ripple', kind: 'wave', strength: 6, scale: 70, speed: 0.8, angle: 90 },
|
|
932
|
+
{ type: 'warp', kind: 'haze', strength: 3, scale: 30, speed: 0.4 }],
|
|
933
|
+
keyframes: [{ at: 0, to: { 'filters.ripple.strength': 16 }, duration: 6, ease: 'sine.inOut' }] },
|
|
934
|
+
] };
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
---
|
|
938
|
+
|
|
778
939
|
## A hand-held shake and a flickering light (`wiggle`, seeded)
|
|
779
940
|
|
|
780
941
|
`wiggle()` returns keyframes (spread it into `keyframes`): each property drifts between seeded random targets and ends back at `around`. Use `freq` ~3 for a slow float, 8+ for a shake, `ease: 'none'` for a jittery flicker. Per-layer variety: give each its own `seed`.
|
package/ai/template.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>pixi-effects video</title>
|
|
7
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.25.0/dist/loader.css" /> <!-- the loader: HTML + CSS only, so it is on screen at once and keeps moving while the page builds the video -->
|
|
8
8
|
<style>
|
|
9
9
|
body { background: #0d1220; margin: 0; padding: 16px; }
|
|
10
10
|
.stage { position: relative; width: min(960px, 100%); } /* the player and the loader both sit inside this box */
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"gsap": "https://esm.sh/gsap@3.12.5",
|
|
40
40
|
"gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
|
|
41
41
|
"mediabunny": "https://esm.sh/mediabunny",
|
|
42
|
-
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
43
|
-
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
44
|
-
"pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
45
|
-
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
42
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.25.0/dist/index.js",
|
|
43
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.25.0/dist/Controller.js",
|
|
44
|
+
"pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.25.0/dist/Presenter.js",
|
|
45
|
+
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.25.0/dist/three.js",
|
|
46
46
|
"pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js",
|
|
47
47
|
"three": "https://esm.sh/three@0.178.0"
|
|
48
48
|
}
|
package/ai/tools/check.mjs
CHANGED
|
@@ -39,12 +39,15 @@ export function findChrome(env = process.env, exists = fs.existsSync, platform =
|
|
|
39
39
|
return candidates.find(c => c && exists(c)) ?? null;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export const USAGE = 'usage: node ai/tools/check.mjs <page.html> [--out DIR] [--frames N] [--formats mp4,webm] [--no-export] [--draft] [--at 3.5,title@end] [--onion 1:3] [--query a=1&b=2] [--timeout S] [--root DIR] [--chrome PATH] [--strict] [--help]\n(the header of ai/tools/check.mjs explains each option)';
|
|
43
|
+
|
|
42
44
|
export function parseArgs(argv) {
|
|
43
|
-
const o = { page: null, out: null, frames: 12, formats: ['mp4'], export: true, timeout: 240, root: null, chrome: null, strict: false, at: null, draft: false, onion: null, query: null };
|
|
45
|
+
const o = { page: null, out: null, frames: 12, formats: ['mp4'], export: true, timeout: 240, root: null, chrome: null, strict: false, at: null, draft: false, onion: null, query: null, help: false };
|
|
44
46
|
const need = (i, name) => { if (i + 1 >= argv.length) throw new Error(`${name} needs a value`); return argv[i + 1]; };
|
|
45
47
|
for (let i = 0; i < argv.length; i++) {
|
|
46
48
|
const a = argv[i];
|
|
47
|
-
if (a === '--
|
|
49
|
+
if (a === '--help' || a === '-h') o.help = true;
|
|
50
|
+
else if (a === '--out') o.out = need(i++, a);
|
|
48
51
|
else if (a === '--frames') o.frames = Math.max(1, Math.round(Number(need(i++, a))) || 12);
|
|
49
52
|
else if (a === '--formats') o.formats = need(i++, a).split(',').map(s => s.trim()).filter(Boolean);
|
|
50
53
|
else if (a === '--no-export') o.export = false;
|
|
@@ -336,6 +339,12 @@ export async function runCheck(opts, log = console.log) {
|
|
|
336
339
|
for (const w of wanted) {
|
|
337
340
|
const png = await cdp.eval(`movie.snapshot(${w.frame}, { as: 'dataURL' })`);
|
|
338
341
|
fs.writeFileSync(path.join(framesDir, `${w.label}.png`), Buffer.from(png.slice(png.indexOf(',') + 1), 'base64'));
|
|
342
|
+
// depth of field: the blur (px) each threeD layer has at this moment, so a focus pull can be read as numbers (0 = sharp)
|
|
343
|
+
const rows = await cdp.eval(`movie.inspect(${w.frame}).then(r => r.layers.filter(l => l.depthBlur !== undefined).map(l => [l.path, l.depthBlur]))`).catch(() => []);
|
|
344
|
+
if (rows.some(([, px]) => px > 0)) w.depthBlur = Object.fromEntries(rows);
|
|
345
|
+
// light: how lit each threeD layer is at this moment (left out when the composition has no light)
|
|
346
|
+
const lit = await cdp.eval(`movie.inspect(${w.frame}).then(r => r.layers.filter(l => l.light !== undefined).map(l => [l.path, l.light]))`).catch(() => []);
|
|
347
|
+
if (lit.length) w.light = Object.fromEntries(lit);
|
|
339
348
|
}
|
|
340
349
|
const atSheet = await cdp.eval(`movie.contactSheet({ frames: ${JSON.stringify(wanted.map(w => w.frame))}, as: 'dataURL' })`);
|
|
341
350
|
const atFile = path.join(outDir, 'at.png');
|
|
@@ -469,6 +478,14 @@ function finish(report, outDir, log) {
|
|
|
469
478
|
if (rv.length > 4) L.push(` … ${rv.length - 4} more (report.json)`);
|
|
470
479
|
}
|
|
471
480
|
}
|
|
481
|
+
for (const w of (report.at ?? []).filter(x => x.depthBlur)) {
|
|
482
|
+
const shown = Object.entries(w.depthBlur).slice(0, 8).map(([k, px]) => `${k} ${px} px`).join(', ');
|
|
483
|
+
L.push(` depth ${w.label} — ${shown}${Object.keys(w.depthBlur).length > 8 ? ', …' : ''} (blur of each threeD layer at that moment; 0 = sharp)`);
|
|
484
|
+
}
|
|
485
|
+
for (const w of (report.at ?? []).filter(x => x.light)) {
|
|
486
|
+
const shown = Object.entries(w.light).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([k, v]) => `${k} ${v}`).join(', '); // the most lit first
|
|
487
|
+
L.push(` light ${w.label} — ${shown}${Object.keys(w.light).length > 8 ? ', …' : ''} (the most lit threeD layers first: 1 = as bright as with no light, 0 = black; the number is the layer's middle, not what you can see)`);
|
|
488
|
+
}
|
|
472
489
|
if (report.stops) {
|
|
473
490
|
const s = report.stops;
|
|
474
491
|
L.push(` stops ${s.count} stop(s) on ${s.pages} page(s) — stops.png shows each one in order; the picture at a stop is what the audience sees while it waits`);
|
|
@@ -501,8 +518,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
501
518
|
try {
|
|
502
519
|
if (typeof WebSocket === 'undefined') throw new Error('Node >= 22 is needed (built-in WebSocket)');
|
|
503
520
|
const opts = parseArgs(process.argv.slice(2));
|
|
521
|
+
if (opts.help) { console.log(USAGE); process.exit(0); }
|
|
504
522
|
if (!opts.page) {
|
|
505
|
-
console.error(
|
|
523
|
+
console.error(USAGE);
|
|
506
524
|
process.exit(2);
|
|
507
525
|
}
|
|
508
526
|
const report = await runCheck(opts);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
|
|
2
|
-
import { S as SequenceSpec, C as CompositionShape,
|
|
2
|
+
import { S as SequenceSpec, C as CompositionShape, a6 as AudioDescriptor } from './types-CoT0ypcx.js';
|
|
3
3
|
import { gsap } from 'gsap';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
|
|
2
|
-
import { S as SequenceSpec, C as CompositionShape,
|
|
2
|
+
import { S as SequenceSpec, C as CompositionShape, a6 as AudioDescriptor } from './types-CoT0ypcx.cjs';
|
|
3
3
|
import { gsap } from 'gsap';
|
|
4
4
|
|
|
5
5
|
/**
|