pixi-effects 0.4.0 → 0.5.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 +30 -1
- package/README.md +9 -5
- package/ai/SKILL.md +7 -4
- package/ai/reference/cheatsheet.md +16 -5
- package/ai/reference/pitfalls.md +15 -11
- package/ai/reference/recipes.md +117 -0
- package/ai/template.html +6 -3
- package/ai/tools/check.mjs +385 -0
- package/dist/{Base-5R7C5pHR.d.cts → Base-CPSFBkPm.d.ts} +6 -1
- package/dist/{Base-DhY9oMxr.d.ts → Base-DnRAxroQ.d.cts} +6 -1
- package/dist/Composition-63EXHNJJ.cjs +13 -0
- package/dist/{Composition-D7RCAGEA.cjs.map → Composition-63EXHNJJ.cjs.map} +1 -1
- package/dist/Composition-TUCRSMJU.js +4 -0
- package/dist/{Composition-RGTRZ2JX.js.map → Composition-TUCRSMJU.js.map} +1 -1
- package/dist/Controller.cjs +37 -13
- package/dist/Controller.cjs.map +1 -1
- package/dist/Controller.d.cts +4 -2
- package/dist/Controller.d.ts +4 -2
- package/dist/Controller.js +37 -13
- package/dist/Controller.js.map +1 -1
- package/dist/{Movie-DRZtIGU7.d.ts → Movie-CuU_aaCm.d.cts} +56 -2
- package/dist/{Movie-CghFXprP.d.cts → Movie-VyDiUYOj.d.ts} +56 -2
- package/dist/{chunk-QGBOC5YL.cjs → chunk-23M5QTWW.cjs} +33 -21
- package/dist/chunk-23M5QTWW.cjs.map +1 -0
- package/dist/{chunk-6BQ6IEW2.cjs → chunk-2RR43OIN.cjs} +143 -30
- package/dist/chunk-2RR43OIN.cjs.map +1 -0
- package/dist/{chunk-A574IA4F.js → chunk-5Q5XZG7Z.js} +18 -6
- package/dist/chunk-5Q5XZG7Z.js.map +1 -0
- package/dist/chunk-DKGG7WD4.js +61 -0
- package/dist/chunk-DKGG7WD4.js.map +1 -0
- package/dist/{chunk-ZQX7WIYL.js → chunk-UBPZLGLU.js} +143 -31
- package/dist/chunk-UBPZLGLU.js.map +1 -0
- package/dist/chunk-UQBGW5DO.cjs +64 -0
- package/dist/chunk-UQBGW5DO.cjs.map +1 -0
- package/dist/index.cjs +291 -86
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -5
- package/dist/index.d.ts +54 -5
- package/dist/index.js +275 -72
- package/dist/index.js.map +1 -1
- package/dist/sfx-MZJZXYY3.cjs +446 -0
- package/dist/sfx-MZJZXYY3.cjs.map +1 -0
- package/dist/sfx-S37DDC66.js +434 -0
- package/dist/sfx-S37DDC66.js.map +1 -0
- package/dist/three.cjs +5 -5
- package/dist/three.d.cts +2 -2
- package/dist/three.d.ts +2 -2
- package/dist/three.js +1 -1
- package/dist/{types-CRE9WKd4.d.ts → types-7VtTEKUW.d.cts} +96 -6
- package/dist/{types-CRE9WKd4.d.cts → types-7VtTEKUW.d.ts} +96 -6
- package/docs/api.md +36 -0
- package/docs/dsl.md +44 -3
- package/llms-full.txt +234 -22
- package/llms.txt +1 -1
- package/package.json +2 -1
- package/dist/Composition-D7RCAGEA.cjs +0 -13
- package/dist/Composition-RGTRZ2JX.js +0 -4
- package/dist/chunk-6BQ6IEW2.cjs.map +0 -1
- package/dist/chunk-A574IA4F.js.map +0 -1
- package/dist/chunk-QGBOC5YL.cjs.map +0 -1
- package/dist/chunk-ZQX7WIYL.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
**Added**
|
|
6
|
+
|
|
7
|
+
- Sound effects without files: an `audio` layer with `sfx: 'swoosh'` (or `{ preset, pitch, brightness, seed }`, or a custom `{ voices: [...] }`) plays a synthesised sound — click, pop, swoosh, swipe, hit, riser, chime, beep, coin, glitch, typewriter. The layer's `duration` is the sound's length. Deterministic; the synthesiser is a separate chunk loaded only when used.
|
|
8
|
+
- `ai/tools/check.mjs` (`npx pixi-effects-check page.html`): a one-command review for an AI author — its own private headless Chrome and static server, then warnings, `movie.inspect` over the whole timeline (grouped), `movie.inspectAudio()`, a contact sheet PNG, and a real export decoded again (video / audio length, loudness per second, silence); `report.json`; exit 0 / 1. No dependencies (Node ≥ 22 and an installed Chrome). Cut-off / off-canvas text fails the check; text overlaps are listed for review (7 of the 30 gallery pieces overlap on purpose), `--strict` fails on them.
|
|
9
|
+
- `measureText(text, style)` and `splitText(text, style, { by: 'chars' | 'words' | 'lines', x, y, align })`: text layout with the renderer's own font metrics, for per-letter / per-word animation (and sizing pills) without hand-measuring.
|
|
10
|
+
- `shape: 'arc'` — progress rings, pie and donut slices (`radius`, `innerRadius`, `startAngle`, `endAngle` in degrees, all animatable) — and `strokeCap` / `strokeJoin` on every shape.
|
|
11
|
+
- An `audio` layer's starting volume may be written `initial: { volume }` (it was silently ignored there and played at 1) as well as `volume`.
|
|
12
|
+
- Text counters: `format: { pad: 2 }` zero-pads the whole part (`18:42:05`); a `{value}` placeholder that nothing sets or animates warns that it prints 0.
|
|
13
|
+
- `movie.inspectAudio()`: each sound's length, level, loudest moment and brightness (Hz), when it plays, the mix over time, and issues (limited mix, inaudible or cut-off sounds).
|
|
14
|
+
- The audio mix is soft-limited above 0.9 (a quieter mix is unchanged) and warns, naming the layers, when it would have clipped.
|
|
15
|
+
|
|
16
|
+
**Fixed**
|
|
17
|
+
|
|
18
|
+
- MP4 / MOV exports: the audio started ~45 ms after the picture (AAC encoder priming). It is now trimmed with an edit list.
|
|
19
|
+
- `colorSpace: 'oklch'` from or to a nearly grey colour (`#9a9ab4`) swept through pink: a low-chroma endpoint now borrows the other end's hue.
|
|
20
|
+
- `blendMode` on a layer with `filters` (a blurred `multiply` glow rendered black) goes through the last filter.
|
|
21
|
+
- An `image` / `video` used as a `mask` was drawn as a normal picture over the layer, and `maskInverted` looked ignored; sprite masks now stay hidden.
|
|
22
|
+
- A tilted `threeD` layer hidden because one corner passes the camera plane gets a warning that says so (it blamed `z`).
|
|
23
|
+
- `movie.inspect`: text in a panning composition is not "cut off", text scaled to 0 is not "no size".
|
|
24
|
+
- The seek / volume drag of the player ends when the pointerup never arrives (button released outside an iframe).
|
|
25
|
+
- sfx: a negative `at` or an end-relative keyframe past the start of a short sound crashed `movie.init()` (Web Audio rejects negative times): such a sound now starts at 0 with its first part cut off, with a warning; a non-number `duration` falls back to the preset length; silent custom voices (`from >= to`, `gain: 0`) and a non-numeric `seed` warn.
|
|
26
|
+
- `ai/template.html`: the player bar was wider than the canvas.
|
|
27
|
+
|
|
28
|
+
**Breaking (types)**
|
|
29
|
+
|
|
30
|
+
- `AudioSequenceSpec` is now `AudioAssetSpec | AudioSfxSpec`; `AudioDescriptor.buffer` is optional (a descriptor carries `buffer` or `synth`).
|
|
31
|
+
|
|
3
32
|
## 0.4.0
|
|
4
33
|
|
|
5
34
|
**Changed**
|
|
@@ -21,7 +50,7 @@
|
|
|
21
50
|
**Added**
|
|
22
51
|
|
|
23
52
|
- `blendMode: 'normal' | 'add' | 'screen' | 'multiply'` on every layer (a composition's children inherit it): glows and light leaks that add up.
|
|
24
|
-
-
|
|
53
|
+
- The four sample sound-effect mp3s (`examples/_assets/sfx-*.mp3`) are gone: the examples and all 23 gallery pieces that used them now use `sfx` presets (volumes matched to the old files within about 2 dB). `examples/_assets/bgm.mp3` is now a 16 s seamless loop of "One Cool Minute" (Loyalty Freak Music, CC0 1.0; see `examples/_assets/LICENSES.md`) instead of the 6 s drone; the gallery pieces' music volumes are retuned (peak 0.3, about −32 LUFS under the sound effects).
|
|
25
54
|
- `examples/gallery`: 30 portfolio pieces written as plain data by three AI models, with a showcase page, and the stumble notes that fed these fixes. `ai/reference/pitfalls.md` items 32–48.
|
|
26
55
|
|
|
27
56
|
## 0.3.0
|
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# pixi-effects
|
|
2
2
|
|
|
3
|
-
> **Status**: experimental — current release `0.
|
|
3
|
+
> **Status**: experimental — current release `0.5.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `0.5.0` adds synthesised sound effects (`sfx`, no files) and the `check` tool, `0.4.0` makes a mask share its layer's lifetime, `0.3.0` made keyframe `at` relative to the layer).
|
|
4
4
|
|
|
5
5
|
**[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 30 portfolio pieces written as plain data by AI models · **[Live demos →](https://yjmtmtk.github.io/pixi-effects/)** · 12 numbered examples + an in-browser playground.
|
|
6
6
|
|
|
7
7
|
Declarative composition and video rendering for the web. After Effects-style timelines on top of [PixiJS v8](https://pixijs.com/) and [GSAP](https://gsap.com/), with strict TypeScript types. Render to MP4 / WebM / MOV via [mediabunny](https://mediabunny.dev/).
|
|
8
8
|
|
|
9
9
|
- **Declarative DSL** — describe your composition as a tree of typed sequences (text, image, video, audio, shapes, nested compositions). No imperative tween code.
|
|
10
|
+
- **Sound effects without files** — `{ type: 'audio', sfx: 'swoosh', at: 2 }`: 11 synthesised presets (click, pop, swoosh, riser, hit, chime …) with `pitch` / `brightness` / `seed`, or your own `voices`; deterministic and in the exported file at the same moments as in the browser; `movie.inspectAudio()` checks them as numbers.
|
|
10
11
|
- **Expression language** — sprinkle `'GW * 0.5'` or `'min(W, H) / 2'` anywhere a number goes. Resolved at runtime against a sequence-relative scope.
|
|
11
12
|
- **Filters, masks, transitions** — chroma key, blur, color matrix (or any PixiJS filter); inline masks; seven scene transitions (`crossfade`, `wipe`, `iris`, `slide`, `dip`, `zoom`, `dissolve`).
|
|
12
13
|
- **Presets** — `kenBurns` for stills, `withFade` for fade-in/out.
|
|
@@ -35,8 +36,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
|
|
|
35
36
|
"gsap": "https://esm.sh/gsap@3.12.5",
|
|
36
37
|
"gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
|
|
37
38
|
"mediabunny": "https://esm.sh/mediabunny",
|
|
38
|
-
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
39
|
-
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
39
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/index.js",
|
|
40
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/Controller.js"
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
</script>
|
|
@@ -47,7 +48,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
|
|
|
47
48
|
</script>
|
|
48
49
|
```
|
|
49
50
|
|
|
50
|
-
Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.
|
|
51
|
+
Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.5.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.
|
|
51
52
|
|
|
52
53
|
> **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
|
|
53
54
|
>
|
|
@@ -63,7 +64,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
|
|
|
63
64
|
"imports": {
|
|
64
65
|
"...": "(everything from the importmap above)",
|
|
65
66
|
"three": "https://esm.sh/three@0.178.0",
|
|
66
|
-
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
67
|
+
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/three.js"
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
</script>
|
|
@@ -160,6 +161,8 @@ This library is designed to be written by AI: a video is plain data, and every m
|
|
|
160
161
|
- [`ai/SKILL.md`](./ai/SKILL.md) — a [skill](https://docs.claude.com/en/docs/claude-code/skills) (workflow, rules, verification loop). Copy the `ai/` folder to `~/.claude/skills/pixi-effects/` (or your project's `.claude/skills/`) to have Claude load it automatically when you ask for a video.
|
|
161
162
|
- [`ai/reference/cheatsheet.md`](./ai/reference/cheatsheet.md), [`recipes.md`](./ai/reference/recipes.md) (tested), [`pitfalls.md`](./ai/reference/pitfalls.md), and a starter [`ai/template.html`](./ai/template.html).
|
|
162
163
|
|
|
164
|
+
- **One-command review for the AI that wrote the page:** `npx pixi-effects-check my-video.html` (or `node ai/tools/check.mjs my-video.html`; Node ≥ 22 and Chrome installed, no dependencies). It opens the page in its own headless Chrome and reports warnings, layout (`movie.inspect` over the whole timeline), the soundtrack (`movie.inspectAudio`) and a real export decoded again, and writes a contact sheet PNG to look at. Exit code 0 / 1.
|
|
165
|
+
|
|
163
166
|
They ship in the npm package (`node_modules/pixi-effects/ai/`).
|
|
164
167
|
|
|
165
168
|
## Documentation
|
|
@@ -179,6 +182,7 @@ They ship in the npm package (`node_modules/pixi-effects/ai/`).
|
|
|
179
182
|
- `10-three.html` — a three.js scene as a layer (`pixi-effects/three`)
|
|
180
183
|
- `11-depth.html` — 2.5D layers and camera: parallax, spin, dolly zoom
|
|
181
184
|
- `12-title-motion.html` — a staggered 2.5D title: letters fly in from depth, particles, orbiting camera
|
|
185
|
+
- `13-sfx.html` — synthesised sound effects (no audio files), a riser into a hit, a custom voice
|
|
182
186
|
- `playground.html` — in-browser editor with preset dropdown
|
|
183
187
|
|
|
184
188
|
## Browser support
|
package/ai/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pixi-effects
|
|
3
|
-
description: Make motion graphics and videos as code with the pixi-effects JS library — titles, lower-thirds, kinetic typography, slideshows with transitions, 2.5D / depth scenes, three.js titles, data-driven charts. A video is a plain-object composition (text, shape, image, video, audio, camera layers with keyframes and expressions) that plays in the browser and exports MP4/WebM. Use when asked to create, animate, preview, or export a video / title / promo / animated chart in JavaScript or HTML.
|
|
3
|
+
description: Make motion graphics and videos as code with the pixi-effects JS library — titles, lower-thirds, kinetic typography, slideshows with transitions, 2.5D / depth scenes, three.js titles, data-driven charts. A video is a plain-object composition (text, shape, image, video, audio and file-free sound-effect, camera layers with keyframes and expressions) that plays in the browser and exports MP4/WebM. Use when asked to create, animate, preview, or export a video / title / promo / animated chart in JavaScript or HTML.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# pixi-effects — write a video as data
|
|
@@ -11,11 +11,13 @@ You describe a video as a tree of plain objects; the library plays it in a canva
|
|
|
11
11
|
|
|
12
12
|
1. **Start from `template.html`** (copy it; it loads the library from a CDN, collects every console warning into `window.__logs`, and exposes `window.movie`). One HTML file, plain JS. Default canvas 1280×720 @ 30 fps.
|
|
13
13
|
2. **Write the composition as data.** Anything repeated (letters, bars, particles, scenes) is a JS function or loop that returns specs — never copy-paste objects. Derive coordinates and times from constants/data, not magic numbers.
|
|
14
|
-
3. **Run
|
|
14
|
+
3. **Run the check — one command does steps 3, 4 and the export:** `node ai/tools/check.mjs my-video.html` (Node ≥ 22 and Chrome installed; no npm install; in an installed package it is `npx pixi-effects-check my-video.html`). It starts its own private Chrome (nothing is shared with other sessions), waits for `window.__ready`, and prints: console warnings, `movie.inspect` over the WHOLE timeline (grouped), `movie.inspectAudio()`, then exports an mp4 and decodes it again (video size and length, audio length, loudness per second, silence). It writes `check-out/<name>/sheet.png` — **open that picture and look at it** — plus the mp4 and `report.json`. Exit 0 = nothing to fix; exit 1 = a list of problems; fix them and run it again. Text that overlaps text is listed under `review` (not a failure: ghost layers, glow copies and per-letter boxes are often intentional; look at the sheet; `--strict` makes them fail). The manual steps below are for looking at one frame closely.
|
|
15
|
+
**Or by hand: run it and read the logs first.** `window.__ready` must be `true` and `window.__logs` should be `[]`. A pixi-effects warning is an instruction: fix what it names.
|
|
15
16
|
4. **Look at it** — you cannot judge motion from code. The library has the tools built in:
|
|
16
17
|
- `await movie.contactSheet({ count: 6, as: 'dataURL' })` → ONE image of several labelled frames (pass `frames: [...]` / `times: [...]` to choose; include the middle of every transition and the last second). Save it and view it.
|
|
17
18
|
- `await movie.inspect(frame)` → every layer's canvas bounds and visibility, plus `issues` (text off the canvas, cut off by an edge, empty, or overlapping other text). Run it at several frames; fix every issue it names.
|
|
18
19
|
- `await movie.snapshot(frame, { as: 'dataURL' })` → one frame, canvas only (no player bar).
|
|
20
|
+
- `movie.inspectAudio()` → **hear with numbers**: for every sound its `start`, `sound.length`, `sound.peakDb`, `sound.loudestAt` (line this up with the picture) and `sound.brightnessHz` (< 500 dark, > 8000 airy), plus `issues` (limited mix, inaudible or cut-off sounds). Check it whenever the movie has audio; `render()` encodes this same mix.
|
|
19
21
|
From a browser tool the result is a `data:` URL string: `agent-browser eval "movie.contactSheet({ count: 6, as: 'dataURL' })" | python3 ai/tools/save-image.py /absolute/sheet.png`, then open the PNG. Sheet tiles are small (~480 px): use `snapshot` for detail. `inspect` output is long on busy scenes: pass `{ layers: 'none' }` and read `issues` first. Give layers a `name` so inspect paths are readable. A moving ticker is not flagged as cut off.
|
|
20
22
|
5. **Iterate** on what you saw (clipped text, collisions, wrong timing). Then export: `const blob = await movie.render({ format: 'mp4' })` (about real time; `movie.on('progress', …)` reports 0–100).
|
|
21
23
|
|
|
@@ -25,10 +27,10 @@ You describe a video as a tree of plain objects; the library plays it in a canva
|
|
|
25
27
|
- **Layers are hidden, not removed, after their lifespan; z-order is array order.** Switch scenes by stacking layers with `at`/`duration` over an opaque background rect.
|
|
26
28
|
- **Angles are degrees. `+z` is toward the viewer. Numbers can be expressions** (`'GW/2 - w/2'`; no `pi`; no per-frame variable).
|
|
27
29
|
- **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`.
|
|
28
|
-
-
|
|
30
|
+
- **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`.
|
|
29
31
|
- **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.**
|
|
30
32
|
- **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.
|
|
31
|
-
- **Built in:** `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`. **Not in the DSL (hand-roll, recipes exist or loops are easy):**
|
|
33
|
+
- **Built in:** `splitText()` / `measureText()` (per-letter / per-word layers, exact positions), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`. **Not in the DSL (hand-roll, recipes exist or loops are easy):** stroke draw-on (trim path), text that changes over time (other than `{value}`), other per-frame curves and jitter, per-letter text animators (one layer per letter), particle emitters, group/parent layers.
|
|
32
34
|
- **Blend modes:** `blendMode: 'add' | 'screen' | 'multiply'` on any layer (glows, light leaks).
|
|
33
35
|
- **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.
|
|
34
36
|
|
|
@@ -41,6 +43,7 @@ Centred text must be positioned by `anchorX/anchorY: 0.5`; estimate text width a
|
|
|
41
43
|
- `reference/cheatsheet.md` — every layer type, prop, default and rule on one page.
|
|
42
44
|
- `reference/recipes.md` — tested building blocks: slam type, lower-third, marquee, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions + music, looping, particles, gradients/vignette, three.js metal.
|
|
43
45
|
- `reference/pitfalls.md` — the full list of real mistakes, with status.
|
|
46
|
+
- `tools/check.mjs` — the one-command review (see Workflow step 3).
|
|
44
47
|
- `template.html` — the starting file. (It loads the library from a CDN; when testing against a local build, point the three `pixi-effects*` importmap entries at `../../dist/…`.)
|
|
45
48
|
- `tools/save-image.py` — save a `dataURL` result (contact sheet / snapshot) to a PNG.
|
|
46
49
|
- Full reference: `docs/dsl.md` and `docs/api.md` in the repository.
|
|
@@ -48,28 +48,30 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
|
|
|
48
48
|
|
|
49
49
|
## Layer types
|
|
50
50
|
|
|
51
|
-
**text** — `text`, `style` (any PixiJS TextStyle field: `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding`; expressions OK for numbers), `colorSpace`. Default anchor is **top-left**; use `anchorX/anchorY: 0.5` to centre on `x,y`. A text layer has one animatable number, `value`, printed where the text contains `{value}` (`text: '{value} users'`, `initial: { value: 0 }`, keyframe `to: { value: 2480 }`; `format: { decimals, grouping }`) — that is how counters work. Other content cannot change over time. Multi-line text is centre-aligned by default (`style.align: 'left'` to change).
|
|
51
|
+
**text** — `text`, `style` (any PixiJS TextStyle field: `fontSize fontFamily fontWeight fill letterSpacing lineHeight align wordWrap wordWrapWidth stroke dropShadow padding`; expressions OK for numbers), `colorSpace`. Default anchor is **top-left**; use `anchorX/anchorY: 0.5` to centre on `x,y`. A text layer has one animatable number, `value`, printed where the text contains `{value}` (`text: '{value} users'`, `initial: { value: 0 }`, keyframe `to: { value: 2480 }`; `format: { decimals, grouping, pad /* zero-pad the whole part: pad 2 → 05 */ }`) — that is how counters work. Other content cannot change over time. Multi-line text is centre-aligned by default (`style.align: 'left'` to change).
|
|
52
52
|
|
|
53
53
|
**image** — `asset`, `tint`, `colorSpace: 'rgb'|'oklab'|'oklch'`. Default anchor top-left; natural size = `w`,`h`.
|
|
54
54
|
|
|
55
55
|
**video** — `asset`, `loop`, `audio`, `volume`; `initial: { scale: 'cover' }`.
|
|
56
56
|
|
|
57
|
-
**audio** — `asset`, `loop`, `volume`; fade with volume keyframes (`{ at: -2, to: { volume: 0 }, duration: 2 }` fades the last 2 s).
|
|
57
|
+
**audio** — a file: `asset`, `loop`, `volume`; `volume` (top level, or in `initial` like every other layer's starting values); fade with volume keyframes (`{ at: -2, to: { volume: 0 }, duration: 2 }` fades the last 2 s). Omit `duration` for a one-shot clip: it lasts exactly as long as the clip. With `loop: true` and no `duration` it lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true`.
|
|
58
|
+
**Or a sound effect with no file — `sfx`:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` is the whole thing. Presets, default length in s: `click` 0.04 · `pop` 0.12 · `swoosh` 0.5 (loudest 0.16 s in, moves left → right) · `swipe` 0.22 · `hit` 0.7 · `riser` 1 (loudest at its END: `at = hit − duration`) · `chime` 1.4 · `beep` 0.16 · `coin` 0.4 · `glitch` 0.35 · `typewriter` 0.06. Knobs: `sfx: { preset, pitch /* semitones ±24 */, brightness /* −1 dark … 1 bright */, seed /* another take */ }`. **The layer's `duration` IS the sound's length** (omit it; set it to stretch, e.g. a riser); `volume` is its level (1 = the preset's standard level, peaking at −12 … −18 dBFS by preset). No `loop`: one layer per hit (a JS loop). Custom sound: `sfx: { voices: [{ wave: 'noise' | 'sine' | 'square' | 'saw' | 'triangle', freq: 440 | [from, to], filter: { type: 'lowpass' | 'highpass' | 'bandpass', freq: [from, to] }, envelope: 'fall' | 'bell' | 'swell' | 'hold', pan: [-1, 1] }] }`. The export (mp4 / webm / mov) encodes this same mix (lossy AAC / Opus), on time within a few ms.
|
|
58
59
|
|
|
59
|
-
**shape** — `shape: 'rect' | 'circle' | 'ellipse' | 'line' | 'polygon' | 'path'`:
|
|
60
|
+
**shape** — `shape: 'rect' | 'circle' | 'ellipse' | 'arc' | 'line' | 'polygon' | 'path'`:
|
|
60
61
|
|
|
61
62
|
| shape | geometry (top level **or** in `initial`) |
|
|
62
63
|
|---|---|
|
|
63
64
|
| rect | `width height cornerRadius anchorX anchorY` |
|
|
64
65
|
| circle | `radius anchorX anchorY` |
|
|
65
66
|
| ellipse | `radiusX radiusY anchorX anchorY` |
|
|
67
|
+
| arc | `radius innerRadius startAngle endAngle` — degrees, **0° = 3 o'clock, clockwise**; centred on `x, y`; stroke only = an open arc line (`strokeCap: 'round'`), with a fill = a sector, with `innerRadius` = a donut slice; a sweep ≥ 360° = a full circle. A progress ring: `startAngle: -90` and animate `endAngle` from −90 to 270 |
|
|
66
68
|
| line | `from: [x,y] to: [x,y]` — plain canvas coordinates (omit `x,y`; if you give them they place the line's midpoint). Stroke in `initial`: `strokeColor`, `strokeWidth` |
|
|
67
69
|
| polygon | `points: [[x,y],…] open` — canvas coordinates like `line` |
|
|
68
70
|
| path | `d` (SVG path data) — canvas coordinates like `line` |
|
|
69
71
|
|
|
70
|
-
Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha strokeWidth`; `colorSpace: 'oklab'|'oklch'` for clean colour tweens. rect/circle/ellipse are **centred on `x,y` by default** (`anchorX/anchorY` default 0.5): for a bar growing from its base use `anchorY: 1` (or `anchorX: 0` for left-to-right). **`fillGradient`** (top level or `initial`; instead of `fillColor`): `{ type?: 'linear'|'radial', stops: [[0, '#000'], [1, 'rgba(0,0,0,.6)']], angle? /* linear, deg, 90 = top→bottom */, center?, innerRadius?, radius? /* radial, 0–1 of bounds */ }` — stops may have alpha, so a radial transparent→dark is a vignette.
|
|
72
|
+
Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha strokeWidth`; `colorSpace: 'oklab'|'oklch'` for clean colour tweens. rect/circle/ellipse are **centred on `x,y` by default** (`anchorX/anchorY` default 0.5): for a bar growing from its base use `anchorY: 1` (or `anchorX: 0` for left-to-right). **`fillGradient`** (top level or `initial`; instead of `fillColor`): `{ type?: 'linear'|'radial', stops: [[0, '#000'], [1, 'rgba(0,0,0,.6)']], angle? /* linear, deg, 90 = top→bottom */, center?, innerRadius?, radius? /* radial, 0–1 of bounds */ }` — stops may have alpha, so a radial transparent→dark is a vignette. Stroke ends and corners: `strokeCap: 'butt'|'round'|'square'`, `strokeJoin: 'miter'|'round'|'bevel'` (top level or in `initial`; not animated — `round` keeps sharp corners like an M from poking out).
|
|
71
73
|
|
|
72
|
-
**composition** — `width height duration sequences transitions`. With `threeD: true` it is a **card**: its children are drawn into one texture (content outside its `width × height` is clipped: size it for a soft shadow too) and move / rotate in depth together. Children use the composition's local coordinates and times. Position/rotate/scale it as a unit; to rotate/scale about its centre set `pivotX/pivotY` to the centre and `x/y` to where that point should sit.
|
|
74
|
+
**composition** — `width height duration sequences transitions`. With `threeD: true` it is a **card**: its children are drawn into one texture (content outside its `width × height` is clipped: size it for a soft shadow too; a plain 2D composition does not clip, but is skipped when its whole rectangle is off screen) and move / rotate in depth together. Children use the composition's local coordinates and times. Position/rotate/scale it as a unit; to rotate/scale about its centre set `pivotX/pivotY` to the centre and `x/y` to where that point should sit.
|
|
73
75
|
|
|
74
76
|
**camera** (2.5D) — props in `initial`/keyframes: `x y z lookAtX lookAtY lookAtZ fov` (defaults: centred, looking at the z = 0 plane, `fov` 40, `z` auto = `(H/2)/tan(fov/2)` ≈ 989 at 720p). Never on the camera object itself. An orbit is `orbit()`; by hand: `x = cx + R·sin θ`, `z = R·cos θ`, `R = (H/2)/tan(fov/2)`, `lookAt` = the centre at `z = 0`.
|
|
75
77
|
|
|
@@ -100,6 +102,14 @@ orbit({ duration: 6, degrees: 40 /* , radius, center, start, fov, ease, at */ })
|
|
|
100
102
|
```
|
|
101
103
|
`kenBurns` covers the canvas, so source images should be at least canvas-sized (1920×1080 for 1280×720).
|
|
102
104
|
|
|
105
|
+
```js
|
|
106
|
+
import { measureText, splitText } from 'pixi-effects'; // text layout helpers (measured with the renderer's own fonts)
|
|
107
|
+
measureText('Hello', style) // → { width, height, lines }
|
|
108
|
+
splitText('Hello world', style, { by: 'chars' | 'words' | 'lines', x, y, align: 'left' | 'center' | 'right' })
|
|
109
|
+
// → [{ text, x, y, width, index, line }] — one text layer per piece, same style, anchor top-left
|
|
110
|
+
```
|
|
111
|
+
Per-letter / per-word animation = `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
|
|
112
|
+
|
|
103
113
|
## Assets
|
|
104
114
|
|
|
105
115
|
`assets: [{ name, src }]` in `movie.init` (images, audio, video). `src` may be a URL or a `data:`/`blob:` URL (e.g. `canvas.toDataURL()`).
|
|
@@ -117,5 +127,6 @@ movie.audioBuffer // mixed audio (after init), if
|
|
|
117
127
|
await movie.contactSheet({ count: 6, as: 'dataURL' }) // ONE image of several labelled frames — look at it. Options: frames | times | count, columns, cellWidth, as
|
|
118
128
|
await movie.snapshot(60, { as: 'dataURL' }) // one frame, canvas only (no player bar) — use it for detail; sheet tiles are small
|
|
119
129
|
await movie.inspect(60, { layers: 'none' }) // issues only (default lists the visible layers); text off-canvas / cut off / empty / overlapping text. Name your layers so paths are readable
|
|
130
|
+
movie.inspectAudio() // the soundtrack as numbers — you cannot listen: sources[i] = { layer, start, end, peakDb, sound: { length, peakDb, loudestAt, brightnessHz } }, windows (rms / peak / brightness per 0.1 s), issues (limited mix, inaudible / cut-off sounds)
|
|
120
131
|
new Controller(movie, { canvas }) // optional player bar (overlays the canvas bottom ~60px; not in the export)
|
|
121
132
|
```
|
package/ai/reference/pitfalls.md
CHANGED
|
@@ -10,7 +10,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
|
|
|
10
10
|
2. **A layer's own `at` and a transition's `at` are composition time** (seconds from the parent composition's start). Only keyframes are layer-local.
|
|
11
11
|
3. **Layers are hidden, not removed, after `[at, at+duration)`** and keep their last values. Z-order is array order (later = on top). To switch scenes, stack scenes with `at`/`duration`, each starting with an opaque full-screen rect. *(docs)*
|
|
12
12
|
4. **Transitions need overlapping lifespans.** Both layers must be alive for the whole window: scene `i` starts at `i·(L−T)`, the transition's `at` is the next scene's start, total = `n·(L−T)+T`. A scene that ends while the transition is still running leaves a gap. *(docs)*
|
|
13
|
-
5. **Audio length.** Omit `duration` on a one-shot
|
|
13
|
+
5. **Sound effects need no file at all:** `{ type: 'audio', sfx: 'pop', at }` — the layer lasts exactly as long as the sound. **Audio length of a FILE.** Omit `duration` on a one-shot clip: the layer lasts exactly as long as the file (nothing to measure; the only sample file is `examples/_assets/bgm.mp3`, 6 s). `loop: true` with no `duration` lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true` (`bgm.mp3` is 6 s) and warns. Volume fades work like any keyframe: `volume: 0` + `{ at: 0, to: { volume: v }, duration: 2 }` + `{ at: -2, to: { volume: 0 }, duration: 2 }` (the last 2 s only; `set` is a jump). *(fixed: volume keyframes used to ramp from the previous keyframe's end, so the music faded across the whole piece)*
|
|
14
14
|
6. **Snap times to the frame grid (`Math.round(t*30)/30`) when two things must stay locked** (a bar and its label). Drive both from one JS easing function with per-frame `set` keyframes rather than mixing a tween with computed positions. *(docs)*
|
|
15
15
|
7. **Pick eases on purpose.** `expo.in` stays tiny until the very end (a "flood" circle never reaches the corners — use a bigger radius and `power2.in`). A zoom transition with `power2.out` looks like a cut because the incoming scene is nearly opaque after 40 %. Default is linear. *(docs)*
|
|
16
16
|
8. **`render()` runs in roughly real time** (6 s of 720p ≈ 6 s). Listen to `movie.on('progress', e => e.progress)` (0–100). *(docs)*
|
|
@@ -33,14 +33,14 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
|
|
|
33
33
|
18. **Counters**: `text: '{value}'` + animate `value` (with `format`). Other text content cannot change over time. Earlier sessions generated one text layer per number (40+ per bar). *(fixed)*
|
|
34
34
|
19. **Loops**: `repeat` (finite) / `yoyo` / `repeatDelay` on any keyframe; infinite repeats are rejected with a warning. *(fixed)*
|
|
35
35
|
20. **No per-frame expression time.** Expressions are evaluated once at build. A camera orbit is `orbit()`; any other curve (waves, spirals) must be sampled into short linear keyframes (~0.1 s) in a JS loop. *(partly fixed)*
|
|
36
|
-
21. **
|
|
37
|
-
22. **Colour tweens look muddy in RGB** (red→green passes through brown). Use `colorSpace: 'oklab'` or `'oklch'` on the layer. *(docs)*
|
|
36
|
+
21. **Per-letter / per-word text is `splitText()` (one layer per piece, positions measured for you); no group/null parent, no particle emitter, no blend modes, no caption-linked-to-slide.** One layer per letter; many layers; seeded random loops; plain alpha. *(hand-roll)*
|
|
37
|
+
22. **Colour tweens look muddy in RGB** (red→green passes through brown). Use `colorSpace: 'oklab'` or `'oklch'` on the layer. In `oklch`, a nearly grey end (chroma < ~0.08, e.g. `#9a9ab4`) borrows the other end's hue, so grey → yellow no longer flashes through pink. *(docs / fixed)*
|
|
38
38
|
23. **Generated images**: `canvas.toDataURL()` as an asset `src` works. Make them ≥ canvas size or `kenBurns` zooms look soft. *(docs)*
|
|
39
39
|
|
|
40
40
|
## 2.5D / three.js
|
|
41
41
|
|
|
42
42
|
24. **`threeD` is required** for `z`, `rotationX`, `rotationY` (they warn otherwise). Camera props go in `initial`/keyframes, never on the camera object. `+z` is toward the viewer. *(fixed: warns)*
|
|
43
|
-
25. **A layer at/behind the camera plane is hidden** — the default camera sits at `z = (H/2)/tan(fov/2)` ≈ 989 at 720p, fov 40 (≈ 808 at fov 48, lower at higher fov). Keep every layer's `z` below it. *(fixed: warns once)*
|
|
43
|
+
25. **A layer at/behind the camera plane is hidden** — the default camera sits at `z = (H/2)/tan(fov/2)` ≈ 989 at 720p, fov 40 (≈ 808 at fov 48, lower at higher fov). Keep every layer's `z` below it. *(fixed: warns once)* **It is hidden whole when ANY corner of it passes the camera plane**: a big plane tilted with `rotationX` / `rotationY` (a ground plane, an orbit ring) vanishes as soon as one corner swings behind the camera (the warning then says a corner reaches the plane). Tilt it less, shrink it, or keep the camera clear of its depth range. *(docs / fixed: warns)*
|
|
44
44
|
26. **Under a dolly zoom only the `z = 0` plane stays put**; everything nearer balloons and clips. Headline at `z: 0`, near cards `z ≤ ~70`. Perspective also pulls far layers toward the centre: put far cards at the outer x, near cards inward, and keep ~10 % margin for an orbit sweep. `orbit({ dollyZoom: { from, to } })` does both together. *(docs)*
|
|
45
45
|
27. **Combining orbit and dolly zoom**: `orbit()` specifies `z`, so it stops following `fov` — write your own `fov` and `z = (H/2)/tan(fov/2)` keyframes for the zoom. *(docs)*
|
|
46
46
|
28. **Nearer `threeD` layers sort on top of text** — lay out around them; equal `z` keeps array order. A card (rect + icon + label that move together) is a `composition` with `threeD: true`. A final call-to-action or overlay should be a plain 2D layer (last in the array): it ignores the camera, and fade the 3D scene and headline out before it appears (a dim rect alone leaves them visible). *(docs)*
|
|
@@ -51,7 +51,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
|
|
|
51
51
|
30. **A scene change must keep the outgoing scene alive until the covering flood / wipe has finished**, and the next scene starts when it is complete; the flood circle must reach the corners (radius ≥ half the diagonal) and use `power2.in`, not `expo.in`. *(docs)*
|
|
52
52
|
30b. **Unknown option names now warn** (`contactSheet({ cols: 2 })` → did you mean `columns`; `init({ fps })` → `frameRate`). Layer names show up in `inspect` paths, so give layers a `name`. `inspect` ignores faint (alpha < 0.3) layers and text whose x / y is animated (tickers), so a scrolling marquee is not reported as cut off. Pixi may print `[BindGroup] … destroyed while still bound` warnings at `movie.destroy()`: harmless (playback and seeking never warn). *(docs)*
|
|
53
53
|
30c. **Look with the built-in tools, not OS screenshots**: `await movie.contactSheet({ count: 6, as: 'dataURL' })` returns one labelled image of the whole animation; `await movie.inspect(frame)` returns every layer's canvas bounds and `issues`. If you do use a browser tool, `screenshot` needs an **absolute path**, macOS `sed -i` needs `''`, and a static server may redirect `/x.html` → `/x` and drop `?query`. Keep the template's `try/catch` around `init` so failures land in `window.__logs`. Check ≥ 5 timestamps including mid-transition. *(fixed: tools built in)*
|
|
54
|
-
31. **Check audio
|
|
54
|
+
31. **Check audio with `movie.inspectAudio()`**: every sound measured on its own (`sound.length`, `peakDb`, `loudestAt`, `brightnessHz`), when it plays, loudness and brightness per 0.1 s window, and `issues` (the mix was limited, a layer is inaudible, an sfx is cut off by the end). Decoding a render by hand is no longer needed: `render()` encodes the same mix, on time in mp4 / mov / webm / mkv. *(fixed)*
|
|
55
55
|
|
|
56
56
|
## Round 2 — found by building 16 portfolio pieces (examples/gallery)
|
|
57
57
|
|
|
@@ -60,16 +60,20 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
|
|
|
60
60
|
34. **`from` and `from+to` show their start value from the LAYER's start**, not from the keyframe's `at` (GSAP behaviour): a delayed fade-in `{ at: 2, from: { alpha: 0 }, to: { alpha: 1 } }` needs no `initial.alpha`. To keep the current value until `at` and then jump, use `set`. *(docs)*
|
|
61
61
|
35. **One `Movie` per page.** Creating a second Movie (for an isolated repro) and destroying it breaks the first (`null.clear`). Reload instead. *(docs)*
|
|
62
62
|
36. **Multi-line text is centre-aligned by default** (`style.align: 'left'` for left-aligned paragraphs). *(docs)*
|
|
63
|
-
37. **A `threeD` composition (a card)
|
|
63
|
+
37. **Composition size.** A 2D composition does not clip its children (a child outside `width × height` still draws), but its rectangle is the culling area: if the whole rectangle is off the canvas the group is skipped even when a child is on screen — keep it near what it draws, or canvas-sized. A `threeD` composition (a card) IS clipped at its `width × height` (it is rendered into a texture that size): size it for the soft shadow too. A growing `threeD` shape or text is fine: its texture is resized in place (it used to warn "destroyed while still bound"). *(measured; docs / fixed)*
|
|
64
64
|
38. **Under an orbit the side the camera swings toward becomes the NEAR side**: a "far" card at `z: −200` can be drawn bigger than 1× on that side and run off the frame (`inspect` only checks text). Pull those cards inward / lower `z`, and check a frame at the extreme of the sweep. After `orbit()` ends the default camera returns, so to hold the final pose add a camera layer that starts there. Near particles balloon under a dolly zoom: cap particle `z` at about +150, or one becomes a fake full stop. *(docs)*
|
|
65
65
|
39. **`fillGradient` stops span the whole unmasked shape**, not the visible part: under a mask, place the stops for the visible fraction (e.g. 0.64→1). A translucent shape over near-black reads as grey: put the alpha in the gradient stops. *(docs)*
|
|
66
|
-
40. **Rotate a polygon / path about a chosen point
|
|
66
|
+
40. **Rotate a polygon / path / line about a chosen point with `pivotX` / `pivotY`** (in the shape's own canvas coordinates; put the same point in `x, y`): `{ points, initial: { pivotX: 600, pivotY: 300, x: 600, y: 300, rotation: 180 } }` turns about the vertex (600, 300) — measured. Without a pivot the shape turns about the centre of its bounds. `anchorX` / `anchorY` do nothing on these shapes (they are for rect / circle / ellipse). *(docs; the gallery wrapped every polygon in a composition unnecessarily)*
|
|
67
67
|
41. **Glow a whole set of layers** by putting them in one `composition` with a blur filter and `filterArea` (only a composition carries a filter for its children): wide enough for the blur, and the set is built once, so the extra copy is just a second group. *(docs)*
|
|
68
68
|
42. **Transition looks:** the default `dissolve` reads as a hard black-and-white camouflage and `scale: 220` as static; `scale: 90, smoothing: 0.5` is a soft film dissolve. The default edge softness of `iris` (0.02) and `wipe` blurs flat graphics at 720p: `smoothing: 0.004`–`0.01` gives a clean edge. *(docs)*
|
|
69
69
|
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)*
|
|
70
|
-
44.
|
|
70
|
+
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)*
|
|
71
71
|
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)*
|
|
72
|
-
46. **Still not in the DSL (hand-roll; round-two notes have workarounds):**
|
|
73
|
-
46b. **Blend modes exist now** (`blendMode: 'add' | 'screen' | 'multiply'` on any layer, threeD layers and compositions included; a composition's children inherit it). Additive white cores no longer read grey on a coloured background. *(fixed)*
|
|
74
|
-
47. **Browser tooling
|
|
72
|
+
46. **Still not in the DSL (hand-roll; round-two notes have workarounds):** stroke draw-on / trim path on a line, polygon or path (use a growing rect mask for a one-direction reveal; a circular progress ring is `shape: 'arc'`), text content that changes over time (only `{value}`), per-frame jitter (film grain = seeded `set` keyframes on an oversized image), particle emitters, shared masks, `zIndex`.
|
|
73
|
+
46b. **Blend modes exist now** (`blendMode: 'add' | 'screen' | 'multiply'` on any layer, threeD layers and compositions included; a composition's children inherit it). Additive white cores no longer read grey on a coloured background. It also works on a layer with `filters` (a blurred glow with `add` / `multiply`): the mode is applied by the last filter, because a container's blend inside a filter pass multiplied against nothing and rendered black. With `add`, use dim saturated colours: overlaps sum, and near-white sums to pure white. *(fixed)*
|
|
74
|
+
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.
|
|
75
75
|
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)*
|
|
76
|
+
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)*
|
|
77
|
+
50. **An image (or video) works as a mask, `maskInverted` included** *(fixed)*: PIXI hides a sprite mask with `renderable = false` and the lifespan toggles used to switch it back on, so the mask picture was drawn over the layer (a white disc) and `maskInverted` looked ignored. Use the picture's alpha (or red channel) as the shape: an opaque word or logo on a transparent canvas image is a text-shaped mask with real glyph edges. A `text` layer also works as a mask (cut to its glyphs; measured with a heavy sans `I`). *(docs / fixed)*
|
|
78
|
+
51. **Arcs and rings are `shape: 'arc'`** (`radius`, `innerRadius`, `startAngle`, `endAngle`, degrees; 0° = 3 o'clock, clockwise). A progress ring is an arc with `startAngle: -90` and an animated `endAngle` (−90 → 270); `strokeCap: 'round'` rounds BOTH ends. Fill it for a pie slice, add `innerRadius` for a donut slice (a 120-polygon donut is no longer needed). Style (`strokeColor`, `strokeWidth`, `fillColor`) goes in `initial`; `strokeCap` / `strokeJoin` may be top level. *(fixed)*
|
|
79
|
+
52. **Measure and split text with `measureText` / `splitText`, not by hand.** `splitText(text, style, { by, x, y, align })` returns each piece's x / y / width in the renderer's own font metrics (kerning kept; verified: a whole line vs the same line in per-letter layers differs only by anti-aliasing). The pieces are for text layers with the default top-left anchor and the same `style`. Load a web font before measuring (`await document.fonts.load('24px "Name"')`) or the fallback font's widths are used. Size a pill from `measureText(...).width + padding` instead of `0.58 × fontSize × length`. *(fixed)*
|
package/ai/reference/recipes.md
CHANGED
|
@@ -272,6 +272,7 @@ photos.forEach((_, i) => sequences.push(withFade({
|
|
|
272
272
|
}, { in: 0.4, out: 0.4 })));
|
|
273
273
|
sequences.push({ type: 'audio', asset: 'bgm', loop: true, volume: 0, duration: total,
|
|
274
274
|
keyframes: [{ at: 0, to: { volume: 0.8 }, duration: 2 }, { at: -2, to: { volume: 0 }, duration: 2 }] });
|
|
275
|
+
transitions.forEach(tr => sequences.push({ type: 'audio', sfx: 'swoosh', at: tr.at + tr.duration / 2 - 0.16 })); // a swoosh is loudest 0.16 s in: centre it on the transition
|
|
275
276
|
return { sequences, transitions, duration: total };
|
|
276
277
|
```
|
|
277
278
|
|
|
@@ -279,6 +280,122 @@ Source images should be at least canvas-sized (`kenBurns` zooms in): draw genera
|
|
|
279
280
|
|
|
280
281
|
---
|
|
281
282
|
|
|
283
|
+
## Sound effects without files
|
|
284
|
+
|
|
285
|
+
No `assets`, no files: a sound effect is one audio layer with `sfx`. It lasts as long as the sound.
|
|
286
|
+
|
|
287
|
+
```js
|
|
288
|
+
// @recipe sfx-minimal
|
|
289
|
+
return [
|
|
290
|
+
{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0f1424' } },
|
|
291
|
+
{ type: 'text', name: 'word', text: 'HELLO', at: 2, duration: 3,
|
|
292
|
+
style: { fontSize: 120, fontWeight: '900', fill: '#ffffff' },
|
|
293
|
+
initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
|
|
294
|
+
keyframes: [{ at: 0, from: { x: -400 }, to: { x: 'GW/2' }, duration: 0.4, ease: 'power3.out' }] },
|
|
295
|
+
{ type: 'audio', sfx: 'swoosh', at: 2 }, // flies in with a swoosh…
|
|
296
|
+
{ type: 'audio', sfx: 'hit', at: 2.4 }, // …and lands (0.4 s later) with a hit
|
|
297
|
+
];
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Check it without listening, then export — the file encodes this same mix (mp4 / mov: AAC, webm / mkv: Opus; lossy):
|
|
301
|
+
|
|
302
|
+
```js
|
|
303
|
+
// @docs-only render-and-verify
|
|
304
|
+
await movie.init({ canvas, width: 1280, height: 720, duration: 5, composition: { sequences } });
|
|
305
|
+
const a = movie.inspectAudio();
|
|
306
|
+
console.log(a.issues); // must be []
|
|
307
|
+
console.log(a.sources.map(s => [s.layer, s.start, s.sound.loudestAt, s.sound.brightnessHz]));
|
|
308
|
+
const blob = await movie.render({ format: 'mp4' });
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## Sound effects in sync with the picture
|
|
314
|
+
|
|
315
|
+
Line up the LOUD point, not the start: a `riser` is loudest at its end (`at = hit − duration`), a `swoosh` 0.16 s in, everything else at its start (`inspectAudio().sources[i].sound.loudestAt` tells you). Vary `pitch` for a series (a rising pitch reads as progress) and `seed` for repeats (no two typewriter keys alike).
|
|
316
|
+
|
|
317
|
+
```js
|
|
318
|
+
// @recipe sfx-cues
|
|
319
|
+
const BG = { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0f1424' } };
|
|
320
|
+
const HIT = 1.5, RISE = 1.2; // the title slams in at 1.5 s; the riser leads into it
|
|
321
|
+
const sequences = [BG,
|
|
322
|
+
{ type: 'audio', name: 'riser', sfx: 'riser', at: HIT - RISE, duration: RISE, volume: 0.7 }, // a riser is loudest at its END
|
|
323
|
+
{ type: 'audio', name: 'hit', sfx: 'hit', at: HIT },
|
|
324
|
+
{ type: 'text', name: 'title', text: 'LAUNCH', at: HIT, duration: 8 - HIT,
|
|
325
|
+
style: { fontSize: 140, fontWeight: '900', fill: '#ffffff', fontFamily: 'Arial Black, Arial, sans-serif' },
|
|
326
|
+
initial: { x: 'GW/2', y: 260, anchorX: 0.5, anchorY: 0.5 },
|
|
327
|
+
keyframes: [{ at: 0, from: { scale: 2.4, alpha: 0 }, to: { scale: 1, alpha: 1 }, duration: 0.18, ease: 'expo.out' }] },
|
|
328
|
+
];
|
|
329
|
+
['PLAN', 'BUILD', 'SHIP'].forEach((label, i) => { // each item pops in with a pop; pitch climbs a step per item
|
|
330
|
+
const at = 2.4 + i * 0.5;
|
|
331
|
+
sequences.push({ type: 'text', name: 'item' + i, text: label, at, duration: 8 - at,
|
|
332
|
+
style: { fontSize: 44, fontWeight: 'bold', fill: '#ffd166' },
|
|
333
|
+
initial: { x: 360 + i * 280, y: 430, anchorX: 0.5, anchorY: 0.5 },
|
|
334
|
+
keyframes: [{ at: 0, from: { scale: 0, alpha: 0 }, to: { scale: 1, alpha: 1 }, duration: 0.25, ease: 'back.out(2)' }] });
|
|
335
|
+
sequences.push({ type: 'audio', name: 'pop' + i, sfx: { preset: 'pop', pitch: i * 2 }, at });
|
|
336
|
+
});
|
|
337
|
+
const CAPTION = 'ships friday', T0 = 4.4, STEP = 0.07; // typed caption: one letter + one key per step
|
|
338
|
+
[...CAPTION].forEach((ch, i) => {
|
|
339
|
+
const at = T0 + i * STEP;
|
|
340
|
+
sequences.push({ type: 'text', name: 'cap' + i, text: ch, at, duration: 8 - at,
|
|
341
|
+
style: { fontSize: 36, fill: '#9fb3d9', fontFamily: 'ui-monospace, Menlo, monospace' },
|
|
342
|
+
initial: { x: 640 - (CAPTION.length * 22) / 2 + i * 22, y: 540, anchorY: 0.5 } });
|
|
343
|
+
if (ch !== ' ') sequences.push({ type: 'audio', sfx: { preset: 'typewriter', seed: i }, at, volume: 0.6 }); // seed: every key sounds a little different
|
|
344
|
+
});
|
|
345
|
+
sequences.push({ type: 'audio', name: 'done', sfx: 'chime', at: T0 + CAPTION.length * STEP + 0.3 });
|
|
346
|
+
return sequences;
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## Progress ring (an arc) with a counter
|
|
352
|
+
|
|
353
|
+
`shape: 'arc'` is a ring when it has only a stroke: animate `endAngle` from the start angle (−90 = 12 o'clock) round to +270 for a full circle. A second arc under it is the track; `{value}` in the middle counts in step with the same ease.
|
|
354
|
+
|
|
355
|
+
```js
|
|
356
|
+
// @recipe progress-ring
|
|
357
|
+
const CX = 640, CY = 360, R = 120, TO = 0.82; // 82 % filled
|
|
358
|
+
const ease = 'power3.out', AT = 0.3, DUR = 2;
|
|
359
|
+
return [
|
|
360
|
+
{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0f1424' } },
|
|
361
|
+
{ type: 'shape', shape: 'arc', name: 'track', radius: R, startAngle: 0, endAngle: 360,
|
|
362
|
+
initial: { x: CX, y: CY, strokeColor: '#27304d', strokeWidth: 24 } },
|
|
363
|
+
{ type: 'shape', shape: 'arc', name: 'progress', radius: R, startAngle: -90, endAngle: -90, strokeCap: 'round',
|
|
364
|
+
initial: { x: CX, y: CY, strokeColor: '#ffd166', strokeWidth: 24 },
|
|
365
|
+
keyframes: [{ at: AT, to: { endAngle: -90 + 360 * TO }, duration: DUR, ease }] },
|
|
366
|
+
{ type: 'text', name: 'percent', text: '{value}%', format: { decimals: 0 },
|
|
367
|
+
style: { fontSize: 72, fontWeight: 'bold', fill: '#ffffff' },
|
|
368
|
+
initial: { x: CX, y: CY, anchorX: 0.5, anchorY: 0.5, value: 0 },
|
|
369
|
+
keyframes: [{ at: AT, to: { value: TO * 100 }, duration: DUR, ease }] },
|
|
370
|
+
];
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
---
|
|
374
|
+
|
|
375
|
+
## Letters and words that animate one by one (`splitText`)
|
|
376
|
+
|
|
377
|
+
Text content cannot change, so a per-letter or per-word effect is one text layer per piece. `splitText(text, style, { by: 'chars' | 'words' | 'lines', x, y, align })` measures the text with the renderer's own font metrics (kerning kept) and returns each piece's `text`, `x`, `y`, `width`, `index` and `line`: put them straight into the layers. Use the same `style` for the whole text and each piece, and a web font must be loaded first. `measureText(text, style)` gives `{ width, height, lines }` for sizing pills and underlines.
|
|
378
|
+
|
|
379
|
+
```js
|
|
380
|
+
// @recipe split-text
|
|
381
|
+
const style = { fontFamily: 'Arial, sans-serif', fontSize: 110, fontWeight: 'bold', fill: '#ffffff' };
|
|
382
|
+
const sub = { fontFamily: 'Arial, sans-serif', fontSize: 44, fill: '#ffd166' };
|
|
383
|
+
const sequences = [{ type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0f1424' } }];
|
|
384
|
+
splitText('MAKE THINGS', style, { x: 640, y: 200, align: 'center' }).forEach(p => sequences.push({
|
|
385
|
+
type: 'text', name: 'letter-' + p.index, text: p.text, style, at: 0.3 + p.index * 0.06,
|
|
386
|
+
initial: { x: p.x, y: p.y, alpha: 0 }, // x / y are the top-left of the letter: anchorX / anchorY stay 0
|
|
387
|
+
keyframes: [{ at: 0, from: { y: p.y + 40, alpha: 0 }, to: { y: p.y, alpha: 1 }, duration: 0.35, ease: 'back.out(2)' }],
|
|
388
|
+
}));
|
|
389
|
+
splitText('one word at a time', sub, { by: 'words', x: 640, y: 380, align: 'center' }).forEach(p => sequences.push({
|
|
390
|
+
type: 'text', name: 'word-' + p.index, text: p.text, style: sub, at: 1.4 + p.index * 0.25,
|
|
391
|
+
initial: { x: p.x, y: p.y, alpha: 0 },
|
|
392
|
+
keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.3 }],
|
|
393
|
+
}));
|
|
394
|
+
return sequences;
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
282
399
|
## Looping motion
|
|
283
400
|
|
|
284
401
|
`repeat` (a finite count) and `yoyo` go on any keyframe. Total time = `duration × (repeat + 1)`.
|
package/ai/template.html
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
<style>
|
|
8
8
|
body { background: #0d1220; margin: 0; padding: 16px; }
|
|
9
9
|
canvas { width: min(960px, 100%); border-radius: 8px; }
|
|
10
|
+
/* the player wraps the canvas: size the wrapper, so the bar is as wide as the picture */
|
|
11
|
+
.movie-controller-wrap { width: min(960px, 100%); }
|
|
12
|
+
.movie-controller-wrap canvas { width: 100%; height: auto; }
|
|
10
13
|
/* Screenshots: the player bar overlays the bottom ~60px of the canvas (it is NOT part of the exported video).
|
|
11
14
|
Uncomment to hide it while you check the layout: .movie-controller { display: none !important; } */
|
|
12
15
|
</style>
|
|
@@ -33,9 +36,9 @@
|
|
|
33
36
|
"gsap": "https://esm.sh/gsap@3.12.5",
|
|
34
37
|
"gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
|
|
35
38
|
"mediabunny": "https://esm.sh/mediabunny",
|
|
36
|
-
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
37
|
-
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
38
|
-
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.
|
|
39
|
+
"pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/index.js",
|
|
40
|
+
"pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/Controller.js",
|
|
41
|
+
"pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.5.0/dist/three.js",
|
|
39
42
|
"three": "https://esm.sh/three@0.178.0"
|
|
40
43
|
}
|
|
41
44
|
}
|