pixi-effects 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +5 -5
  3. package/ai/chat-template.html +3 -3
  4. package/ai/tools/check.mjs +38 -9
  5. package/ai/tools/lookup.mjs +15 -2
  6. package/dist/{Base-pNq6sVGl.d.cts → Base-BlA6SeqJ.d.cts} +1 -1
  7. package/dist/{Base-CXsMdmJB.d.ts → Base-YqM0Ljvp.d.ts} +1 -1
  8. package/dist/Composition-5KZETM4J.js +7 -0
  9. package/dist/{Composition-NDXKQHB4.js.map → Composition-5KZETM4J.js.map} +1 -1
  10. package/dist/Composition-MHDI7UNZ.cjs +16 -0
  11. package/dist/{Composition-OLH7RC5Z.cjs.map → Composition-MHDI7UNZ.cjs.map} +1 -1
  12. package/dist/Controller.d.cts +2 -2
  13. package/dist/Controller.d.ts +2 -2
  14. package/dist/{Movie-VYtUN4TS.d.ts → Movie-CJN_gvLB.d.cts} +26 -2
  15. package/dist/{Movie-Db4QnMIH.d.cts → Movie-CfHaGmUP.d.ts} +26 -2
  16. package/dist/{Spans-QHHWC2PB.js → Spans-FSSO2HGX.js} +6 -6
  17. package/dist/{Spans-QHHWC2PB.js.map → Spans-FSSO2HGX.js.map} +1 -1
  18. package/dist/{Spans-QRMN2EZO.cjs → Spans-WVJH77BW.cjs} +11 -11
  19. package/dist/{Spans-QRMN2EZO.cjs.map → Spans-WVJH77BW.cjs.map} +1 -1
  20. package/dist/{blendModes-WIUCYIMC.cjs → blendModes-FD3THLNC.cjs} +5 -5
  21. package/dist/{blendModes-WIUCYIMC.cjs.map → blendModes-FD3THLNC.cjs.map} +1 -1
  22. package/dist/{blendModes-PP5NPBXY.js → blendModes-FMYO6SQN.js} +4 -4
  23. package/dist/{blendModes-PP5NPBXY.js.map → blendModes-FMYO6SQN.js.map} +1 -1
  24. package/dist/{chunk-ITDYFBB6.js → chunk-32MMNDDN.js} +7 -6
  25. package/dist/chunk-32MMNDDN.js.map +1 -0
  26. package/dist/{chunk-GL2AQFLQ.cjs → chunk-3SAS3ZE5.cjs} +7 -6
  27. package/dist/chunk-3SAS3ZE5.cjs.map +1 -0
  28. package/dist/{chunk-7TTCPHAV.cjs → chunk-E26M4NTL.cjs} +235 -49
  29. package/dist/chunk-E26M4NTL.cjs.map +1 -0
  30. package/dist/{chunk-XV4L72FW.js → chunk-IPQNT65V.js} +6 -6
  31. package/dist/chunk-IPQNT65V.js.map +1 -0
  32. package/dist/{chunk-IMQJVRMT.cjs → chunk-JN7JHNZR.cjs} +5 -5
  33. package/dist/{chunk-IMQJVRMT.cjs.map → chunk-JN7JHNZR.cjs.map} +1 -1
  34. package/dist/{chunk-2GNK6DPV.js → chunk-KNDEKWQO.js} +203 -18
  35. package/dist/chunk-KNDEKWQO.js.map +1 -0
  36. package/dist/{chunk-5IJENZOG.cjs → chunk-MJMOPWMK.cjs} +92 -92
  37. package/dist/chunk-MJMOPWMK.cjs.map +1 -0
  38. package/dist/{chunk-LEECUCTG.cjs → chunk-QHIOOK5S.cjs} +436 -29
  39. package/dist/chunk-QHIOOK5S.cjs.map +1 -0
  40. package/dist/{chunk-Y253JQOH.js → chunk-SBR75G73.js} +416 -11
  41. package/dist/chunk-SBR75G73.js.map +1 -0
  42. package/dist/{chunk-RTJD3ZSW.js → chunk-VNP7QCJE.js} +4 -4
  43. package/dist/{chunk-RTJD3ZSW.js.map → chunk-VNP7QCJE.js.map} +1 -1
  44. package/dist/index.cjs +41 -33
  45. package/dist/index.d.cts +47 -7
  46. package/dist/index.d.ts +47 -7
  47. package/dist/index.js +5 -5
  48. package/dist/loader.css +1 -1
  49. package/dist/present.cjs +5 -5
  50. package/dist/present.d.cts +2 -2
  51. package/dist/present.d.ts +2 -2
  52. package/dist/present.js +5 -5
  53. package/dist/three.cjs +6 -6
  54. package/dist/three.d.cts +2 -2
  55. package/dist/three.d.ts +2 -2
  56. package/dist/three.js +2 -2
  57. package/dist/{types-CavnUvd5.d.cts → types-DvnXXm33.d.cts} +67 -3
  58. package/dist/{types-CavnUvd5.d.ts → types-DvnXXm33.d.ts} +67 -3
  59. package/docs/api.md +5 -4
  60. package/docs/dsl.md +42 -5
  61. package/llms-full.txt +305 -22
  62. package/package.json +1 -1
  63. package/skills/pixi-effects/SKILL.md +5 -4
  64. package/skills/pixi-effects/reference/cheatsheet.md +9 -6
  65. package/skills/pixi-effects/reference/pitfalls.md +7 -0
  66. package/skills/pixi-effects/reference/recipes.md +237 -3
  67. package/skills/pixi-effects/template.html +6 -6
  68. package/dist/Composition-NDXKQHB4.js +0 -7
  69. package/dist/Composition-OLH7RC5Z.cjs +0 -16
  70. package/dist/chunk-2GNK6DPV.js.map +0 -1
  71. package/dist/chunk-5IJENZOG.cjs.map +0 -1
  72. package/dist/chunk-7TTCPHAV.cjs.map +0 -1
  73. package/dist/chunk-GL2AQFLQ.cjs.map +0 -1
  74. package/dist/chunk-ITDYFBB6.js.map +0 -1
  75. package/dist/chunk-LEECUCTG.cjs.map +0 -1
  76. package/dist/chunk-XV4L72FW.js.map +0 -1
  77. package/dist/chunk-Y253JQOH.js.map +0 -1
package/llms-full.txt CHANGED
@@ -6,11 +6,11 @@ Concatenation of the skill, cheatsheet, pitfalls, recipes and the DSL / API refe
6
6
 
7
7
  <!-- ===== skills/pixi-effects/SKILL.md ===== -->
8
8
 
9
- > Written for pixi-effects 0.35.0. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
9
+ > Written for pixi-effects 0.37.0. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
10
10
 
11
11
  # pixi-effects — write a video as data
12
12
 
13
- You describe a video as a tree of plain objects; the library plays it in a canvas and exports it (MP4/WebM/MOV). You never write per-frame code. **Read `reference/quickstart.md` first (2 KB): the commands, a working title card, and what goes wrong most.** For anything it does not answer, ask the docs: `npx pixi-effects help <word>` prints the sections about a word (mask, threeD, keyframes, music …); `npx pixi-effects recipe` lists the tested recipes (a title, a lower third, a chart, kinetic type, a 2.5D scene …) and `npx pixi-effects recipe <id>` prints one with its code. `reference/cheatsheet.md` (35 KB) and `reference/pitfalls.md` (41 KB, numbered) are for looking things up when a warning or the picture surprises you: search them for the word, do not read them end to end. A simple title needs the quick start and one recipe, nothing more.
13
+ You describe a video as a tree of plain objects; the library plays it in a canvas and exports it (MP4/WebM/MOV). You never write per-frame code (except inside a `canvas` layer, which you add only for a picture nothing else draws). **Read `reference/quickstart.md` first (2 KB): the commands, a working title card, and what goes wrong most.** For anything it does not answer, ask the docs: `npx pixi-effects help <word>` prints the sections about a word (mask, threeD, keyframes, music …); `npx pixi-effects recipe` lists the tested recipes (a title, a lower third, a chart, kinetic type, a 2.5D scene …) and `npx pixi-effects recipe <id>` prints one with its code. `reference/cheatsheet.md` (35 KB) and `reference/pitfalls.md` (41 KB, numbered) are for looking things up when a warning or the picture surprises you: search them for the word, do not read them end to end. A simple title needs the quick start and one recipe, nothing more.
14
14
 
15
15
  ## Workflow
16
16
 
@@ -21,10 +21,10 @@ You describe a video as a tree of plain objects; the library plays it in a canva
21
21
  **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.
22
22
  5. **Look at it** — you cannot judge motion from code. The library has the tools built in:
23
23
  - `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.
24
- - `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.
24
+ - `await movie.inspect(frame)` → every layer's canvas bounds and visibility, plus `issues` (text off the canvas, cut off by an edge, empty, overlapping other text, or hidden / struck through by a picture: an opaque shape or image drawn OVER part of it, or a thin line drawn over its body; a shadow, a glow, a ring or a backdrop under it is not reported). Run it at several frames; fix every issue it names.
25
25
  - `await movie.snapshot(frame, { as: 'dataURL' })` → one frame, canvas only (no player bar).
26
26
  - `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.
27
- - **A moment, a movement, the sound (`pixi-effects check` options):** `--at 3.5,title@end,50%,f120` writes those pictures to `check-out/<name>/frames/*.png` (and `at.png`): the last frame of a layer is `name@end`, the middle `name@mid`. `--onion 1:3` writes `onion.png`, eight frames of seconds 1–3 overlaid (the later the stronger): a trail that shows a movement's path and easing (even steps = constant speed, bunched = slow). `report.audio` now has `loudness` (`integratedLufs`, `truePeakDb`, `clippedSamples`, `silences`), `scenes` (per named top-level composition), `cues` (when each sound starts) and `notes` (advice: quiet or loud for web video, little headroom, silent stretches; web video is typically −14 to −16 LUFS); `waveform.png` draws the mix with the scene edges and the cues. A web font that failed to load fails the check; a text layer none of whose fonts is installed is listed for review.
27
+ - **A moment, a movement, the sound (`pixi-effects check` options):** `--at 3.5,title@end,50%,f120` writes those pictures to `check-out/<name>/frames/*.png` (and `at.png`; with `--out DIR` they land in `DIR/frames/` and `DIR/at.png`, there is no `<name>/` inside DIR); add `--crop X,Y,W,H` (canvas pixels) to get each picture as just that part, enlarged up to 4x, so a 40 px figure, a fine line or a texture can be judged (the contact sheet tiles are too small for that): the last frame of a layer is `name@end`, the middle `name@mid`. `--onion 1:3` writes `onion.png`, eight frames of seconds 1–3 overlaid (the later the stronger): a trail that shows a movement's path and easing (even steps = constant speed, bunched = slow). `report.audio` now has `loudness` (`integratedLufs`, `truePeakDb`, `clippedSamples`, `silences`), `scenes` (per named top-level composition), `cues` (when each sound starts) and `notes` (advice: quiet or loud for web video, little headroom, silent stretches; web video is typically −14 to −16 LUFS); `waveform.png` draws the mix with the scene edges and the cues. A web font that failed to load fails the check; a text layer none of whose fonts is installed is listed for review.
28
28
  - **A deck** (`composition.stops`, `deck()`): `await movie.contactSheet({ frames: movie.stops.map(s => s.frame), as: 'dataURL' })` is one picture of every stop (the check writes it as `stops.png`); what the audience reads while a page waits is that picture, so check every one. `await inspectStops(movie)` (from `pixi-effects/present`) lists the stops where the picture is still changing (a stop landing before its animation ends); the check prints it as a review list: move the stop later or flag a settled one `pdf: true`. `await stopImages(movie, { which: 'pages' })` / `exportPDF(movie)` give the pages themselves.
29
29
  From a browser tool the result is a `data:` URL string: `agent-browser eval "movie.contactSheet({ count: 6, as: 'dataURL' })" | python3 scripts/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.
30
30
  6. **Iterate** on what you saw (clipped text, collisions, wrong timing). Fast motion looks choppy: add `motionBlur: true` to `movie.init` (render, snapshot and contact sheet then show a film-style blur; renders take ~8× longer). Then export: `const blob = await movie.render({ format: 'mp4' })` (about real time; `movie.on('progress', …)` reports 0–100). **While you iterate, do not export the whole movie:** `check --no-export --at <the moment you changed>` takes seconds (3 to 10 s on the gallery pieces: most of it is loading the page and measuring the sound); to see motion, `render({ range: [from, to] })` / `pixi-effects render --range 10:15` / `--scene name` exports only that part (picture and sound); `draft: true` / `--draft` is half size, low quality and no motion blur: a much smaller file (a piece that asks for motion blur renders much faster as a draft: 4 s of `hanabi-night` took 14.0 s, and 8.2 s as a draft, about 7 s of either being the browser's start-up), but the drawing itself is not faster. Export the whole movie once, at the end.
@@ -50,6 +50,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
50
50
  - **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.
51
51
  - **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.
52
52
  - **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` from `pixi-effects/present` (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.)
53
+ - **Draw it yourself (`canvas` layer):** when nothing in the library draws what you need (an afterimage, rings, a brush stroke, a pixel sprite, a chart), `{ type: 'canvas', width, height, draw: (ctx, t, info) => { … } }`: your function of the time draws into the layer's own cleared 2D canvas and it is a normal layer. Pure function of `t` and `info.params` (`info.random()` is seeded; no `Math.random` / `Date.now`); a simulation is computed in `setup` and read by `info.frame`. `info.time` is the movie's clock (several layers share it; never add `at` by hand), `info.canvas(w, h)` makes sprites and masks in `setup`, data shared by several canvas layers lives in module scope, and `import { ease, noise, noise2, random, samplePath } from 'pixi-effects'` gives the library's easing, seeded noise and a path walked by length. `check` prints a `canvas` line (deterministic, slowest draw in ms) and does NOT see text drawn in a canvas. `help canvas`, recipes `canvas-trail`, `canvas-life`, `canvas-text-mask`, `canvas-text-on-path`, `canvas-bars`.
53
54
  - **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.
54
55
  - **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.
55
56
  - **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.
@@ -120,9 +121,10 @@ A video is **data**: a tree of plain objects. Everything below is a JS object yo
120
121
  | Angles | **degrees**: `rotation`, `skew*`, `rotationX`, `rotationY` |
121
122
  | Depth (`z`) | `+z` toward the viewer (bigger, nearer) |
122
123
  | Numbers | any number may be an **expression string**: `'GW/2 - w/2'`, `'min(W,H)*0.4'` (build-time only; no per-frame time variable). Functions: `min max abs floor ceil round sqrt pow sin cos tan lerp clamp smoothstep mod step`, `PI`, and **seeded** `rand(seed)` (0…1) / `noise(x, seed)` (−1…1): give each layer its own seed, never `Math.random()`. **Relative values** work in a keyframe's `to` / `from` / `set` (not in `initial`, a style, or the start of a `from`+`to`): `'+=36'` / `'-=36'` are measured from where the layer stands, and the right side is an expression (`'-=GW*0.1'`). A slide-in is `from: { x: '-=36' }`, a nudge `to: { y: '+=40' }`; keyframes one after another stack; with `repeat` the same move replays from the same start (it does not stack). Expressions work in layer `x y` and the other numeric props, **not** in `line.from / to`, `polygon.points` or `path.d`: compute those in JS (`const W = 1920`) |
123
- | Colours | `'#rrggbb'` strings or numbers; text colour is `style.fill`, shape colour is `fillColor`/`strokeColor`, image colour is `tint` |
124
+ | Colours | `'#rrggbb'` strings or numbers; text colour is `style.fill` (or `initial: { fill }`; a `fill` keyframe animates it), shape colour is `fillColor`/`strokeColor`, image colour is `tint` |
124
125
  | Z-order | **array order**, later = on top. A layer is hidden (not removed) outside `[at, at+duration)` and keeps its last values |
125
126
  | Time of a sequence's `at` | seconds from the start of its **parent composition** (default 0) |
127
+ | `from` before its `at` | a keyframe's `from` holds **from the start of the layer**, not from its `at` (so a title waits invisible with `from: { alpha: 0 }`), and it replaces `initial`: `initial: { alpha: 0 }` with `{ at: 2, from: { alpha: 1 }, … }` shows the layer at alpha 1 from 0 s. To appear only at 2 s give the layer `at: 2`, or start from the same value in both |
126
128
  | Time of a keyframe's `at` | seconds from the start of **its own layer**; **negative = back from the layer's end** |
127
129
  | Time of a transition's `at` | seconds from the start of the **parent composition**; negative = back from its end |
128
130
  | `duration` | defaults to the parent composition's duration |
@@ -160,9 +162,9 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
160
162
 
161
163
  ## Layer types
162
164
 
163
- **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. **Changing text:** a keyframe `set: { text: 'Two' }` swaps the string at that time (undone on seek back; `to` / `from` cannot tween a string); `visibleChars` (number, first N characters; starts at 0 once animated) is a typewriter: `to: { visibleChars: 30 }, duration: 2.4, ease: 'none'` — use `anchorX: 0` + `style.align: 'left'` so it grows rightward. Multi-line text is centre-aligned by default (`style.align: 'left'` to change). **Colour inside one line:** `spans: [{ text: 'type', fill: '#7fa07f' }, ': ', { text: "'a'", fill: '#ffd166', fontWeight: '700' }, ',\n next line']` instead of `text` (strings use the layer's `style`; a span changes only fill / fontWeight / fontStyle / fontVariant / letterSpacing / dropShadow, never a size, font or stroke (those are the layer's `style`); `\n` starts a line; `style.align: 'left'` for code; `visibleChars` types over all the spans; it becomes a group of text layers `<name>.0…`).
165
+ **text** — `text`, `resolution` (1–8: draw at N× the pixels so text inside a parent scaled up N× stays sharp), `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 */, time: 'hh:mm:ss'|'h:mm:ss'|'hh:mm'|'mm:ss'|'m:ss' /* the value in SECONDS as a clock */ }`) — that is how counters work. **Changing text:** a keyframe `set: { text: 'Two' }` swaps the string at that time (undone on seek back; `to` / `from` cannot tween a string); `visibleChars` (number, first N characters; starts at 0 once animated) is a typewriter: `to: { visibleChars: 30 }, duration: 2.4, ease: 'none'` — use `anchorX: 0` + `style.align: 'left'` so it grows rightward. Multi-line text is centre-aligned by default (`style.align: 'left'` to change). **Colour inside one line:** `spans: [{ text: 'type', fill: '#7fa07f' }, ': ', { text: "'a'", fill: '#ffd166', fontWeight: '700' }, ',\n next line']` instead of `text` (strings use the layer's `style`; a span changes only fill / fontWeight / fontStyle / fontVariant / letterSpacing / dropShadow, never a size, font or stroke (those are the layer's `style`); `\n` starts a line; `style.align: 'left'` for code; `visibleChars` types over all the spans; it becomes a group of text layers `<name>.0…`).
164
166
 
165
- **image** — `asset`, `tint`, `colorSpace: 'rgb'|'oklab'|'oklch'`. Default anchor top-left; natural size = `w`,`h`.
167
+ **image** — `asset`, `tint`, `colorSpace: 'rgb'|'oklab'|'oklch'`, `scaleMode: 'nearest'` (hard square pixels when scaled up: pixel art; default `'linear'`, smooth; it belongs to the asset). Default anchor top-left; natural size = `w`,`h`.
166
168
 
167
169
  **video** — `asset`, `loop`, `audio`, `volume`, **`speed`**; `initial: { scale: 'cover' }`. `speed: 0.5` slow motion, `speed: -1` backward (it starts at |speed| × `duration`, so a 3 s file with no `duration` plays whole, backward); with no `duration` the layer lasts the file's length ÷ |speed|. For anything that is not one straight speed — a freeze, a ramp, a loop — animate **`time`** (seconds in the file) like any property: `keyframes: [{ at: 0, from: { time: 0 }, to: { time: 2 }, duration: 1 }, { at: 1, to: { time: 2 }, duration: 0.5 }, { at: 1.5, to: { time: 6 }, duration: 3, ease: 'power2.inOut' }]` (play 2 s, hold, then accelerate). `repeat` loops it, `yoyo` ping-pongs. `speed` itself is not animatable (it warns): animate `time`. The sound follows (pitch too, like a tape).
168
170
 
@@ -180,14 +182,16 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
180
182
  | 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 |
181
183
  | 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` |
182
184
  | polygon | `points: [[x,y],…] open` — canvas coordinates like `line` |
183
- | path | `d` (SVG path data) — canvas coordinates like `line`. **Morph:** `morphTo: 'M…Z'` + animate `morph` 0 → 1 (points half way between, closed outlines lined up so they do not twist; `morphPoints` 8–2048) |
185
+ | path | `d` (SVG path data) — canvas coordinates like `line`; leave `x` / `y` out and it sits where `d` says; give them (even `y: 0`) and they place its MIDDLE, not an offset (measured: `y: 0` put a square's centre on the top edge). **Holes:** several sub-paths are each filled alone (a donut written as two circles is a disc) unless `fillRule: 'evenodd'`: the biggest is the shape, the others are holes. **Morph:** `morphTo: 'M…Z'` + animate `morph` 0 → 1 (points half way between, closed outlines lined up so they do not twist; `morphPoints` 8–2048; two outlines with the same number of corners are paired corner to corner, so a quad stays a sharp quad) |
184
186
 
185
- **Draw-on:** every shape but `arc` has `trimStart` / `trimEnd` (0–1 of the outline, default 0 / 1, animatable): `trimEnd: 0` + keyframe `to: { trimEnd: 1 }` draws a line / border / path on; `trimStart` 0 → 1 after it wipes it off; both = a travelling dash. The fill is NOT trimmed. Rect outline starts top-left, circle / ellipse at 12 o'clock, both clockwise. A `path` with several sub-paths walks them in order as one length; `trimEach: true` trims each on its own (all draw on together). Use `strokeCap: 'round'`. **Dashes:** there is no dash option; write the outline as alternating sub-paths (`d: 'M 0 0 L 10 0 M 20 0 L 30 0 …'`, or sample the curve) and `trimEnd` 0 → 1 walks them in order, so the dashes draw on one after another (as in `replay-highlight`).
187
+ **Draw-on:** every shape but `arc` has `trimStart` / `trimEnd` (0–1 of the outline, default 0 / 1, animatable): `trimEnd: 0` + keyframe `to: { trimEnd: 1 }` draws a line / border / path on; `trimStart` 0 → 1 after it wipes it off; both = a travelling dash. The fill is NOT trimmed. Rect outline starts top-left, circle / ellipse at 12 o'clock, both clockwise. A `path` with several sub-paths walks them in order as one length; `trimEach: true` trims each on its own (all draw on together). Use `strokeCap: 'round'`. **Dashes:** there is no dash option; write the outline as alternating sub-paths (`d: 'M 0 0 L 10 0 M 20 0 L 30 0 …'`, or sample the curve) and `trimEnd` 0 → 1 walks them in order, so the dashes draw on one after another (as in `route-atlas`).
186
188
 
187
189
  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; DEFAULT 90 = top→bottom, 0 = left→right: a wide gradient slid sideways as a matte needs angle: 0 */, center?, innerRadius?, radius? /* radial, 0–1 of the SHAPE's own bounds, not the canvas: a glow on a 1500 px circle is placed with fractions of that circle */ }` — stops may have alpha, so a radial transparent→dark is a vignette. **Animate it** with a partial gradient in a keyframe (`to: { fillGradient: { angle: 360, stops: [...] } }`: keys you leave out stay; the same number of stops; `type` cannot change; a start gradient is required; use `colorSpace: 'oklch'` for a vivid fade). A text layer takes `fillGradient` too (LINEAR only: angle and stops; type / center / radius are ignored with a warning; letters re-rasterised per change). `from` alone runs from that gradient to the layer's own. The names `'fillGradient.angle'` / `gradientAngle` are not properties: write `fillGradient: { angle }`. 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).
188
190
 
189
191
  **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.
190
192
 
193
+ **canvas** — `{ type: 'canvas', width, height, draw: (ctx, t, info) => { … }, setup?, params?, seed?, resolution?, smoothing? }`: a picture drawn by YOUR function of the time into the layer's own cleared canvas (`t` = the layer's seconds; `info` = `{ frame, duration, progress, width, height, resolution, frameRate, params, state, random, start, time, canvas }`: `time` is the MOVIE's seconds (`start + t`; share it between layers instead of adding `at` by hand), `canvas(w, h)` an offscreen canvas for sprites and masks made in `setup`). It is a normal layer (initial, keyframes, mask, filters, blendMode …); `params` are numbers keyframes animate as `'params.x'`; no `Math.random` / `Date.now` (`info.random()` is seeded); a simulation is computed in `setup` and read by `info.frame`; it cannot see what is below it. Data several canvas layers share lives in module scope; `import { ease, noise, noise2, random, rand, samplePath } from 'pixi-effects'` for easing by keyframe name, seeded noise and a path walked by length (`samplePath(d).at(u)` / `.trim(a, b)`). `check` draws it twice per moment (one draw thrown away first) and reports a picture that differs, with how much; it prints a `canvas` line when all pass and does NOT see text drawn in a canvas.
194
+
191
195
  **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.
192
196
 
193
197
  **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.
@@ -265,7 +269,7 @@ measureText('Hello', style) // → { width, height,
265
269
  splitText('Hello world', style, { by: 'chars' | 'words' | 'lines', x, y, align: 'left' | 'center' | 'right' })
266
270
  // → [{ text, x, y, width, index, line }] — one text layer per piece, same style, anchor top-left
267
271
  ```
268
- Per-letter / per-word / per-line animation in one call: `...animateText(text, style, { x, y, align, by: 'chars'|'words'|'lines', at, duration /* required: until the last piece is gone */, in: 'rise'|'drop'|'fade'|'pop'|'zoom'|'slide'|'spin'|{ from, to, duration, ease }|false, out: <same, in the same wave (leave `out` out and the pieces stay until the layer ends); by default BACKWARDS FROM THE LAYER'S END (at + duration, so to be gone at time T set duration = T − at); `out: { preset: 'fade', at: 6 }` starts the exit at composition second 6 instead>, stagger: { each, from, … }, idle: { y: 4, rotation: 1 }, styleFor: p => ({ fill }) })` → text layers `<name>-0…` (anchor centre; `x`/`y` in `from` are offsets) to spread into `sequences`. **`x` / `y` are pixel NUMBERS (an expression like `'GW/2'` throws: compute `W / 2`), and `y` is the TOP of the line box**: the visible text is centred `lineHeight / 2` below it, so an underline goes at `y + lineHeight`, not at `y` (recipe `animate-text`). By hand: `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
272
+ Per-letter / per-word / per-line animation in one call: `...animateText(text, style, { x, y, align, vertical: true /* tategaki: down a column, new line = new column to the left, x = RIGHT edge of the first column; 、。 ー 「」 placed and turned for you */, by: 'chars'|'words'|'lines', at, duration /* required: until the last piece is gone */, in: 'rise'|'drop'|'fade'|'pop'|'zoom'|'slide'|'spin'|{ from, to, duration, ease }|false, out: <same, in the same wave (leave `out` out and the pieces stay until the layer ends); by default BACKWARDS FROM THE LAYER'S END (at + duration, so to be gone at time T set duration = T − at); `out: { preset: 'fade', at: 6 }` starts the exit at composition second 6 instead>, stagger: { each, from, … }, idle: { y: 4, rotation: 1 }, styleFor: p => ({ fill }) })` → text layers `<name>-0…` (anchor centre; `x`/`y` in `from` are offsets) to spread into `sequences`. **`x` / `y` are pixel NUMBERS (an expression like `'GW/2'` throws: compute `W / 2`), and `y` is the TOP of the line box**: the visible text is centred `lineHeight / 2` below it, so an underline goes at `y + lineHeight`, not at `y` (recipe `animate-text`). By hand: `splitText(...)` + one layer per piece (recipe `split-text`). A web font must be loaded before measuring.
269
273
 
270
274
  ## Assets
271
275
 
@@ -438,6 +442,13 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
438
442
  109. **No font is bundled, and each script needs a font that has its characters.** The machine's own fonts are used; characters a font lacks are drawn with whatever the machine falls back to, so the same page can look different on another machine. Load a web font that covers the script (Noto Sans JP, Noto Sans KR for Hangul, Noto Sans SC) with `await document.fonts.load('40px "Noto Sans KR"')` before `movie.init`, and say it in `style.fontFamily` (pitfall 103). `check` lists a layer none of whose fonts is available, but a family that nothing installs or loads is silently replaced by the next one in the list (or the generic name) without a word: the first name in `fontFamily` is not proof it was used.
439
443
  110. **Right-to-left text: three traps.** `splitText` / `animateText` order words left to right, so Arabic or Hebrew comes out reversed (split by words yourself, from the right edge, with `measureText` of the logical prefix); a mark below the font box can be clipped (the dots of an Arabic ي) unless the layer has `style.padding`; `check` sees neither. `bulgePinch.center` is 0–1 of the layer, not pixels (pitfalls elsewhere say canvas pixels for the other centres: `twist`, `zoomBlur`, `shockwave`). `npx pixi-effects help arabic` and `help bulgePinch` print the sections.
440
444
  111. **Only some named filters are tested on both backends.** `glow`, `outline`, `dropShadow`, `bevel`, `bloom`, `pixelate`, `crt`, `rgbSplit`, `dot`, `radialBlur` and `glitch` look the same on WebGPU (the default) and WebGL; `multiColorReplace` draws everything black on WebGPU, `kawaseBlur` is far weaker and `advancedBloom` sharper there: use `colorReplace`, `blur`, `bloom`. A filter follows the edges of the layer's own picture: `glow` on a full-screen opaque layer shows nothing (put it on the shapes and the text).
445
+ 112. **Text inside a parent that is scaled up comes out soft.** Text is drawn once at its own size and then stretched, so `{ type: 'null', initial: { scale: 3 } }` with a text child of `fontSize: 30` is blurry (shapes stay sharp). Give the text `resolution: 3` (it is drawn at 3 times the pixels, for a parent that grows to 3x; 1 to 8, the cost is the memory), or write it at the size it is shown (`fontSize: 90`, parent scale 1), or let the parent only scale DOWN (a pull-back from big to normal is sharp the whole way). `check --at … --crop` shows the difference.
446
+ 113. **Two limits on sound that `help sfx` / `help music` did not say.** An `sfx` lasts at most 10 s (`MAX_LENGTH` in the synth): for a longer rain or room tone, chain two audio layers that overlap by a moment, or use `loop` on a short one. A `music` layer plays at most 16 tracks, and a warning says that only the first 16 play: put the rest in a second audio layer.
447
+ 114. **A keyframe's `from` is on screen from the start of the layer, and it beats `initial`.** `initial: { alpha: 0 }` plus `{ at: 2, from: { alpha: 1 }, to: { alpha: 0.5 }, duration: 1 }` shows the layer at full alpha from second 0 (measured in Chrome: the red square was there at 0.5 s). That is how a title waits invisible with `from: { alpha: 0 }` until its `at`, but it surprises when `from` is the visible end. Give the layer its own `at: 2`, or use `set` at the moment it should appear. No warning: the same pattern (`initial` = the resting value, `from` = where it starts) is what the recipes do everywhere.
448
+ 115. **A `canvas` layer is a function of the time, nothing more.** The canvas is cleared before every call and nothing survives into the next, so `x += 1` per call, "draw over the last picture" and `Math.random()` all break: a seek or an export would not match playback (`check` draws each canvas layer twice at a few moments and reports a layer whose pictures differ). Compute from `t` (a trail is the positions of the past), use `info.random()` (seeded) and `params` that keyframes animate, and compute a whole simulation in `setup` (an array by frame) and read `info.frame`. A cache of a pure function is fine (same input, same output); state that depends on which frames were drawn before is not. A canvas layer cannot see or paint on what is below it: use `filters`, a matte or a composition for that. Pixel art needs `smoothing: false` (hard pixels when scaled up) and a small `width` / `height` with a big `initial.scale`.
449
+ 116. **Several canvas layers that need the same thing: module scope and `info.time`, not `setup` and a hand-added `at`.** The state a layer's `setup` returns is private to that layer. A plant, a city or a palette that two layers draw is computed once before `movie.init` (from `random(seed)` and plain functions) and read by both; the shared clock is `info.time` (the movie's seconds), not `t + 3` with the layer's `at` copied in, which goes wrong without a word when someone moves the layer.
450
+ 117. **A sprite or mask made in `setup` with `document.createElement('canvas')` can be drawn differently the first time.** A GPU canvas used as the source of `drawImage`, under `destination-in` after many anti-aliased strokes, drew up to 128 of 255 differently over 40 000 pixels of a 1280×720 layer the first time and the same every time after. `info.canvas(w, h)` makes a canvas that does not do this; `check` throws away one draw of each moment before it compares, so it does not blame the layer, but an export that draws a frame for the first time is still safer with `info.canvas`. A mask is also easier to get right as: the words first, then `globalCompositeOperation = 'source-atop'` and the texture (recipe `canvas-text-mask`); two `fillText` under `destination-in` intersect.
451
+ 118. **`check` does not see text drawn in a canvas.** The layout test (cut-off, off-canvas, overlap) reads the text layers; a label or a caption you draw with `fillText` can run off the edge or collide with another without a word, and "no cut-off text" only speaks for the text layers. Look at the sheet and at `--at … --crop`, and keep a margin.
441
452
 
442
453
 
443
454
  <!-- ===== skills/pixi-effects/reference/recipes.md ===== -->
@@ -589,6 +600,229 @@ return [
589
600
 
590
601
  ---
591
602
 
603
+ ## Push in on a whole flat scene (the 2D "camera"): a null that scales, and text drawn big enough
604
+
605
+ There is no second camera for flat scenes: the camera of the 2.5D layers is for `threeD` layers. For a push-in or pull-back of an ordinary 2D scene, put everything in a `null` layer that sits at the middle of the canvas and scale THAT (children use `parent: 'world'`, and their x / y are measured from the null's origin, so the middle of the canvas is 0, 0). Text is drawn once at its own size and then stretched, so give every text `resolution: R` where R is the biggest scale the group reaches (R 1–8); otherwise it goes soft.
606
+
607
+ ```js
608
+ // @recipe scene-zoom
609
+ const R = 2; // the group grows to 2x: its text is drawn at 2x pixels
610
+ const inWorld = (spec) => ({ parent: 'world', ...spec });
611
+ return [
612
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0f1424' } },
613
+ { type: 'null', name: 'world', initial: { x: 'GW/2', y: 'GH/2' },
614
+ keyframes: [{ at: 0.5, from: { scale: 1 }, to: { scale: R }, duration: 3.5, ease: 'power2.inOut' }] },
615
+ inWorld({ type: 'shape', shape: 'circle', radius: 120, initial: { x: 0, y: 0, fillColor: '#3b5bdb' } }),
616
+ inWorld({ type: 'shape', shape: 'circle', radius: 40, initial: { x: 260, y: -120, fillColor: '#ffd166' } }),
617
+ inWorld({ type: 'text', text: 'Zoom in', resolution: R, style: { fontSize: 48, fontWeight: 'bold', fill: '#ffffff' }, initial: { x: 0, y: 0, anchorX: 0.5, anchorY: 0.5 } }),
618
+ inWorld({ type: 'text', text: 'sharp at 2x', resolution: R, style: { fontSize: 22, fill: '#c9d3f5' }, initial: { x: 0, y: 150, anchorX: 0.5, anchorY: 0.5 } }),
619
+ ];
620
+ ```
621
+
622
+ ---
623
+
624
+ ## Parallax: layers that scroll past at their own speed (a window of a train, a side-scroller)
625
+
626
+ Each layer is a long strip of repeated shapes that moves left at a constant speed, `speed × duration` pixels in all, so it must be that long plus the width of the screen. The speed is the depth: the near strip fast, the far one slow. A strip that is longer than it needs to be is only more layers.
627
+
628
+ ```js
629
+ // @recipe parallax-strips
630
+ const D = 8, W = 1280;
631
+ // one strip: `count` shapes `gap` px apart, scrolling left at `speed` px/s from x = 0 for D seconds
632
+ const strip = (name, speed, gap, y, size, color) => {
633
+ const count = Math.ceil((speed * D + W) / gap) + 1;
634
+ return Array.from({ length: count }, (_, i) => ({
635
+ type: 'shape', shape: 'rect', name: `${name}-${i}`, width: size, height: size * 2,
636
+ initial: { x: i * gap, y, fillColor: color },
637
+ keyframes: [{ at: 0, to: { x: i * gap - speed * D }, duration: D, ease: 'none' }],
638
+ }));
639
+ };
640
+ return [
641
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#1b2a4a' } },
642
+ ...strip('far', 30, 240, 420, 60, '#26365c'), // distant hills: 30 px/s
643
+ ...strip('mid', 120, 200, 500, 40, '#314a7a'), // 120 px/s
644
+ ...strip('near', 480, 360, 600, 24, '#0b1220'), // poles: 480 px/s
645
+ ];
646
+ ```
647
+
648
+ To land something exactly where you want at a time `t` (a moon under a bridge at 6 s), compute the strip's x at `t` the same way: `i * gap - speed * t`.
649
+
650
+ ---
651
+
652
+ ## Draw it yourself: the `canvas` layer (an afterimage, rings that grow, a pixel sprite, a simulation)
653
+
654
+ When no layer draws what you need, write the drawing as a **function of the time**: `draw(ctx, t, info)` paints into the layer's own cleared 2D canvas and the layer is a normal layer (`initial`, `keyframes`, `filters`, `mask`, export). Rules: the same `t` and `info.params` give the same pixels (no `Math.random` or `Date.now`: `info.random()` is seeded), nothing survives from the last frame, and a simulation is computed whole in `setup`. `help canvas` has the contract.
655
+
656
+ An **afterimage** is drawn from the positions of the past, not from the last picture:
657
+
658
+ ```js
659
+ // @recipe canvas-trail
660
+ const at = (t) => ({ x: 640 + 420 * Math.sin(t * 1.7), y: 360 + 200 * Math.sin(t * 2.9) }); // where the dot is at time t
661
+ return [
662
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#101420' } },
663
+ { type: 'canvas', name: 'trail', duration: 6, params: { tail: 0.5 },
664
+ draw: (ctx, t, info) => {
665
+ const N = 14;
666
+ for (let i = N; i >= 0; i--) { // the oldest first, the head last
667
+ const p = at(Math.max(0, t - i * info.params.tail / N));
668
+ ctx.globalAlpha = (1 - i / (N + 1)) * 0.85;
669
+ ctx.fillStyle = '#ff4f1a';
670
+ ctx.beginPath(); ctx.arc(p.x, p.y, 34 - i * 1.6, 0, Math.PI * 2); ctx.fill();
671
+ }
672
+ } },
673
+ ];
674
+ ```
675
+
676
+ **Rings that grow**, wobbling a little with a seeded `random()`, and `params` that keyframes animate (the `progress` of the layer is `info.progress`):
677
+
678
+ ```js
679
+ // @recipe canvas-rings
680
+ return [
681
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#efe9dc' } },
682
+ { type: 'canvas', name: 'rings', width: 720, height: 720, duration: 8, seed: 3, params: { years: 0 },
683
+ initial: { x: 640, y: 360, anchorX: 0.5, anchorY: 0.5 },
684
+ keyframes: [{ at: 0, to: { 'params.years': 40 }, duration: 8, ease: 'power1.inOut' }],
685
+ draw: (ctx, t, info) => {
686
+ const cx = info.width / 2, cy = info.height / 2, n = Math.floor(info.params.years);
687
+ const wob = Array.from({ length: n + 1 }, () => [info.random() * 6, info.random() * Math.PI * 2]); // the same wobble at every frame
688
+ for (let k = n; k >= 0; k--) {
689
+ const r = 8 + k * 8.5 + (k === n ? (info.params.years - n) * 8.5 : 0);
690
+ ctx.beginPath();
691
+ for (let a = 0; a <= 64; a++) { const th = (a / 64) * Math.PI * 2, rr = r + wob[k][0] * Math.sin(th * 3 + wob[k][1]); ctx.lineTo(cx + rr * Math.cos(th), cy + rr * Math.sin(th)); }
692
+ ctx.closePath();
693
+ ctx.fillStyle = k % 2 ? '#c9a36a' : '#a87c45'; ctx.fill();
694
+ }
695
+ } },
696
+ ];
697
+ ```
698
+
699
+ A **pixel sprite**: drawn small with `smoothing: false`, shown big (the layer is scaled up and the pixels stay hard):
700
+
701
+ ```js
702
+ // @recipe canvas-pixel-sprite
703
+ const ROWS = ['..1111..', '.111111.', '11.11.11', '11111111', '.1.11.1.', '..1111..', '.1....1.', '1......1'];
704
+ return [
705
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#1b1330' } },
706
+ { type: 'canvas', name: 'sprite', width: 8, height: 8, smoothing: false, duration: 8,
707
+ initial: { x: 640, y: 360, anchorX: 0.5, anchorY: 0.5, scale: 30 },
708
+ keyframes: [{ at: 0, from: { y: 300 }, to: { y: 420 }, duration: 8, ease: 'steps(16)' }], // moves in whole steps
709
+ draw: (ctx, t) => {
710
+ const blink = Math.floor(t * 2) % 2; // two frames of animation
711
+ ROWS.forEach((row, y) => [...row].forEach((c, x) => { if (c === '1') { ctx.fillStyle = (y === 2 && blink) ? '#1b1330' : '#ffd166'; ctx.fillRect(x, y, 1, 1); } }));
712
+ } },
713
+ ];
714
+ ```
715
+
716
+ A **simulation** (a game of life) is computed WHOLE in `setup`, one array per generation, and `draw` only reads the generation for the time (`info.frame`, or here `t`): a pure function of the time, so a seek or an export agrees with playback:
717
+
718
+ ```js
719
+ // @recipe canvas-life
720
+ const W = 48, H = 27, GENERATIONS = 90;
721
+ const step = (g) => g.map((row, y) => row.map((v, x) => {
722
+ let n = 0; for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (dx || dy) n += g[(y + dy + H) % H][(x + dx + W) % W];
723
+ return n === 3 || (v && n === 2) ? 1 : 0;
724
+ }));
725
+ return [
726
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0b0f14' } },
727
+ { type: 'canvas', name: 'life', width: 960, height: 540, duration: 6, seed: 11, initial: { x: 640, y: 360, anchorX: 0.5, anchorY: 0.5 },
728
+ setup: (info) => {
729
+ let g = Array.from({ length: H }, () => Array.from({ length: W }, () => (info.random() < 0.3 ? 1 : 0)));
730
+ const all = [g]; for (let i = 1; i < GENERATIONS; i++) all.push((g = step(g)));
731
+ return all;
732
+ },
733
+ draw: (ctx, t, info) => {
734
+ const g = info.state[Math.min(info.state.length - 1, Math.floor(t * 15))]; // 15 generations a second
735
+ ctx.fillStyle = '#7bd88f';
736
+ g.forEach((row, y) => row.forEach((v, x) => { if (v) ctx.fillRect(x * 20 + 2, y * 20 + 2, 16, 16); }));
737
+ } },
738
+ ];
739
+ ```
740
+
741
+ ---
742
+
743
+ ## Type, curves and charts in a canvas layer (type through a moving texture, text along a curve, bars that swap places)
744
+
745
+ The `canvas` layer (see the section above and `help canvas`) is also where text effects the text layer cannot do and live charts are drawn. `info.canvas(w, h)` makes sprites and masks in `setup`, `info.time` is the movie's clock, and `ease`, `noise`, `noise2`, `random`, `samplePath` are importable from `pixi-effects`. `check` does not see text drawn in a canvas: look at the sheet and at `--crop` enlargements.
746
+
747
+ **Type as a window onto something that moves.** Draw the words once into a canvas from `info.canvas` (in `setup`), draw them back first, then paint with `source-atop`: the paint shows only where the words are (two `fillText` under `destination-in` would intersect instead):
748
+
749
+ ```js
750
+ // @recipe canvas-text-mask
751
+ return [
752
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0d0a14' } },
753
+ { type: 'canvas', name: 'window', width: 1100, height: 320, duration: 6, initial: { x: 640, y: 360, anchorX: 0.5, anchorY: 0.5 },
754
+ setup: (info) => { // the words, drawn once into a canvas of their own
755
+ const word = info.canvas(1100, 320), c = word.getContext('2d');
756
+ c.fillStyle = '#fff'; c.font = '900 280px "Arial Black", "Helvetica Neue", sans-serif';
757
+ c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText('LIGHT', 550, 160);
758
+ return { word };
759
+ },
760
+ draw: (ctx, t, info) => {
761
+ ctx.drawImage(info.state.word, 0, 0); // the words first: they are the window …
762
+ ctx.globalCompositeOperation = 'source-atop'; // … and what is drawn next appears only where they are
763
+ ctx.fillStyle = '#2a1030'; ctx.fillRect(0, 0, 1100, 320);
764
+ for (let k = 0; k < 14; k++) { // bands of colour sliding through
765
+ ctx.fillStyle = k % 2 ? '#ff4f1a' : '#ffb347';
766
+ ctx.fillRect(((k * 90 + t * 160) % 1260) - 80, 0, 38, 320);
767
+ }
768
+ } },
769
+ ];
770
+ ```
771
+
772
+ **Text along a curve, and a line that draws on.** `samplePath(d)` walks SVG path data by length: `at(u)` is the point and the heading a fraction `u` along, `trim(a, b)` the part between two fractions. `ease('power2.out')` is the same easing a keyframe takes. Build the sampler once, outside `draw`:
773
+
774
+ ```js
775
+ // @recipe canvas-text-on-path
776
+ const route = samplePath('M 140 560 C 340 120 940 120 1140 560');
777
+ const WORD = 'ALONG THE CURVE', EASE = ease('power2.out');
778
+ return [
779
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#101420' } },
780
+ { type: 'canvas', name: 'along', duration: 6,
781
+ draw: (ctx, t) => {
782
+ const run = EASE(Math.min(1, t / 3)); // 0 → 1: the line draws on, then the letters settle
783
+ ctx.strokeStyle = '#ff4f1a'; ctx.lineWidth = 3; ctx.beginPath();
784
+ for (const piece of route.trim(0, run)) { ctx.moveTo(piece[0], piece[1]); for (let i = 2; i < piece.length; i += 2) ctx.lineTo(piece[i], piece[i + 1]); }
785
+ ctx.stroke();
786
+ ctx.font = '700 54px Georgia, serif'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#f4efe6';
787
+ let d = 0; // distance along the route, in px
788
+ for (const ch of WORD) {
789
+ const w = ctx.measureText(ch).width;
790
+ const u = (d + w / 2 + 90) / route.length - (1 - run) * 0.5; // where this letter's middle is, a fraction along
791
+ d += w + 4;
792
+ if (u < 0 || u > 1) continue;
793
+ const p = route.at(u);
794
+ ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.angle); ctx.globalAlpha = run; ctx.fillText(ch, -w / 2, -30); ctx.restore();
795
+ }
796
+ } },
797
+ ];
798
+ ```
799
+
800
+ **Bars that swap places smoothly.** A bar's row is its rank averaged over the last third of a second, computed from `t` alone (the values at those earlier moments are a function of time too): when two bars change places they glide past each other, passing through one another for the length of the swap as in any bar race, nothing jumps, and a seek is exact:
801
+
802
+ ```js
803
+ // @recipe canvas-bars
804
+ const NAMES = ['Oslo', 'Lima', 'Kyiv', 'Pune', 'Graz'], FROM = [40, 55, 30, 20, 45], TO = [60, 35, 70, 50, 25];
805
+ const BAR = 90, GAP = 28, EASE = ease('power2.inOut');
806
+ const valuesAt = (time) => FROM.map((a, i) => a + (TO[i] - a) * EASE(Math.min(1, Math.max(0, time) / 5)));
807
+ return [
808
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#101420' } },
809
+ { type: 'canvas', name: 'race', width: 1000, height: 600, duration: 6, initial: { x: 140, y: 60 },
810
+ draw: (ctx, t) => {
811
+ const now = valuesAt(t), before = Array.from({ length: 8 }, (_, k) => valuesAt(t - k * 0.04)); // the last third of a second
812
+ ctx.font = '700 34px system-ui, sans-serif'; ctx.textBaseline = 'middle';
813
+ now.forEach((value, i) => {
814
+ const rank = before.reduce((sum, v) => sum + v.filter(other => other > v[i]).length, 0) / before.length; // 0 = top
815
+ const y = rank * (BAR + GAP);
816
+ ctx.fillStyle = i === 2 ? '#ff4f1a' : '#4c5a78'; ctx.fillRect(160, y, value * 10, BAR);
817
+ ctx.fillStyle = '#f4efe6'; ctx.textAlign = 'right'; ctx.fillText(NAMES[i], 140, y + BAR / 2);
818
+ ctx.textAlign = 'left'; ctx.fillText(String(Math.round(value)), 160 + value * 10 + 14, y + BAR / 2);
819
+ });
820
+ } },
821
+ ];
822
+ ```
823
+
824
+ ---
825
+
592
826
  ## A ticker that scrolls out completely (`w` = the text's own width)
593
827
 
594
828
  `'-w'` is the exact x at which the text has left the screen on the left (`w` is measured after the style is applied).
@@ -921,18 +1155,18 @@ return sequences;
921
1155
  ```js
922
1156
  // @recipe progress-ring
923
1157
  const CX = 640, CY = 360, R = 120, TO = 0.82; // 82 % filled
924
- const ease = 'power3.out', AT = 0.3, DUR = 2;
1158
+ const EASE = 'power3.out', AT = 0.3, DUR = 2;
925
1159
  return [
926
1160
  { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0f1424' } },
927
1161
  { type: 'shape', shape: 'arc', name: 'track', radius: R, startAngle: 0, endAngle: 360,
928
1162
  initial: { x: CX, y: CY, strokeColor: '#27304d', strokeWidth: 24 } },
929
1163
  { type: 'shape', shape: 'arc', name: 'progress', radius: R, startAngle: -90, endAngle: -90, strokeCap: 'round',
930
1164
  initial: { x: CX, y: CY, strokeColor: '#ffd166', strokeWidth: 24 },
931
- keyframes: [{ at: AT, to: { endAngle: -90 + 360 * TO }, duration: DUR, ease }] },
1165
+ keyframes: [{ at: AT, to: { endAngle: -90 + 360 * TO }, duration: DUR, ease: EASE }] },
932
1166
  { type: 'text', name: 'percent', text: '{value}%', format: { decimals: 0 },
933
1167
  style: { fontSize: 72, fontWeight: 'bold', fill: '#ffffff' },
934
1168
  initial: { x: CX, y: CY, anchorX: 0.5, anchorY: 0.5, value: 0 },
935
- keyframes: [{ at: AT, to: { value: TO * 100 }, duration: DUR, ease }] },
1169
+ keyframes: [{ at: AT, to: { value: TO * 100 }, duration: DUR, ease: EASE }] },
936
1170
  ];
937
1171
  ```
938
1172
 
@@ -1040,6 +1274,17 @@ return [
1040
1274
  ];
1041
1275
  ```
1042
1276
 
1277
+ **Vertical Japanese (tategaki)** is one option: `vertical: true`. The characters go down a column and a new line is a new column to the LEFT, so `x` is the RIGHT edge of the first column. The commas sit at the upper right of their cell, and the long vowel mark and the brackets are turned a quarter, as in print.
1278
+
1279
+ ```js
1280
+ // @recipe vertical-text
1281
+ const style = { fontFamily: 'Hiragino Mincho ProN, Yu Mincho, serif', fontSize: 56, fill: '#1c1a17' };
1282
+ return [
1283
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#efe9dc' } },
1284
+ ...animateText('雨のあと、\nコーヒーは「ゆっくり」', style, { x: 900, y: 70, vertical: true, at: 0.3, duration: 5, in: 'fade', stagger: { each: 0.04 } }),
1285
+ ];
1286
+ ```
1287
+
1043
1288
  Presets for `in` / `out`: `rise` (default) `drop` `fade` `pop` `zoom` `slide` `spin`; or `{ from: { x: -30, scale: 0.5, rotation: 20 }, duration: 0.8, ease: 'power3.out' }` (`x` / `y` in `from` are offsets from the resting place, the rest are absolute values). `idle` drifts each piece between the entrance and the exit.
1044
1289
 
1045
1290
  ---
@@ -1948,7 +2193,7 @@ A text layer has one animatable number, `value`, printed wherever the text conta
1948
2193
  }
1949
2194
  ```
1950
2195
 
1951
- `format`: `decimals` (default 0), thousands `grouping` (default false) and `pad` (zero-pad the whole part to that many digits: `pad: 2` gives `05`, so a clock is `text: '18:42:{value}'` with `format: { pad: 2 }`). Put prefixes and suffixes in the text (`'${value}'`, `'{value}%'`). If `value` is animated but the text has no `{value}`, a warning says so.
2196
+ `resolution` (1 to 8, default the renderer's own) draws the text at that many times the pixels: text is drawn once at its own size and then stretched by its parents, so text inside a group scaled up to 3x is soft unless it has `resolution: 3`. `format`: `decimals` (default 0), thousands `grouping` (default false) and `pad` (zero-pad the whole part to that many digits: `pad: 2` gives `05`, so a clock is `text: '18:42:{value}'` with `format: { pad: 2 }`) and `time` (print the value, in SECONDS, as a clock: `'hh:mm:ss'`, `'h:mm:ss'`, `'hh:mm'`, `'mm:ss'` or `'m:ss'`; the seconds are cut, not rounded, and the hours do not wrap, so a day clock keeps the value inside 0–86399 with `mod`: `{ value: 15000 }` with `format: { time: 'hh:mm' }` is `04:10`). Put prefixes and suffixes in the text (`'${value}'`, `'{value}%'`). If `value` is animated but the text has no `{value}`, a warning says so.
1952
2197
 
1953
2198
  **Colour inside one text layer: `spans`.** Instead of `text`, write an array of strings (drawn in the layer's own `style`) and `{ text, fill, … }` objects; they are shown one after the other, and `\n` inside a span starts a new line:
1954
2199
 
@@ -1962,7 +2207,7 @@ A span may change `fill`, `fontWeight`, `fontStyle`, `fontVariant`, `letterSpaci
1962
2207
 
1963
2208
  ### `image`
1964
2209
 
1965
- Renders a [PIXI.Sprite](https://pixijs.com/8.x/guides/components/scene-objects/sprite/sprite) from a registered asset. Intrinsic `w`/`h` come from the loaded texture.
2210
+ Renders a [PIXI.Sprite](https://pixijs.com/8.x/guides/components/scene-objects/sprite/sprite) from a registered asset. Intrinsic `w`/`h` come from the loaded texture. `scaleMode: 'nearest'` draws the pixels as hard squares when the picture is scaled up (pixel art, a sprite drawn small and shown big); the default `'linear'` smooths. It belongs to the asset: every layer that shows it gets it.
1966
2211
 
1967
2212
  ```ts
1968
2213
  {
@@ -2085,7 +2330,7 @@ A tune with no audio file: notes, chords and drum patterns written as strings, p
2085
2330
  } }
2086
2331
  ```
2087
2332
 
2088
- **Layer.** `at` starts it, `volume` (and volume keyframes) scale it. `duration` defaults to the music's length plus its reverb tail (`tail`, 2.5 s), but never past the end of the composition (the music then ends with the movie, with a short fade). A shorter `duration` cuts it with a fade; a longer one is silent after the music unless **`loop: true`**, which repeats the score until the layer ends (default: the end of the composition). At `volume: 1` the music peaks at −6 dBFS (about −20 dB RMS), a bed that sits under speech and sound effects (an sfx peaks at −12 dBFS). Measured: a 4-bar tune (bell + bass + kick, a pluck and a hat on the repeat) at `volume: 0.8` reads −22 LUFS and −7.7 dBTP (`piano-roll`); web video is usually −14 to −16 LUFS, so 0.8 is not a limit: raise `volume` and watch `inspectAudio().loudness.truePeakDb`. `inspectAudio()` lists a music layer as ONE source (its start); to line a picture up with a note, take its time from [`musicEvents(music)`](api.md#musicevents) (seconds from the start of the music, the tempo map applied: add the layer's `at`), or build the bars and the score from one array so they cannot drift.
2333
+ **Layer.** `at` starts it, `volume` (and volume keyframes) scale it. `duration` defaults to the music's length plus its reverb tail (`tail`, 2.5 s), but never past the end of the composition (the music then ends with the movie, with a short fade). A shorter `duration` cuts it with a fade; a longer one is silent after the music unless **`loop: true`**, which repeats the score until the layer ends (default: the end of the composition). At `volume: 1` the music peaks at −6 dBFS (about −20 dB RMS), a bed that sits under speech and sound effects (an sfx peaks at −12 dBFS). Measured: a 4-bar tune (bell + bass + kick, a pluck and a hat on the repeat) at `volume: 0.8` reads −22 LUFS and −7.7 dBTP; web video is usually −14 to −16 LUFS, so 0.8 is not a limit: raise `volume` and watch `inspectAudio().loudness.truePeakDb`. `inspectAudio()` lists a music layer as ONE source (its start); to line a picture up with a note, take its time from [`musicEvents(music)`](api.md#musicevents) (seconds from the start of the music, the tempo map applied: add the layer's `at`), or build the bars and the score from one array so they cannot drift.
2089
2334
 
2090
2335
  **Score** (`music`): `bpm` (a number, or `[beat, bpm]` points for a tempo change: `[[0, 96], [28, 96], [32, 60]]` slows down over beats 28–32, a ritardando), `tracks`, `drums`, `swing` (0–0.4: delays the off-beat eighths and sixteenths; 0.16 is a gentle lo-fi lilt), `reverb` (0–0.6, default 0.22), `humanize` (seconds of seeded timing jitter, default 0.012), `drumVol` (default 0.8), `grid` (drum steps per beat, default 4), `meter` (beats in a bar, default 4: it only names the bars in [`musicEvents()`](api.md#musicevents) and in the `check` read-back, playback does not use it), `transpose` (semitones, all pitched tracks), `tail`, `seed`. Misspelt names warn with "did you mean". The music's length is its longest track (drums repeat up to it), so a track of rests (`'_:16'`) sets a length.
2091
2336
 
@@ -2165,7 +2410,9 @@ Every primitive draws centred on its local origin (so `anchorX`/`anchorY` and `p
2165
2410
  | `arc` | `radius` | `innerRadius`, `startAngle` (0), `endAngle` (360) |
2166
2411
  | `line` | `from: [x,y]`, `to: [x,y]` (canvas coordinates; stroke in `initial`) | `trimStart`, `trimEnd` |
2167
2412
  | `polygon` | `points: [[x,y], …]` | `open` (default false), `trimStart`, `trimEnd` |
2168
- | `path` | `d` (SVG path data) | `trimStart`, `trimEnd`, `morphTo`, `morph`, `morphPoints` |
2413
+ | `path` | `d` (SVG path data) | `fillRule`, `trimStart`, `trimEnd`, `morphTo`, `morph`, `morphPoints` |
2414
+
2415
+ **Holes (`fillRule: 'evenodd'`) on a `path`.** Several sub-paths in one `d` are each filled on their own: a donut written as two circles is a solid disc. With `fillRule: 'evenodd'` the biggest sub-path is the shape and every other one is a hole cut out of it (the stroke goes round the shape and each hole): `{ shape: 'path', fillRule: 'evenodd', d: 'M 0 -100 A 100 100 0 1 1 0 100 A 100 100 0 1 1 0 -100 Z M 0 -50 A 50 50 0 1 0 0 50 A 50 50 0 1 0 0 -50 Z', initial: { x: 640, y: 360, fillColor: '#ffcc00' } }`. Write the sub-paths in absolute coordinates (each starts with `M`); an island inside a hole is not drawn, and a path that morphs (`morphTo`) has no holes.
2169
2416
 
2170
2417
  **`arc`** is an arc around its local origin (the centre). Angles are in **degrees: 0° is 3 o'clock and positive is clockwise**, so a progress ring starts at `startAngle: -90` (12 o'clock) and animates `endAngle` from `-90` to `270`. With a stroke and no fill it is an open arc line (`strokeCap: 'round'` for rounded ends); with a fill it is a sector (a pie slice), and with `innerRadius` a ring segment (a donut slice). A sweep of 360° or more is a full circle; `endAngle < startAngle` sweeps counter-clockwise. `radius`, `innerRadius`, `startAngle` and `endAngle` are all animatable.
2171
2418
 
@@ -2183,7 +2430,7 @@ Every primitive draws centred on its local origin (so `anchorX`/`anchorY` and `p
2183
2430
  keyframes: [{ at: 0, to: { trimEnd: 1 }, duration: 1, ease: 'power2.inOut' }] } // a check mark drawing itself
2184
2431
  ```
2185
2432
 
2186
- **Morph (`morphTo` / `morph`).** A `path` can turn into another outline: give `morphTo` (SVG path data in the same canvas coordinates as `d`) and animate `morph` from 0 (the `d` outline) to 1 (the `morphTo` outline). In between it is drawn as a polygon of points half way from one to the other (`morphPoints`: 8–2048, default 128). Closed outlines are lined up (start point and direction) so they do not twist; sub-paths are paired in order, and an extra one grows from (or shrinks into) its own centre. Fill and stroke both follow, and `trimStart` / `trimEnd` walk the morphing outline. Write both outlines as `M … L … C … Z` in absolute coordinates (arcs and holes work too, but keep the same number of sub-paths on both sides).
2433
+ **Morph (`morphTo` / `morph`).** (Two outlines with the **same number of corners**, such as two quads, are paired corner to corner, so every in-between picture is a straight-edged quad and no corner is rounded; `morphPoints` only matters when the corner counts differ or the outlines are curves.) A `path` can turn into another outline: give `morphTo` (SVG path data in the same canvas coordinates as `d`) and animate `morph` from 0 (the `d` outline) to 1 (the `morphTo` outline). In between it is drawn as a polygon of points half way from one to the other (`morphPoints`: 8–2048, default 128). Closed outlines are lined up (start point and direction) so they do not twist; sub-paths are paired in order, and an extra one grows from (or shrinks into) its own centre. Fill and stroke both follow, and `trimStart` / `trimEnd` walk the morphing outline. Write both outlines as `M … L … C … Z` in absolute coordinates (arcs and holes work too, but keep the same number of sub-paths on both sides).
2187
2434
 
2188
2435
  ```ts
2189
2436
  { type: 'shape', shape: 'path', d: 'M 640 160 C 780 160 860 260 860 360 C 860 470 770 560 640 560 C 510 560 420 470 420 360 C 420 250 500 160 640 160 Z',
@@ -2331,6 +2578,41 @@ Rules:
2331
2578
  8. **Size and place**: with `width` / `height` smaller than the composition the layer's **top-left corner** sits at `initial.x` / `initial.y` (like an image; a shader layer has no anchor to set). **A mask or matte cuts the picture the shader drew over its whole size, it does not move it**: a ripple centred in the shader's own frame stays there, so to show the middle of a pattern through a disc at x = 410, put the pattern's centre at x = 410 (a `vec2` uniform: `uniforms: { centre: [410, 360] }`) or size and place the layer to match. With `resolution` below 1, `iResolution` and `fragCoord` are in the **scaled** buffer's pixels (half size at 0.5), so a pixel-unit uniform (a centre, a radius) must be multiplied by `resolution`; `fragCoord / iResolution.xy` needs no correction.
2332
2579
  9. **Cost** (a prototype measurement at 1080p, on one machine, an Apple M1 Pro; the released layer is not re-measured): a 160×90 shader with an 8-step loop, 20 layers, cost 11 ms (WebGPU) to 17 ms (WebGL) a frame more than 20 plain rectangles; one full-size 1920×1080 layer cost about 10 ms more. Every layer pays a fixed cost (about 0.5 to 0.85 ms), so use a few big shaders rather than many small ones, and `resolution` for heavy ones. All shader layers share **one** WebGL2 context (so there is no limit from the browser's number of contexts); a shader layer needs WebGL2 (without it the build warns and draws the checkerboard).
2333
2580
 
2581
+ ### `canvas`
2582
+
2583
+ A picture drawn by **your own function of the time**, into a canvas of the layer's own, shown like an image at its place in the stack. Use it for what no other layer draws: an afterimage, rings that grow, a brush stroke, a waveform, a pixel sprite, a chart, a simulation. It is the escape hatch for "I want to draw this myself".
2584
+
2585
+ ```ts
2586
+ { type: 'canvas', width: 800, height: 450, duration: 4, initial: { x: 640, y: 360, anchorX: 0.5, anchorY: 0.5 },
2587
+ params: { size: 40 },
2588
+ draw: (ctx, t, info) => { // ctx: the layer's own 2D context, already cleared
2589
+ ctx.fillStyle = '#ff4f1a';
2590
+ ctx.beginPath(); ctx.arc(400 + 300 * Math.sin(t * 2), 225, info.params.size, 0, Math.PI * 2); ctx.fill();
2591
+ } }
2592
+ ```
2593
+
2594
+ It is a layer like any other: `name`, `at`, `duration`, `initial`, `keyframes`, `filters`, `mask`, `blendMode`, `parent`, `threeD`, lights, transitions, mattes, export all work on its picture.
2595
+
2596
+ | Key | Meaning |
2597
+ |---|---|
2598
+ | `draw(ctx, t, info)` | **Required.** Draws the picture at `t`, the layer's own seconds (0 when it starts). The canvas is **cleared before every call** and nothing a call sets (a fill colour, a font, a transform) survives into the next. The units are the layer's pixels. |
2599
+ | `setup(info)` | Optional, may be async, called once at build: heavy preparation. Its result is `info.state`. |
2600
+ | `width`, `height` | Layer size in px or an expression (`'GW / 2'`); default the composition's size; at most 8192. |
2601
+ | `resolution` | 0.25 to 4 (default 1): canvas pixels per layer pixel. Text and fine lines under a zoom (a parent scaled up) need 2 or more. |
2602
+ | `params` | Numbers that **keyframes can animate**: `keyframes: [{ at: 0, to: { 'params.size': 80 }, duration: 2 }]`; `draw` reads them as `info.params`. |
2603
+ | `seed` | Integer (default 0) that seeds `info.random()`. |
2604
+ | `smoothing` | `false`: no smoothing when drawing images into it AND hard pixels when the layer is scaled up (pixel art). Default `true`. |
2605
+
2606
+ `info` is `{ frame, duration, progress, width, height, resolution, frameRate, params, state, random, start, time, canvas }`: `frame` is the layer's own frame number, `progress` is `t / duration`, `params` holds the values at this time, `state` is what `setup` returned, `random()` is a **seeded** generator whose sequence starts again at every call (the same frame always draws the same numbers; for a value that must differ from frame to frame, derive it from `t`), **`start`** is where the layer starts on the movie's clock (its `at` plus the starts of the compositions around it; in `setup` just its `at`; inside a composition with a time remap, `speed` or `time`, the clock of that composition) and **`time`** is the movie's time now (`start + t`): read it for anything several layers or the sound share, instead of adding the `at` by hand (which breaks quietly when someone moves the layer). **`info.canvas(width, height)`** returns an offscreen canvas for a sprite, a mask or a scratch picture.
2607
+
2608
+ **Helpers you can import** (they are plain functions of their input, so they are safe in `draw`): `import { ease, noise, noise2, random, rand, samplePath } from 'pixi-effects'`. `ease('power3.out')` returns the function `x => eased x` for any ease name a keyframe takes (`back.out`, `elastic.out`, `cubic-bezier(…)`, `spring.…`), so a canvas moves like the other layers. `noise(x, seed)` and `noise2(x, y, seed)` are smooth noise in [-1, 1] (sum a few doublings of `x` and `y` for clouds and terrain); `random(seed)` is a repeatable stream and `rand(seed)` one number for a seed. `samplePath(d)` walks SVG path data by length: `.length`, `.at(u)` gives `{ x, y, angle }` (radians) a fraction `u` along, `.trim(a, b)` gives the part between two fractions as flat `[x, y, …]` pieces for `moveTo` / `lineTo` (a line that draws on, a string set along a curve, a petal that falls along a route). Build a sampler once (module scope or `setup`) and ask it every frame.
2609
+
2610
+ **The contract: a function of the time.** The same `t` and the same `params` must give the same pixels, in playback, in a seek and in an export. So: no `Math.random`, `Date.now`, `performance.now` or `new Date()` (a warning at build says so, and `check` draws each canvas layer twice at a few moments and reports a layer whose two pictures differ), and no state that outlives a call (a cache of a pure function, same input same output, is fine: a palette per phase, a table filled on the first call; state that depends on which frames were drawn before is not). A trail is drawn from the positions of the past computed from `t`, not from the last picture. A **simulation** (a game of life, a physics sim, a growth process) is computed whole in `setup` into an array and read by `info.frame` in `draw`. **Data that several canvas layers share** (the same plant, the same city, a palette phase) belongs in module scope, computed before `movie.init` from `random(seed)` and plain functions of the time: the state `setup` returns is private to its layer, and every layer reads the same clock through `info.time`. **Sprites, masks and scratch pictures** are made in `setup` with `info.canvas(w, h)` and drawn back with `drawImage` (a word rendered once, a paper texture, a mask for `destination-in`); not with `document.createElement('canvas')`: a GPU canvas used as a source can draw a little differently the first time (measured: up to 128 of 255 over 40 000 pixels on a 1280×720 layer), which is what `info.canvas` avoids. Everything of the 2D context works (`ctx.filter`, composite operations such as `source-atop` and `destination-in`, `drawImage` of another canvas, `measureText`: in `setup`, `info.canvas(1, 1).getContext('2d')` is a context to measure with). A web font must be loaded before it is measured or drawn: `await document.fonts.load('40px "Name"')` in `setup`. A type effect that shows words through a moving texture is: draw the words (the mask) first, then `ctx.globalCompositeOperation = 'source-atop'` and the texture, which paints only where the words are.
2611
+
2612
+ **What it cannot do.** It cannot see or paint on what is below it in the stack (the stage is drawn by the GPU, not by a 2D context): use `filters`, a matte or a composition that holds the layers for that. **`check` does not see text drawn in a canvas**: its layout test (cut-off, off-canvas, overlap) reads the text layers only, so labels and captions you draw yourself can collide without a word; look at the contact sheet and at `--crop` enlargements. It is not audio. For a picture made by a formula on the GPU, the `shader` layer is faster. A spec that holds a `canvas` layer holds functions, so it is not pure JSON (the same standing as `three` and the `custom` filter).
2613
+
2614
+ Mistakes are said at build time with the layer's name: no `draw`, a `draw` or `setup` that throws (the frame is a magenta checkerboard and the next frames are tried again), a bad `width` / `height` / `resolution` / `params`, an unknown key (did you mean …), a `params.name` keyframe that names nothing. A draw costs time on every frame that changes (`draw` is called once for each distinct time, so a still picture still redraws every frame of a moving movie: draw what never changes once into a canvas from `info.canvas` in `setup` and `drawImage` it): a slow `draw` slows the export in proportion, and `check` prints the usual time of one draw. The layer's `duration` defaults to its parent's length: with `at` greater than 0 and no `duration`, `info.progress` stops short of 1 when the movie ends, so give a `duration` when you draw from `progress`. `check` draws each canvas layer in a scratch canvas (so reading its pixels does not change how the real one is drawn) at four moments: one draw of each is thrown away (a browser can draw a canvas from `setup` differently the first time), then each moment is drawn twice and in two orders. It prints a `canvas` line when every layer passed (`deterministic · slowest draw 3.2 ms`), so silence is never the only sign that it ran, and a layer that is not deterministic is reported under that heading with the moments, how many channel values differ and by how much (up to N of 255).
2615
+
2334
2616
  ## 3D layers & camera
2335
2617
 
2336
2618
  Place any 2D layer in depth and view it through a camera — After Effects style, no three.js. Everything uses the normal `initial` / `keyframes` / expression vocabulary.
@@ -3679,13 +3961,13 @@ Text layout helpers, exported from `pixi-effects`. They measure with the rendere
3679
3961
  ```ts
3680
3962
  measureText('Hello', { fontSize: 72 }); // { width: 187.4, height: 81.3, lines: 1 }
3681
3963
 
3682
- interface SplitOptions { by?: 'chars' | 'words' | 'lines'; x?: number; y?: number; align?: 'left' | 'center' | 'right' }
3683
- interface TextPiece { text: string; x: number; y: number; width: number; index: number; line: number }
3964
+ interface SplitOptions { by?: 'chars' | 'words' | 'lines'; x?: number; y?: number; align?: 'left' | 'center' | 'right'; vertical?: boolean }
3965
+ interface TextPiece { text: string; x: number; y: number; width: number; index: number; line: number; cx?: number; cy?: number; rotation?: number } // cx / cy / rotation: vertical text only
3684
3966
  splitText('Hello world', style, { by: 'words', x: 640, y: 300, align: 'center' });
3685
3967
  // → [{ text: 'Hello', x, y, width, index: 0, line: 0 }, { text: 'world', ... }]
3686
3968
  ```
3687
3969
 
3688
- `x` is the line's left edge, or its centre / right edge with `align` (each line of a `\n` text is aligned on its own width); `y` is the top of the first line, and further lines go down by the line height. Whitespace takes its room but is not returned as a piece. Use one text layer per piece with the same `style` and the default top-left anchor: `{ type: 'text', text: p.text, style, initial: { x: p.x, y: p.y } }`. Kerning is kept (a piece's x is the measured width of everything before it).
3970
+ `x` is the line's left edge, or its centre / right edge with `align` (each line of a `\n` text is aligned on its own width); `y` is the top of the first line, and further lines go down by the line height. **Vertical writing (`vertical: true`, tategaki):** characters go down a column and a new line is a new column to the LEFT; `x` is the RIGHT edge of the first column, `y` the top of a column (`align: 'center'` / `'right'` put `y` at its middle / bottom). Each piece also has `cx` / `cy` (the centre of its cell, with 、。 moved to the upper right and small kana a little up and right) and `rotation` (90 for the long vowel mark, dashes, waves, leaders and brackets, which lie turned in vertical text): put a text layer with `anchorX: 0.5, anchorY: 0.5` at `cx` / `cy` with `rotation: p.rotation`, or call `animateText(..., { vertical: true })`, which does it. Latin letters and digits stay upright, one per cell (no tate-chu-yoko), and a line is only cut at `\n` (no kinsoku). Only `by: 'chars'` has a vertical form. Whitespace takes its room but is not returned as a piece. Use one text layer per piece with the same `style` and the default top-left anchor: `{ type: 'text', text: p.text, style, initial: { x: p.x, y: p.y } }`. Kerning is kept (a piece's x is the measured width of everything before it).
3689
3971
 
3690
3972
  ### `movie.inspect(frame?, options?): Promise<InspectReport>`
3691
3973
 
@@ -3695,7 +3977,7 @@ Where every layer is drawn at `frame`, as JSON — for checking layout without e
3695
3977
  interface InspectReport {
3696
3978
  frame: number; time: number; canvas: { width: number; height: number };
3697
3979
  summary: { layers: number; visible: number };
3698
- issues: string[]; // text off the canvas / cut by an edge / empty / overlapping other text — read this first
3980
+ issues: string[]; // text off the canvas / cut by an edge / empty / overlapping other text / partly hidden by an opaque shape or image drawn over it / struck through by a thin line drawn over it — read this first
3699
3981
  layers: Array<{
3700
3982
  path: string; // names (or type#index) from the root, joined by '/'
3701
3983
  name?: string; type: string; threeD: boolean;
@@ -3720,6 +4002,7 @@ interface AudioReport {
3720
4002
  start: number; end: number; // movie time
3721
4003
  peakDb: number; // the MIX while it plays
3722
4004
  sound: { length: number; peakDb: number; loudestAt: number; brightnessHz: number }; // the sound alone
4005
+ belowMixDb?: number; // how far below the loudest sample of the mix (itself included) around its own loudest moment it is; short sfx more than 12 dB under get ONE note, "buried", naming the worst
3723
4006
  }>;
3724
4007
  windows: Array<{ t: number; rmsDb: number; peakDb: number; brightnessHz: number }>;
3725
4008
  issues: string[]; // limited mix, inaudible layer, sfx cut off by the end, no audio — read first
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixi-effects",
3
- "version": "0.35.0",
3
+ "version": "0.37.0",
4
4
  "description": "Declarative composition and video rendering for the web. Built on PixiJS v8.",
5
5
  "type": "module",
6
6
  "exports": {