pixi-effects 0.28.2 → 0.29.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 (91) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +10 -10
  3. package/ai/chat-template.html +5 -5
  4. package/ai/tools/check.mjs +18 -3
  5. package/ai/tools/pixi-effects.mjs +1 -1
  6. package/ai/tools/render.mjs +14 -9
  7. package/ai/tools/view.mjs +6 -2
  8. package/dist/Base-CB1_FPZD.d.cts +60 -0
  9. package/dist/Base-Ne74ztNl.d.ts +60 -0
  10. package/dist/Composition-MUKR3FYD.cjs +16 -0
  11. package/dist/{Composition-K6B3APM5.cjs.map → Composition-MUKR3FYD.cjs.map} +1 -1
  12. package/dist/Composition-RJGA7KDU.js +7 -0
  13. package/dist/{Composition-H7UNR5F5.js.map → Composition-RJGA7KDU.js.map} +1 -1
  14. package/dist/Controller.cjs +73 -45
  15. package/dist/Controller.cjs.map +1 -1
  16. package/dist/Controller.d.cts +16 -22
  17. package/dist/Controller.d.ts +16 -22
  18. package/dist/Controller.js +74 -40
  19. package/dist/Controller.js.map +1 -1
  20. package/dist/{Movie-CHcM8HNB.d.ts → Movie-C2rZFkrO.d.ts} +227 -119
  21. package/dist/{Movie-Cx5ozUGb.d.cts → Movie-DdnpLpw1.d.cts} +227 -119
  22. package/dist/{blendModes-HDOP6R7T.cjs → blendModes-ETMYWATF.cjs} +5 -5
  23. package/dist/{blendModes-HDOP6R7T.cjs.map → blendModes-ETMYWATF.cjs.map} +1 -1
  24. package/dist/{blendModes-PAVTW3BO.js → blendModes-RIVZXL5A.js} +4 -4
  25. package/dist/{blendModes-PAVTW3BO.js.map → blendModes-RIVZXL5A.js.map} +1 -1
  26. package/dist/{chunk-P4GY3Z4G.cjs → chunk-2DXPTKQZ.cjs} +6 -6
  27. package/dist/chunk-2DXPTKQZ.cjs.map +1 -0
  28. package/dist/{chunk-TYWFKCPY.js → chunk-4KB3GUEK.js} +5 -5
  29. package/dist/chunk-4KB3GUEK.js.map +1 -0
  30. package/dist/{chunk-5MZWXVTT.js → chunk-A3Q3SDSH.js} +35 -28
  31. package/dist/chunk-A3Q3SDSH.js.map +1 -0
  32. package/dist/chunk-CRQFDY7E.js +4182 -0
  33. package/dist/chunk-CRQFDY7E.js.map +1 -0
  34. package/dist/chunk-HREBQAY4.cjs +4206 -0
  35. package/dist/chunk-HREBQAY4.cjs.map +1 -0
  36. package/dist/{chunk-ZMAD3KJU.cjs → chunk-LYOKH7KE.cjs} +22 -3
  37. package/dist/chunk-LYOKH7KE.cjs.map +1 -0
  38. package/dist/{chunk-45QKLD6M.js → chunk-RCVZX2JP.js} +22 -3
  39. package/dist/chunk-RCVZX2JP.js.map +1 -0
  40. package/dist/{chunk-KBH33IQX.cjs → chunk-WHNADXXG.cjs} +121 -114
  41. package/dist/chunk-WHNADXXG.cjs.map +1 -0
  42. package/dist/{chunk-7VN2KI3U.js → chunk-ZAXYPXKW.js} +14 -6
  43. package/dist/chunk-ZAXYPXKW.js.map +1 -0
  44. package/dist/{chunk-MJW7KILM.cjs → chunk-ZVUO325O.cjs} +32 -24
  45. package/dist/chunk-ZVUO325O.cjs.map +1 -0
  46. package/dist/index.cjs +106 -4482
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.d.cts +15 -60
  49. package/dist/index.d.ts +15 -60
  50. package/dist/index.js +6 -4457
  51. package/dist/index.js.map +1 -1
  52. package/dist/loader.css +1 -1
  53. package/dist/{Presenter.cjs → present.cjs} +370 -31
  54. package/dist/present.cjs.map +1 -0
  55. package/dist/present.d.cts +278 -0
  56. package/dist/present.d.ts +278 -0
  57. package/dist/{Presenter.js → present.js} +358 -32
  58. package/dist/present.js.map +1 -0
  59. package/dist/three.cjs +6 -6
  60. package/dist/three.d.cts +2 -2
  61. package/dist/three.d.ts +2 -2
  62. package/dist/three.js +2 -2
  63. package/dist/{types-DpK1vwcl.d.ts → types-BzIB65Vk.d.cts} +11 -265
  64. package/dist/{types-DpK1vwcl.d.cts → types-BzIB65Vk.d.ts} +11 -265
  65. package/docs/api.md +44 -77
  66. package/docs/dsl.md +16 -14
  67. package/llms-full.txt +86 -115
  68. package/llms.txt +1 -1
  69. package/package.json +5 -5
  70. package/skills/pixi-effects/SKILL.md +5 -5
  71. package/skills/pixi-effects/reference/cheatsheet.md +9 -9
  72. package/skills/pixi-effects/reference/direction.md +2 -0
  73. package/skills/pixi-effects/reference/pitfalls.md +6 -6
  74. package/skills/pixi-effects/reference/recipes.md +4 -4
  75. package/skills/pixi-effects/template.html +7 -7
  76. package/dist/Base-C6f0fE8j.d.ts +0 -109
  77. package/dist/Base-DI1FCGcH.d.cts +0 -109
  78. package/dist/Composition-H7UNR5F5.js +0 -7
  79. package/dist/Composition-K6B3APM5.cjs +0 -16
  80. package/dist/Presenter.cjs.map +0 -1
  81. package/dist/Presenter.d.cts +0 -122
  82. package/dist/Presenter.d.ts +0 -122
  83. package/dist/Presenter.js.map +0 -1
  84. package/dist/chunk-45QKLD6M.js.map +0 -1
  85. package/dist/chunk-5MZWXVTT.js.map +0 -1
  86. package/dist/chunk-7VN2KI3U.js.map +0 -1
  87. package/dist/chunk-KBH33IQX.cjs.map +0 -1
  88. package/dist/chunk-MJW7KILM.cjs.map +0 -1
  89. package/dist/chunk-P4GY3Z4G.cjs.map +0 -1
  90. package/dist/chunk-TYWFKCPY.js.map +0 -1
  91. package/dist/chunk-ZMAD3KJU.cjs.map +0 -1
package/llms-full.txt CHANGED
@@ -6,7 +6,7 @@ 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.28.2. Update this skill with `npx skills update`; the newest docs are at https://yjmtmtk.github.io/pixi-effects/
9
+ > Written for pixi-effects 0.29.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
 
@@ -24,10 +24,10 @@ You describe a video as a tree of plain objects; the library plays it in a canva
24
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.
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.
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 movie.inspectStops()` 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 movie.stopImages({ which: 'pages' })` / `movie.exportPDF()` give the pages themselves.
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.
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
- 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.
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.
31
31
 
32
32
  ## For a film (more than a title card): direct it first
33
33
 
@@ -49,7 +49,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
49
49
  - **2.5D needs `threeD: true`** on each layer plus a `{ type: 'camera' }` layer; camera props go in `initial`/keyframes; keep every `z` below the camera distance (≈ 989 at 720p, fov 40); under a dolly zoom only `z = 0` stays fixed; **depth of field** is two camera props, `focus` (a threeD layer's name or a z) and `aperture` (30 default, 0 off), and a rack focus is a keyframe on `focus`.
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
- - **Built in:** `animateText()` (per-letter / per-word / per-line entrances and exits in one call: `...animateText('Title', style, { x, y, align: 'center', at: 0.5, duration: 5, in: 'rise', out: 'fade', stagger: { each: 0.05 } })`), `splitText()` / `measureText()` (per-letter / per-word layers, exact positions, by hand), `shape: 'arc'` (progress rings, pie / donut slices: animate `endAngle`), `fillGradient` (linear/radial, alpha stops → vignettes), `{value}` counters, `repeat`/`yoyo`, `orbit()`, seeded randomness (`rand(seed)` / `noise(x, seed)` in expressions, `random(seed)` in JS, never `Math.random()`) `wiggle()` (a seeded shake as keyframes; on a camera put it on `offsetX` / `offsetY` so it rides on the move) `cameraPath()` (a camera flight through `[x, y, z]` points as keyframes; layers it passes: `hideBehindCamera: true`) depth of field (camera `focus` + `aperture`: `{ type: 'camera', initial: { focus: 'title' } }` blurs the other threeD layers by depth; a rack focus is a keyframe on `focus`) `music` (a tune with no file: `{ type: 'audio', music: { bpm, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4' }, { inst: 'bass', notes: 'c2:2 g1:2' }], drums: { from: 8, kick: 'x...x...' } } }`: notes and chords as text, counted in beats; instruments `keys pluck pad bass sub lead bell musicbox`; `musicEnvelope(music)` feeds `react()`), `audioEnvelope()` / `bpmEnvelope()` + `react()` (visuals that follow music: `const env = await audioEnvelope('music.mp3', { frameRate })` before `movie.init`, then `keyframes: react(env, { duration, props: { scale: { base: 1, amount: 0.4, band: 'bass', release: 0.2 } } })`; `beats: true` pulses on the beats; `bpmEnvelope(120, …)` when there is no file), `deck()` + `Presenter` (a talk as one movie: pages, steps, stops that pause playback, arrow keys / click / swipe to move on: `...deck({ transition: { kind: 'slide', duration: 0.6 }, pages: [{ name, duration, stops, sequences }] })`, `new Presenter(movie, { canvas })` from `pixi-effects/presenter`; G = page overview, P = presenter view with notes and a timer; `pixi-effects-render talk.html -o talk.pdf` = a PDF of the pages), `particles()` (bursts, snow, confetti, sparks as seeded layers: `...particles({ count: 90, at: 2, life: [1, 1.8], area: { x: 640, y: 330 }, speed: [120, 420], gravity: 240, fade: { out: 0.6 } })`), `followPath()` (a layer along an SVG path: `keyframes: followPath({ d, duration, orient: true })`), path morphing (`{ shape: 'path', d, morphTo, initial/keyframes: morph 0 → 1 }`) and `stagger()` (delays for a wave of layers: `stagger(letters, { each: 0.05, from: 'center' })`, or `stagger(n, …)` for numbers; GSAP-style `each` / `amount` / `from` / `grid` / `ease`). **Not in the DSL (hand-roll with a JS loop):** other per-frame curves (waves, spirals: sample them into short linear keyframes). (Draw-on strokes `trimEnd`, text that changes over time `set: { text }` / `visibleChars`, shake `wiggle()` and particles are built in.)
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
53
  - **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
54
  - **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
55
  - **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.
@@ -113,7 +113,7 @@ name, at, duration,
113
113
  initial: { ...props applied before any keyframe },
114
114
  keyframes: [ { at, duration, ease, set | to | from | (from + to), repeat?, yoyo?, repeatDelay? } ],
115
115
  filters: [ { type: 'chromaKey', keyColor, threshold, smoothing, spill } | { type: 'glow', name?, outerStrength: 3, color: '#ffd166' } /* any named filter, see below */ | { type: 'custom', name, filter: <Pixi Filter> } ],
116
- mask: <a layer spec> | 'matteName' | { layer: 'matteName', channel: 'alpha' | 'luma', invert: true } | [ …refs ], maskInverted, // an inline mask with no `at` of its own starts and ends with the layer it masks (its keyframes count from that layer's start); an explicit mask `at` is composition time. A NAME points at a sibling layer (the matte, shared by any number of layers, NOT drawn itself, alive for its own `at`/`duration`); a list is an intersection; subtract = `invert: true`
116
+ mask: <a layer spec> | 'matteName' | { layer: 'matteName' | <a layer spec>, channel: 'alpha' | 'luma', invert: true } | [ …names or { layer: 'name', … } ], // an inline mask with no `at` of its own starts and ends with the layer it masks (its keyframes count from that layer's start); an explicit mask `at` is composition time. A NAME points at a sibling layer (the matte, shared by any number of layers, NOT drawn itself, alive for its own `at`/`duration`); a list is an intersection; subtract = `invert: true`
117
117
  filterArea: { x, y, width, height } // in the layer's OWN coordinates; lets blur/glow draw past the layer's bounds
118
118
  threeD: true // opt into 2.5D (see below)
119
119
  blendMode: 'normal' | 'add' | 'screen' | 'multiply' | 'overlay' | 'soft-light' | 'hard-light' | 'color-dodge' | 'color-burn' | 'darken' | 'lighten' | 'difference' | 'exclusion' | 'hue' | 'saturation' | 'color' | 'luminosity' | 'linear-burn' // CSS names (hyphenated); blends with EVERYTHING BELOW the layer; add / screen brighten (glows), multiply darkens, soft-light / overlay for colour casts and contrast, color-dodge for light sources; on a composition the children each blend (to blend it as one picture: threeD: true or a filter); the 14 new ones cost ~2 ms a layer on WebGL (10+ on screen warns)
@@ -195,7 +195,7 @@ transitions: [{ kind: 'crossfade' | 'wipe' | 'iris' | 'slide' | 'dip' | 'zoom' |
195
195
  ## Presets
196
196
 
197
197
  ```js
198
- import { kenBurns, withFade, wiggle, stagger, followPath, cameraPath, particles, deck, audioEnvelope, bpmEnvelope, musicEnvelope, react, random } from 'pixi-effects';
198
+ import { kenBurns, withFade, wiggle, stagger, followPath, cameraPath, particles, audioEnvelope, bpmEnvelope, musicEnvelope, react, random } from 'pixi-effects';
199
199
  kenBurns({ asset, name, at, duration, motion: 'still'|'scale'|'rotation'|'position',
200
200
  fit: 'cover'|'contain', ease, /* scale */ origin, zoom, direction, /* rotation */ angle, /* position */ from, to })
201
201
  withFade(spec, { in: 0.5, out: 0.5 }) // alpha fade; `out` needs spec.duration
@@ -211,12 +211,12 @@ keyframes: followPath({ d: 'M 120 560 C 360 120 920 120 1160 560', duration: 4,
211
211
  // a presentation: stops pause playback (`composition.stops: [2, { at: 5, page: 'Name', notes, advance, pdf }]`; `pdf: true` = the stop that pictures its page in a PDF, `pdf: false` = keep it out; default the page's last stop; a `page` stop begins a page, the others are steps); deck() lays pages out
212
212
  await movie.init({ canvas, width, height, frameRate, ...deck({ transition: { kind: 'slide', direction: 'left', duration: 0.6 },
213
213
  pages: [{ name: 'Hello', duration: 3.2 /* last stop + transition */, stops: [2.6] /* page-local */, notes, sequences: [ /* page-local time; a step = a layer starting at the previous stop */ ] }] }) });
214
- new Presenter(movie, { canvas }); // import { Presenter } from 'pixi-effects/presenter': → ↓ Space Enter click = next, ← = back, Home / End, number+Enter, B / W, F, ?
215
- movie.next() / prev() / goToStop(i) / goToPage(n) / movie.stops / movie.on('stop', …) // Controller: marks on the seek bar, play / Space / click = play to the next stop (pauseAtStops: false = straight through), a Present button, PDF in the download panel
214
+ new Presenter(movie, { canvas }); // import { deck, Presenter, next, prev, … } from 'pixi-effects/present' (an import-map line of its own): → ↓ Space Enter click = next, ← = back, Home / End, number+Enter, B / W, F, ?
215
+ next(movie) / prev(movie) / goToStop(movie, i) / goToPage(movie, n) / movie.stops / onStop(movie, fn) // (from 'pixi-effects/present'; the core has movie.playUntil(seconds)) Controller: marks on the seek bar, play / Space / click = play to the next stop (pauseAtStops: false = straight through), a Present button, PDF in the download panel
216
216
  // Presenter extras: G = page overview (pictures), P = presenter view (a second window: live picture, next picture, notes, timer); start() makes the pictures before the audience sees anything
217
- await movie.stopImages({ as: 'dataURL', scale: 0.25 }) // [{ stop, page, image }] one per page (its last stop); { which: 'stops' } one per stop (a page's picture honours `pdf` flags)
218
- await movie.inspectStops() // { stops, issues }: stops whose picture is still changing (a stop landing before its animation ends); the check lists them
219
- await movie.exportPDF({ title }) // Blob: a PDF page per page of the deck; CLI: pixi-effects-render talk.html -o talk.pdf (--all-stops)
217
+ await stopImages(movie, { as: 'dataURL', scale: 0.25 }) // [{ stop, page, image }] one per page (its last stop); { which: 'stops' } one per stop (a page's picture honours `pdf` flags)
218
+ await inspectStops(movie) // { stops, issues }: stops whose picture is still changing (a stop landing before its animation ends); the check lists them
219
+ await exportPDF(movie, { title }) // Blob: a PDF page per page of the deck; CLI: pixi-effects render talk.html -o talk.pdf (--all-stops)
220
220
  const env = await audioEnvelope('music.mp3', { frameRate: 30 }) // BEFORE movie.init: .series.level|bass|mid|treble (0–1 per frame), .beats [s], .bpm, .at(t, band); bands: { name: [Hz, Hz] }
221
221
  const env = bpmEnvelope(120, { duration: 12 }) // no audio file: kick / snare / hats exactly on a tempo
222
222
  keyframes: react(env, { duration: 12, props: { scale: { base: 1, amount: 0.4, band: 'bass', attack: 0.02, release: 0.2 }, alpha: { base: 0.4, amount: 0.6, beats: true, decay: 0.15 } }, audioOffset: 0, loop: false })
@@ -230,7 +230,7 @@ measureText('Hello', style) // → { width, height,
230
230
  splitText('Hello world', style, { by: 'chars' | 'words' | 'lines', x, y, align: 'left' | 'center' | 'right' })
231
231
  // → [{ text, x, y, width, index, line }] — one text layer per piece, same style, anchor top-left
232
232
  ```
233
- 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, runs in the same wave BACKWARDS FROM THE LAYER'S END (at + duration): it has no `at`; to be gone at time T set duration = T − at>, 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.
233
+ 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; 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.
234
234
 
235
235
  ## Assets
236
236
 
@@ -250,7 +250,7 @@ await movie.contactSheet({ count: 6, as: 'dataURL' }) // ONE image of several
250
250
  await movie.snapshot(60, { as: 'dataURL' }) // one frame, canvas only (no player bar) — use it for detail; sheet tiles are small
251
251
  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
252
252
  await movie.onionSkin({ from: 1, to: 3, count: 8, as: 'dataURL' }) // frames overlaid into ONE picture of a movement (later = stronger): path and easing at a glance
253
- movie.review({ at: 'title@end' }) // what `pixi-effects-check` looks at, as data: { frames, problems (text cut off, off-canvas, no size), review (text overlaps: look at them), fonts, audio, at }; leaves the movie as it was. `strict: true` makes every issue a problem
253
+ movie.review({ at: 'title@end' }) // what `pixi-effects check` looks at, as data: { frames, problems (text cut off, off-canvas, no size), review (text overlaps: look at them), fonts, audio, at }; leaves the movie as it was. `strict: true` makes every issue a problem
254
254
  movie.resolveAt('3.5, 50%, f120, title@end') // moments → [{ label, frame }]; a mistake says what could not be read
255
255
  movie.inspectFonts() // { missing: [{ layer, family }] (no font of the list is installed), failed: [web fonts whose file did not load] }
256
256
  movie.inspectAudio() // the soundtrack as numbers; also loudness { integratedLufs, truePeakDb, clippedSamples, silences }, scenes, cues, notes (advice: -14 to -16 LUFS is typical for web video) — — you cannot listen: sources[i] = { layer, start, end, peakDb, sound: { length, peakDb, loudestAt, brightnessHz } } (`peakDb` is the MIX's peak between `start` and `end`, not the layer's own: `sound.peakDb` is the sound's; for a sound inside a remapped composition `start` / `end` span every pass it is heard, forward, backward, replayed, in the movie's seconds), windows (`{ t, rmsDb, peakDb, brightnessHz }` per 0.1 s), issues (limited mix, inaudible / cut-off sounds)
@@ -339,21 +339,21 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
339
339
  47. **Browser tooling: prefer `npx pixi-effects check 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.
340
340
  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)*
341
341
  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)*
342
- 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)*
342
+ 50. **An image (or video) works as a mask, inverted too** *(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 an inverted mask 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)*
343
343
  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)*
344
344
  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)*
345
345
  53. **Draw-on and typewriter need no masks or per-letter layers.** `trimStart` / `trimEnd` (0–1 of the outline, animatable, every shape but `arc`) draw a stroke on: `trimEnd: 0` plus a keyframe `to: { trimEnd: 1 }`. The fill is not trimmed, so fade `fillAlpha` in afterwards; use `strokeCap: 'round'`. Text changes over time with `set: { text }` (swap at a time) and `visibleChars` (typewriter; left-align it so it grows rightward). A `to: { text }` cannot tween a string and warns. *(fixed)*
346
346
  54. **A deck page lasts until its last stop plus the transition.** `deck()` lays pages out one after another in page-local time and a `transition` overlaps them: a page shorter than its last stop (or shorter than the transition) loses its final step or flashes. A step is a layer that starts at the previous stop (`at: <previous stop>`), so the thing that appears on a press is fully visible when the movie stops. The transition option is `kind`, not `type`. If the last stop is mid-exit on paper, flag the stop you want in the PDF: `{ at, pdf: true }` (in `deck()`: `stops: [1.2, { at: 3, pdf: true }]`), and `pdf: false` keeps one out.
347
- 55. **Presenter and Controller both listen to click and keys.** Use the `Presenter` for a talk (or let the Controller's Present button start one); do not run both on the same canvas yourself. `movie.play()` ignores stops, but the Controller's play button / Space / click-on-picture play to the next stop and pause (`pauseAtStops: false` plays through); `movie.next()` plays to the next stop (a second press skips to it), `prev()` jumps back (nothing plays in reverse).
347
+ 55. **Presenter and Controller both listen to click and keys.** Use the `Presenter` for a talk (or let the Controller's Present button start one); do not run both on the same canvas yourself. `movie.play()` ignores stops, but the Controller's play button / Space / click-on-picture play to the next stop and pause (`pauseAtStops: false` plays through); `next(movie)` (from `pixi-effects/present`) plays to the next stop (a second press skips to it), `prev(movie)` jumps back (nothing plays in reverse).
348
348
  56. **Analyse audio before `init`, and give it the movie's frame rate.** `const env = await audioEnvelope('music.mp3', { frameRate })` is async; `react()` is pure and bakes the keyframes, so there is no per-frame callback. A custom `bands` list replaces the defaults (include `bass` if you want it; beats are picked there). No file yet: `bpmEnvelope(120, { duration })` is a stand-in with a kick on every beat. Offset the music with `audioOffset` when the audio layer does not start at 0.
349
- 57. **Review a deck by its stops, not by its timeline.** `pixi-effects-check` writes `stops.png` (one picture per stop, in order) for a page with `stops`; the picture at a stop is what the audience reads while it waits. A stop that lands a few frames before an animation ends shows it half-finished (also in the PDF): `movie.inspectStops()` / the check's review list names such stops; give an animation a little more time than its step (e.g. the next stop 0.3 s after it ends).
349
+ 57. **Review a deck by its stops, not by its timeline.** `pixi-effects check` writes `stops.png` (one picture per stop, in order) for a page with `stops`; the picture at a stop is what the audience reads while it waits. A stop that lands a few frames before an animation ends shows it half-finished (also in the PDF): `inspectStops(movie)` / the check's review list names such stops; give an animation a little more time than its step (e.g. the next stop 0.3 s after it ends).
350
350
  58. **`music` counts BEATS, and every track has to add up.** A token's `:n` is beats (a quarter note), a 4/4 bar is 4: a bar of `c4:1 e4:1 g4:1` is a beat short and every later bar drifts. Keep all tracks the same length (the music lasts the longest; a track of rests `'_:16'` sets a length) and check the printed length with `movie.inspectAudio()`. 6/8 is a bar of 3 beats (dotted quarter `:1.5`, eighth `:0.5`) with `grid: 2`; `grid: 3` is triplet eighths in 4/4.
351
351
  59. **A groove that must enter late needs `from`, not a long string.** `drums: [{ kick: 'x...' }, { from: 8, snare: '....x...' }]`. Patterns repeat; a drum object is a loop of its longest string. A `crash` on the last bar: its own section `{ from: 28, crash: 'x' }`.
352
352
  60. **Build-ups are layers and `vol` points, not louder notes.** A track's `vol` can be `[[0, 0], [8, 0.7]]` (a fade over 8 beats); the pad's `attack` can be 2–3 s; enter instruments with rests. Per-note `!` and `,` only accent and soften.
353
353
  61. **Music is a bed: `volume` 0.5–0.8 under speech or sfx.** At `volume: 1` it peaks at −6 dBFS (an sfx peaks at −12). A layer shorter than the music cuts it with a fade; longer is silent after it unless `loop: true`. Do not also add an mp3 bed.
354
354
  62. **Chord names are voiced close around octave 3**: use `[c3 g3 e4]` for an exact voicing or `Am7/e` for a bass note; `swing` only moves notes that start on an off-beat eighth (`.5`) or sixteenth (`.75`); the bell and music box ring for `ring` seconds whatever the written length (shorten `ring` for fast bell lines); the synth is a handy bed, not an orchestra: keep tracks to 3–6, vary the bars, and end on the tonic.
355
355
  63. **`animateText()` / `splitText()`: `x` and `y` are pixel numbers, and `y` is the TOP of the line.** An expression such as `x: 'GW/2'` throws (the letters are measured once, at build time): write `x: W / 2`. The pieces are centred half a line below `y`, so a line drawn under the text goes at `y + lineHeight` (about 1.2 × `fontSize`), not at `y`; look at the contact sheet, the layout check cannot see an underline through a word. (`check` writes into `check-out/` of the current folder unless you pass `--out`.)
356
- 64. **`animateText()`: `out` leaves from the END of the layer and has no `at`.** `out: { preset: 'fade', at: 7.6 }` does not make the text leave at 7.6 s: `at` is ignored (now with a warning) and the text stays until `at + duration`. To be gone at time T, set `duration: T − at` (title at 0.4 s that must be gone by 8.0 s: `duration: 7.6`). The same holds for a hand-written exit keyframe: a negative `at` counts back from the end of its own layer, a positive one from the start of its own layer, never from the movie.
356
+ 64. **`animateText()`: `out: { preset, at: T }` starts the exit at composition second T; without `at` the exit is the end of the layer run backwards.** `duration` is still how long until the layer ends (every piece is gone by `at + duration`): with `out.at`, give `duration` room for the exit and the wave after T (a warning says `out.at ≤ …` when it does not fit, or when T is before the entrance has finished). Without `out.at`, to be gone at time T set `duration: T − at` (title at 0.4 s that must be gone by 8.0 s: `duration: 7.6`). A hand-written exit keyframe works the other way: a negative `at` counts back from the end of its own layer, a positive one from the start of its own layer, never from the movie.
357
357
  65. **A spring that does not spring, and `'auto'` that is not a spring.** `spring(1,170,26)` is critically damped (damping ratio 1): it never overshoots; at stiffness 170 use damping below 20 (`spring(1, 170, 12)`), or pick a preset (`spring.bouncy`, `spring.wobbly`). With a number `duration`, that number is the settle time (mass and stiffness only matter with `duration: 'auto'`). `duration: 'auto'` needs a spring ease (otherwise it warns and uses 0.5 s) and is for keyframes, not for `animateText` / `orbit` tweens. A colour that overshoots is clamped in its colour space; `alpha` past 1 shows nothing.
358
358
  66. **Animating a gradient: one `fillGradient` object, partial, in the keyframe.** `to: { fillGradient: { angle: 360, stops: [...] } }` (keys you leave out stay). Not `'fillGradient.angle'`, not `gradientAngle`, not `fillGradient: 'red'` (each warns once with what to write). The layer must start with a `fillGradient`; `stops` keep their count; `type` cannot change. A fade between two colours in the default `rgb` passes through grey (green → magenta): set `colorSpace: 'oklch'`. Under a mask the stops still span the whole shape (see 39).
359
359
  67. **A key the layer does not have is warned about now (it used to be ignored in silence).** `{ type: 'text', anchorX: 0.5 }` (anchors go in `initial`), `{ type: 'audio', sfx: 'click', pitch: 3 }` (the knob goes inside: `sfx: { preset: 'click', pitch: 3 }`), `{ type: 'shape', shape: 'rect', radius: 5 }` (that is a circle's key; a rect has `cornerRadius`), a mistyped property in a keyframe (`alhpa`), a mistyped text `style` key. The warning names the key, the kind of layer and what was probably meant; fix the key, do not silence the warning. Three gallery pieces had the `pitch` mistake for months: their sound effects were never pitched.
@@ -368,12 +368,12 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
368
368
  76. **`aperture` is the lens diameter in px, not a 0–1 strength.** 30 is the default (clearly shallow), 0 turns it off, 60 is strong, 100 is extreme (small bright objects speckle); the blur never exceeds 32 px. 20 or more blurred layers at once warns (one filter pass each, more on WebGPU).
369
369
  77. **A dolly does not need the camera's home distance typed in:** keep `z` automatic and animate `offsetZ` (negative = closer). Writing `z` yourself means typing `H / 2 / tan(fov / 2)` (about 989 at 720p, fov 40; as an expression `H / 2 / tan(20 * PI / 180)`). A bokeh field is a few big cards, not dozens of layers: a card at depth `z` fills the frame at size `(W / s) × (H / s)`, `s = camZ / (camZ − z)`.
370
370
  78. **A blend mode mixes the layer with everything BELOW it, in the order of the array.** The backdrop goes first, the blended layer after it; a layer placed before its backdrop blends with nothing. Names are CSS names with the hyphen: `soft-light`, not `softLight` (the warning says which you meant).
371
- 79. **On a plain `composition` a `blendMode` makes each child blend by itself, not the composition as one picture.** To blend the whole group give the composition `threeD: true` (z 0, default camera: nothing else changes) or a filter. A text, image or video layer as the (not inverted) `mask` cannot carry a blend (it warns, draws normal); a shape mask and `maskInverted` work.
371
+ 79. **On a plain `composition` a `blendMode` makes each child blend by itself, not the composition as one picture.** To blend the whole group give the composition `threeD: true` (z 0, default camera: nothing else changes) or a filter. A text, image or video layer as the (not inverted) `mask` cannot carry a blend (it warns, draws normal); a shape mask and an inverted mask work.
372
372
  80. **`soft-light` / `overlay` do almost nothing over near-black or near-white; `color-dodge` over dark gives a saturated colour, not white; `blendMode` cannot be keyframed** (use a second short-lived layer). **The 14 new modes cost about 2 ms a layer on WebGL; 10 or more on screen at once warns.** A glow, a haze or a colour cast is ONE layer with a gradient (`fillGradient`), not dozens of circles; keep `add` / `screen` / `multiply` for big piles of particles.
373
373
  81. **A layer named in `mask` is a matte: it is not drawn, and it must be alive whenever the layers it cuts are.** `{ name: 'band', … }` plus `mask: 'band'` on the text hides the band itself; the matte's time is the composition's time (its own `at` / `duration`). Where the matte is missing the layers it cuts are invisible: the build warns when its lifespan does not cover its users. To show the band as well, add a second layer that looks the same.
374
374
  82. **A `mask` name works inside one composition only.** The matte and the layers that name it are siblings in the same `sequences`. A layer inside a nested composition cannot name a matte outside it (warns, with the closest name if there is one, and the layer is drawn without the matte); a layer that a transition wraps keeps its matte. Put the matte inside the nested composition, or the layer next to it.
375
375
  83. **`channel: 'luma'` reads brightness, `'alpha'` (the default) reads opacity.** A white shape is 1 for both, a black shape is 0 for luma and 1 for alpha, a grey `#808080` is 0.5 luma. Outside the matte layer's own shape both are 0, so a wide gradient rectangle slid across the layers is a soft moving reveal. Use `luma` with a grayscale image or a gradient layer; use the default for shapes and text. `luminance`, `brightness` and `grayscale` are not channel names: they warn ("did you mean luma?") and alpha is used.
376
- 84. **Subtract with `invert: true` in a list; a list multiplies.** `mask: ['panel', { layer: 'hole', invert: true }]` is "inside `panel`, outside `hole`". `maskInverted: true` is for an inline mask only: with a named matte it warns and tells you to write `invert: true`. A luma wipe's `map` is a built-in name (`linear` `diagonal` `radial`) or a grayscale image asset; an unknown name warns once and wipes as if the map were white.
376
+ 84. **Subtract with `invert: true` in a list; a list multiplies.** `mask: ['panel', { layer: 'hole', invert: true }]` is "inside `panel`, outside `hole`". A mask written in place inverts the same way: `mask: { layer: { type: 'shape', … }, invert: true }` (the old `maskInverted: true` key is gone: it warns and says this). A luma wipe's `map` is a built-in name (`linear` `diagonal` `radial`) or a grayscale image asset; an unknown name warns once and wipes as if the map were white.
377
377
  85. **One light layer lights every `threeD` layer, and what no light reaches is black.** Add `{ type: 'light', kind: 'ambient', initial: { intensity: 0.2 } }` (a white ambient light at 1 is the same as no light). A layer you want flat-lit is `lit: false`. Layers without `threeD` are never lit, and a light only counts while it is alive (give it the scene's `at` / `duration`): with no light alive the layers are drawn unlit.
378
378
  86. **A shadow needs `castsShadows: true` on the light AND on the layer.** Either alone shows nothing (the build says which half is missing). The caster has to be a `threeD` layer in front of the receiver (between it and the light); a receiver takes at most 4 casters. A hard shadow can look like a dark slab: `shadowDiffusion: 10`–`20` softens it, and soft shadows cost the most, so use them on one or two lights. A spot's pool on the wall has radius about `distance × tan(coneAngle / 2)` and can hide behind the card that stands in front of it.
379
379
  87. **The light's numbers go in `initial` / `keyframes`; `kind`, `falloff` and `castsShadows` are on the layer.** `{ type: 'light', intensity: 1 }` warns; so do `target` / `pointOfInterest` (use `lookAtX / lookAtY / lookAtZ`), `angle` (`coneAngle`), `castShadows` (`castsShadows`) and `intensity: 80` (it is a multiplier, 1 = full, not a percentage). A spot needs a look-at point (it defaults to the middle of the picture).
@@ -1057,8 +1057,8 @@ return { duration: d.duration, sequences: d.composition.sequences, transitions:
1057
1057
  ```js
1058
1058
  // @docs-only
1059
1059
  // in the page: the deck is spread into init; the Presenter (or the Controller, which then shows the stops and a Present button) plays it
1060
- import { Movie, deck } from 'pixi-effects';
1061
- import { Presenter } from 'pixi-effects/presenter';
1060
+ import { Movie } from 'pixi-effects';
1061
+ import { deck, Presenter } from 'pixi-effects/present'; // (add "pixi-effects/present" to the import map)
1062
1062
  const movie = new Movie();
1063
1063
  await movie.init({ canvas, width: 1280, height: 720, frameRate: 30, ...deck({ /* as above */ }) });
1064
1064
  new Presenter(movie, { canvas }); // arrows / Space / Enter / click / swipe = next, ← = back, B = black screen, F = fullscreen, ? = keys
@@ -1465,7 +1465,7 @@ three({
1465
1465
 
1466
1466
  ## Subtitles from an SRT file
1467
1467
 
1468
- Subtitles are just text layers. The function reads a `.srt` (numbers, `00:00:01,000 --> 00:00:03,500`, the words) and returns one layer per cue: it appears and disappears over 0.12 s, sits 90 px above the bottom edge, wraps at 1000 px (about 80 % of a 1280 px picture) and has a black outline so it reads on any picture. Two cues that overlap in time are drawn on top of each other: split a long line into two lines in the `.srt` rather than relying on wrapping. Check the result with `pixi-effects-check`; it reports text that is cut off by the edge.
1468
+ Subtitles are just text layers. The function reads a `.srt` (numbers, `00:00:01,000 --> 00:00:03,500`, the words) and returns one layer per cue: it appears and disappears over 0.12 s, sits 90 px above the bottom edge, wraps at 1000 px (about 80 % of a 1280 px picture) and has a black outline so it reads on any picture. Two cues that overlap in time are drawn on top of each other: split a long line into two lines in the `.srt` rather than relying on wrapping. Check the result with `pixi-effects check`; it reports text that is cut off by the edge.
1469
1469
 
1470
1470
  ```js
1471
1471
  // @recipe subtitles
@@ -1529,7 +1529,7 @@ const scene = { type: 'composition', name: 'hook', at: 0, duration: 5.5, width:
1529
1529
 
1530
1530
  ## A batch of videos from a table (one video per row)
1531
1531
 
1532
- A batch of videos is a loop outside the video. The page reads what changes from its address (`new URLSearchParams(location.search)`), and `pixi-effects-render --query "name=Aiko&score=92"` opens it with those values. Names with spaces or non-Latin letters must be URL-encoded (`jq -sRr @uri` as below, or `encodeURIComponent` in Node). Each video starts its own browser, so N videos cost N start-ups: try one with `--draft` (half size, fast), then render the real ones. To make the pieces of one row differ in more than text, use the same values to pick colours or an image name.
1532
+ A batch of videos is a loop outside the video. The page reads what changes from its address (`new URLSearchParams(location.search)`), and `pixi-effects render --query "name=Aiko&score=92"` opens it with those values. Names with spaces or non-Latin letters must be URL-encoded (`jq -sRr @uri` as below, or `encodeURIComponent` in Node). Each video starts its own browser, so N videos cost N start-ups: try one with `--draft` (half size, fast), then render the real ones. To make the pieces of one row differ in more than text, use the same values to pick colours or an image name.
1533
1533
 
1534
1534
  ```js
1535
1535
  // @docs-only batch-page
@@ -1560,6 +1560,8 @@ done < table.csv
1560
1560
 
1561
1561
  # Directing a film (for any AI that makes video with code)
1562
1562
 
1563
+ > **No shell or files (a browser chat)?** This guide is not for you: follow https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/CHAT.md. You can still plan: put three lines in your reply (the one sentence to remember, the real assets you were given, the beats in order) and ask the person before you build.
1564
+
1563
1565
  Read this before you write a composition for anything longer than a title card: a product film, a company introduction, a launch, a data story. It is about the decisions around the renderer, so it names no tool; `SKILL.md` has the pixi-effects commands.
1564
1566
 
1565
1567
  A one-line prompt makes the model fill every missing decision with its safest default: large text in the middle, a gradient behind it, everything fading in, the logo last. The fix is not a cleverer prompt. It is to remove the missing decisions before anything is drawn, and to judge what was drawn with evidence.
@@ -2492,31 +2494,33 @@ The mask is itself a sequence, so it can have `keyframes` of its own — useful
2492
2494
  Notes:
2493
2495
  - The mask sequence is rendered as a mask, not as a normal child — its `fillColor` / `strokeColor` only matter for which pixels are kept, not for the visible colour.
2494
2496
  - Any sequence type works as a mask (shape / image / text / video / nested composition); shapes are the natural choice for geometric reveals.
2495
- - A plain **shape** mask (not inverted) is a hard-edged stencil, the cheapest way. Every other mask (a text, image or video layer, `maskInverted`, a named matte, a list) is drawn into a texture once per frame and applied as a **matte filter**: its edges are soft and antialiased, it costs one offscreen pass, and `blendMode` works with it (see [Mattes](#named-mattes-and-luma)).
2497
+ - A plain **shape** mask (not inverted) is a hard-edged stencil, the cheapest way. Every other mask (a text, image or video layer, an inverted or luma mask, a named matte, a list) is drawn into a texture once per frame and applied as a **matte filter**: its edges are soft and antialiased, it costs one offscreen pass, and `blendMode` works with it (see [Mattes](#named-mattes-and-luma)).
2496
2498
  - For a left-to-right wipe, give the mask `anchorX: 0` (rect/circle/ellipse) so `width: 0 → full` grows rightward from the left edge.
2497
2499
 
2498
- #### `maskInverted`
2500
+ #### An inverted mask: `mask: { layer, invert: true }`
2499
2501
 
2500
- When `true`, flip the mask sense: pixels INSIDE the mask shape become transparent, pixels OUTSIDE stay visible. Useful for knockout / cutout effects (punch a circular hole through a panel, etc.). It works inside a masked group too: the hole is cut out of what the outer mask lets through, and nothing shows outside the outer mask.
2502
+ Write the mask layer inside `layer` and add `invert: true` to flip the mask sense: pixels INSIDE the mask shape become transparent, pixels OUTSIDE stay visible. Useful for knockout / cutout effects (punch a circular hole through a panel, etc.). It works inside a masked group too: the hole is cut out of what the outer mask lets through, and nothing shows outside the outer mask.
2501
2503
 
2502
2504
  ```ts
2503
2505
  // Photo with a circular hole punched through the middle
2504
2506
  {
2505
2507
  type: 'image', asset: 'photo',
2506
2508
  initial: { x: 'W/2', y: 'H/2', anchorX: 0.5, anchorY: 0.5 },
2507
- maskInverted: true,
2508
2509
  mask: {
2509
- type: 'shape', shape: 'circle', radius: 80,
2510
- initial: { x: 'W/2', y: 'H/2', fillColor: '#ffffff' },
2511
- keyframes: [
2512
- { at: 0, to: { radius: 120 }, duration: 1.5, ease: 'sine.inOut' },
2513
- { at: 1.5, to: { radius: 80 }, duration: 1.5, ease: 'sine.inOut' },
2514
- ],
2510
+ invert: true,
2511
+ layer: {
2512
+ type: 'shape', shape: 'circle', radius: 80,
2513
+ initial: { x: 'W/2', y: 'H/2', fillColor: '#ffffff' },
2514
+ keyframes: [
2515
+ { at: 0, to: { radius: 120 }, duration: 1.5, ease: 'sine.inOut' },
2516
+ { at: 1.5, to: { radius: 80 }, duration: 1.5, ease: 'sine.inOut' },
2517
+ ],
2518
+ },
2515
2519
  },
2516
2520
  }
2517
2521
  ```
2518
2522
 
2519
- An inverted mask goes through the matte filter (the letters of a text mask, or a hole with a soft edge), so it works with every `blendMode` and with a mask that has its own `filters`. `maskInverted` is the same thing as `invert: true` on a named matte; with a named matte, write `invert: true` (below).
2523
+ An inverted mask goes through the matte filter (the letters of a text mask, or a hole with a soft edge), so it works with every `blendMode` and with a mask that has its own `filters`. `{ layer: <the mask layer>, invert: true }` is the same thing as `{ layer: 'matteName', invert: true }` on a named matte (below); `channel: 'luma'` works on a layer written in place too. (A layer written in place cannot go inside a list of masks: give it a name.)
2520
2524
 
2521
2525
  #### Named mattes and luma
2522
2526
 
@@ -2821,7 +2825,7 @@ initial: { x: 'GW/2', y: 'GH * 0.96', anchorX: 0.5, anchorY: 1 }
2821
2825
 
2822
2826
  ## Stops (presentations)
2823
2827
 
2824
- `composition.stops` says where a presentation pauses: seconds, or `{ at, page?, notes?, advance?, pdf? }` (see [Presentations in the API reference](api.md#presentations-stops-next-prev-presenter)). A stop with `page` begins a page (a name, or `true`), the others are steps of it; with no `page` anywhere each stop is a page. `movie.play()` ignores them; the `Controller` shows marks on the seek bar and its play button plays to the next stop (`pauseAtStops: false` plays through), and `movie.next()` and `Presenter` pause on them. **`pdf: true` / `pdf: false`** on a stop chooses which moment stands for a page in a PDF: `true` = this stop is the page's picture, `false` = never use it (default: the page's last stop). Use it when the last stop is mid-exit. [`deck()`](#deck) builds the whole thing from pages.
2828
+ `composition.stops` says where a presentation pauses: seconds, or `{ at, page?, notes?, advance?, pdf? }` (see [Presentations in the API reference](api.md#presentations-stops-and-pixi-effectspresent)). A stop with `page` begins a page (a name, or `true`), the others are steps of it; with no `page` anywhere each stop is a page. `movie.play()` ignores them; the `Controller` shows marks on the seek bar and its play button plays to the next stop (`pauseAtStops: false` plays through), and `next(movie)` and `Presenter` (both from `pixi-effects/present`) pause on them. **`pdf: true` / `pdf: false`** on a stop chooses which moment stands for a page in a PDF: `true` = this stop is the page's picture, `false` = never use it (default: the page's last stop). Use it when the last stop is mid-exit. [`deck()`](#deck) builds the whole thing from pages.
2825
2829
 
2826
2830
  ## Transitions
2827
2831
 
@@ -3302,10 +3306,10 @@ See [the API reference](api.md#audio-reactive-audioenvelope-react-bpmenvelope) f
3302
3306
 
3303
3307
  ### `deck`
3304
3308
 
3305
- A deck of pages as one movie, for a `Presenter`. Each page is a nested composition laid out after the one before (so its layers are written in the page's own time), the stops come from the pages, and an optional `transition` joins them with the existing transitions.
3309
+ A deck of pages as one movie, for a `Presenter` (`import { deck, Presenter } from 'pixi-effects/present'`). Each page is a nested composition laid out after the one before (so its layers are written in the page's own time), the stops come from the pages, and an optional `transition` joins them with the existing transitions.
3306
3310
 
3307
3311
  ```ts
3308
- import { deck } from 'pixi-effects';
3312
+ import { deck } from 'pixi-effects/present';
3309
3313
 
3310
3314
  await movie.init({ canvas, width: 1280, height: 720, frameRate: 30, ...deck({
3311
3315
  transition: { kind: 'slide', direction: 'left', duration: 0.6, ease: 'power3.inOut' },
@@ -3377,7 +3381,6 @@ The added keyframes are layered on top of any keyframes the spec already has. `w
3377
3381
 
3378
3382
  - [`Movie`](#movie) — composition runtime: init, playback, render
3379
3383
  - [`Controller`](#controller) — drop-in player UI overlay
3380
- - [Helpers](#helpers) — pure utilities exported from `pixi-effects/controller`
3381
3384
  - [`pixi-effects/three`](#pixi-effectsthree) — optional three.js integration
3382
3385
 
3383
3386
  For DSL types (composition spec, sequences, filters, keyframes, expressions), see [DSL reference](./dsl.md).
@@ -3446,13 +3449,21 @@ Seeks to a specific frame. Pauses if currently playing? **No** — does not chan
3446
3449
 
3447
3450
  `force=true` skips the early-return when the requested frame equals `currentFrame`. Use it after a composition rebuild.
3448
3451
 
3452
+ ### `movie.playUntil(seconds?): Promise<boolean>`
3453
+
3454
+ Play, and pause exactly at `seconds` (the frame `round(seconds × frameRate)`). It resolves `true` when the playhead has landed there; `false` when something else ended it first (a `pause()`, a seek, a newer `playUntil()`) or when there is nothing to play toward (a time at or before the playhead: it also pauses the movie where it is; a movie that is not ready). A time at or past the end lands on the last frame; with no time at all it plays to the end, says `ended`, and resolves `true`. An ordinary `play()` is not affected. It is the one move a presentation (`pixi-effects/present`) is built on; it is also the way to "play the intro, then wait": `await movie.playUntil(4.5)`.
3455
+
3456
+ ### `movie.peek(fn): Promise<T>`
3457
+
3458
+ Look around and put the movie back: pauses, runs `fn` (seek, take pictures with `snapshot()`; the seeks made inside are not reported as `seeking` / `seeked`), then puts the playhead (or the poster) back where it was, whatever `fn` did, and returns what `fn` returned. `stopImages()` and `inspectStops()` (`pixi-effects/present`) are made with it.
3459
+
3449
3460
  ### `movie.snapshot(frame?, options?): Promise<Blob | string>`
3450
3461
 
3451
3462
  ```ts
3452
- snapshot(frame?: number, options?: { scale?: number; type?: 'image/png' | 'image/jpeg'; as?: 'blob' | 'dataURL'; motionBlur?: MotionBlurSpec }): Promise<Blob | string>
3463
+ snapshot(frame?: number, options?: { scale?: number; type?: 'image/png' | 'image/jpeg'; quality?: number; as?: 'blob' | 'dataURL' | 'imageData'; motionBlur?: MotionBlurSpec }): Promise<Blob | string | ImageData>
3453
3464
  ```
3454
3465
 
3455
- A picture of one frame: **the canvas only** (the player bar is not in it). Seeks to `frame` (default: the current frame) and stays there. `as: 'dataURL'` returns a `data:` URL string, handy when a script can only return text. Use it to look at what you built.
3466
+ A picture of one frame: **the canvas only** (the player bar is not in it). Seeks to `frame` (default: the current frame) and stays there. `as: 'dataURL'` returns a `data:` URL string, `as: 'imageData'` the pixels as an `ImageData` (to measure or compare; `quality` is the JPEG quality, 0 to 1), handy when a script can only return text. Use it to look at what you built.
3456
3467
 
3457
3468
  ### `movie.contactSheet(options?): Promise<Blob | string>`
3458
3469
 
@@ -3549,10 +3560,10 @@ interface RenderOptions {
3549
3560
 
3550
3561
  `movie.init({ motionBlur })` (or the same option on a single `render()`, `snapshot()` or `contactSheet()` call) exposes each frame the way a film camera does: the frame is drawn `samples` times at moments spread over the shutter interval around its time, and the pictures are averaged. Fast motion smears along its path; a still picture is unchanged. `true` is 8 samples at a 180° shutter, a number is the sample count (2–64), `{ samples, shutter }` sets both (`shutter` is the fraction of a frame the shutter stays open, above 0 and up to 1; `0.5` is the film look, `1` blurs more). `false` turns it off for one call.
3551
3562
 
3552
- - It applies to what is **made**: the exported file, snapshots, contact sheets (so `pixi-effects-check` and `inspect`-style reviews show what the file will have). Live playback is not blurred.
3563
+ - It applies to what is **made**: the exported file, snapshots, contact sheets (so `pixi-effects check` and `inspect`-style reviews show what the file will have). Live playback is not blurred.
3553
3564
  - Cost: a render takes about `samples` times as long (video layers are decoded once per sample). Eight samples is enough for most motion; raise it for very fast or large movement, where fewer samples show as separate ghost images instead of a smear.
3554
3565
  - After a blurred snapshot, contact sheet or render the playhead and the stage are on an exact frame again, not a sample.
3555
- - `pixi-effects-render --motion-blur 8 [--shutter 0.5]` sets it for one file.
3566
+ - `pixi-effects render --motion-blur 8 [--shutter 0.5]` sets it for one file.
3556
3567
 
3557
3568
  **Video codec:** unless you name `video.codec`, the usual one for the format is used (mp4 / mov: `avc`; webm: `vp9`); if this browser cannot encode it, the next that works is used and a warning says so. A codec you name is never swapped: it is used, or the error names the one that would work (`"av1" ... "avc" would work: render({ format: 'mp4', video: { codec: 'avc' } })`). `hevc` and `av1` make smaller files at the same quality but play in fewer places than `avc`; `hardware: 'prefer-hardware'` asks the GPU's encoder.
3558
3569
 
@@ -3578,13 +3589,13 @@ const blob = await movie.posterImage(); // the picture at that time
3578
3589
  - Once the movie is ready the **canvas shows the poster frame**, while the playhead stays at frame 0 (`currentFrame` is 0, the player shows 0:00). The first `play()` or `gotoFrame()` replaces it, and **play starts from 0**.
3579
3590
  - `posterImage()` leaves the movie as it was: still showing the poster, or back at the frame it was at. `contactSheet()` does the same.
3580
3591
  - A time past the end is clamped to the last frame, and something that is not a number is ignored; both warn.
3581
- - `pixi-effects-check` writes the poster as `poster.jpg`.
3592
+ - `pixi-effects check` writes the poster as `poster.jpg`.
3582
3593
 
3583
3594
  ### `movie.timelineData(): TimelineData` and `movie.timelineChart(options?): string`
3584
3595
 
3585
- The whole movie as rows on a time axis, for a person to read at a glance. `timelineData()` is the data: `{ duration, rows, transitions }`, a row per layer with `name`, `type`, `depth` (1 inside a composition), absolute `start` / `end` in seconds, `keys` (when each keyframe starts) and, for a family of generated layers, `parts` (one span per layer). Layers of one type whose names differ only by numbers (`ring10-0`, `ring9-1`, `pop-3` …), four or more, become ONE row (`ring#-# ×19`), so a movie with 700 generated layers is a screenful. `timelineChart({ title })` returns one self-contained HTML page (an inline SVG, no scripts): bars coloured by type, ◆ at each keyframe start, a shaded band per transition, a tooltip per bar. `pixi-effects-check` writes it next to the contact sheet as `timeline.html`.
3596
+ The whole movie as rows on a time axis, for a person to read at a glance. `timelineData()` is the data: `{ duration, rows, transitions }`, a row per layer with `name`, `type`, `depth` (1 inside a composition), absolute `start` / `end` in seconds, `keys` (when each keyframe starts) and, for a family of generated layers, `parts` (one span per layer). Layers of one type whose names differ only by numbers (`ring10-0`, `ring9-1`, `pop-3` …), four or more, become ONE row (`ring#-# ×19`), so a movie with 700 generated layers is a screenful. `timelineChart({ title })` returns one self-contained HTML page (an inline SVG, no scripts): bars coloured by type, ◆ at each keyframe start, a shaded band per transition, a tooltip per bar. `pixi-effects check` writes it next to the contact sheet as `timeline.html`.
3586
3597
 
3587
- `movie.timelineSvg()` is the chart alone (one `<svg>`, geometry in `data-*` attributes, each row `<g class="row" data-start>`): `pixi-effects-view` puts a playhead on it and turns a click into a seek.
3598
+ `movie.timelineSvg()` is the chart alone (one `<svg>`, geometry in `data-*` attributes, each row `<g class="row" data-start>`): `pixi-effects view` puts a playhead on it and turns a click into a seek.
3588
3599
 
3589
3600
  ### Your own layer type: `Sequence` and `registerSequenceType(type, ctor)`
3590
3601
 
@@ -3627,7 +3638,6 @@ movie.off(event, fn): this
3627
3638
  | `'progress'`| `{ progress: number; frame: number; totalFrames: number }` | during `render()`, once per encoded frame |
3628
3639
  | `'loadprogress'` | `{ stage: 'assets' \| 'build' \| 'sound' \| 'frames'; loaded: number; total: number; progress: number }` | during `init()`, after each asset and each layer built, once the sound is mixed; `progress` is 0 to 1 for the whole init. The page's `.pe-loader` already shows it; `movie.loadStages` gives the milliseconds per stage afterwards |
3629
3640
  | `'seeking'` / `'seeked'` | `{ frame, totalFrames }` (the frame it lands on) | a `gotoFrame()` to another frame starts / has finished. NOT emitted for playback ticks, `render()`, `snapshot()` or `contactSheet()` |
3630
- | `'stop'` | `{ index: number; stop: Stop; pageIndex: number }` | a presentation is on a stop: `next()` played to it, or `prev()` / `goToStop()` / `goToPage()` jumped to it |
3631
3641
  | `'ended'` | none | playback ran off the end (after `'pause'`); pausing by hand does not emit it |
3632
3642
  | `'volumechange'` | `{ volume: number; muted: boolean }` | `volume` or `muted` really changed (clamping counts) |
3633
3643
  | `'error'` | `{ where: 'init' \| 'render' \| 'playback'; message: string; error: unknown }` | `init()` / `render()` failed (the call still rejects), or a frame failed during playback |
@@ -3651,28 +3661,32 @@ The names are the ones an HTML5 `<video>` uses, so a player written for `<video>
3651
3661
  | `muted` | boolean | getter/setter; immediate |
3652
3662
  | `app` | `pixi.js Application \| null` | PIXI Application instance (advanced/escape hatch) |
3653
3663
  | `timeline` | `Timeline \| null` | the movie's timeline (advanced): `time(t)`, `duration()`, `segments()` |
3654
- | `stops` | `Stop[]` | the composition's stops (see Presentations); empty for an ordinary movie |
3655
- | `stopIndex`, `pageIndex`, `pageCount`, `currentStop` | number, number, number, `Stop \| null` | where the playhead is among the stops and pages (`-1` / `null` before the first stop) |
3664
+ | `stops` | `Stop[]` | the composition's stops: named times (see Presentations); empty for an ordinary movie |
3656
3665
 
3657
- ### Presentations: `stops`, `next()`, `prev()`, `Presenter`
3666
+ ### Presentations: `stops` and `pixi-effects/present`
3658
3667
 
3659
3668
  A composition can say where a presentation pauses: `composition: { stops: [...] }`. A stop is a time in seconds or `{ at, page?, notes?, advance?, pdf? }`; a stop with `page` (a name, or `true`) begins a page, the others are steps of it; with no `page` anywhere each stop is a page; the first stop always begins one. A negative `at` counts back from the end; two stops on one frame are one; a stop outside the movie is ignored with a warning. `movie.play()` ignores the stops and plays to the end; the `Controller`'s play button, Space and a click on the picture play to the next stop and pause there (see `pauseAtStops`). `pdf: true` marks the stop whose picture stands for its page in a PDF (and in the page overview); `pdf: false` keeps a stop's picture out of the PDF (a last stop that is mid-exit, a step you do not want on paper). Without flags a page is pictured by its last stop.
3660
3669
 
3661
3670
  ```ts
3662
- movie.stops // Stop[]: { index, at, frame, page: string | null, pageIndex, pageStart, notes?, advance?, pdf? }
3663
- await movie.next(); // play to the next stop and pause exactly on its frame; pressed again while playing, skip to that stop at once;
3671
+ import { next, prev, goToStop, goToPage, onStop, stopIndex, pageIndex, pageCount, currentStop } from 'pixi-effects/present';
3672
+
3673
+ movie.stops // (the core) Stop[]: { index, at, frame, page: string | null, pageIndex, pageStart, notes?, advance?, pdf? }
3674
+ await next(movie); // play to the next stop and pause exactly on its frame; pressed again while playing, skip to that stop at once;
3664
3675
  // after the last stop play to the end. Resolves when it has landed (or stopped for another reason).
3665
- await movie.prev(); // jump back to the previous stop at once (no reverse playback); from the first stop, to the start
3666
- await movie.goToStop(i); // 0-based
3667
- await movie.goToPage(n); // 0-based: the first stop of page n
3668
- movie.stopIndex / movie.pageIndex / movie.pageCount / movie.currentStop
3669
- movie.on('stop', ({ index, stop, pageIndex }) => …)
3676
+ await prev(movie); // jump back to the previous stop at once (no reverse playback); from the first stop, to the start
3677
+ await goToStop(movie, i); // 0-based
3678
+ await goToPage(movie, n); // 0-based: the first stop of page n
3679
+ stopIndex(movie) / pageIndex(movie) / pageCount(movie) / currentStop(movie) // where the playhead is (-1 / null before the first stop)
3680
+ const off = onStop(movie, ({ index, stop, pageIndex }) => …); // a stop was landed on; off() stops listening
3670
3681
  ```
3671
3682
 
3672
- `deck({ pages, transition? })` builds such a movie from pages (see the DSL reference). **`Presenter`** (`pixi-effects/presenter`) is the player for it:
3683
+ The presentation side is a separate entry, `pixi-effects/present`, built on the core's public API only (an import map line: `"pixi-effects/present": ".../dist/present.js"`). The core knows `stops` (named times) and two general moves it is made of: `movie.playUntil(seconds)` and `movie.peek(fn)` (below). An ordinary `movie.play()` ignores the stops. `onStop` listeners belong to the `pixi-effects/present` module the page imported: the `Controller` loads `present.js` from beside itself, so map `"pixi-effects/present"` to the same folder as `Controller.js`, or a Controller landing will not reach your `onStop`.
3684
+
3685
+
3686
+ `deck({ pages, transition? })` (from `pixi-effects/present`) builds such a movie from pages (see the DSL reference). **`Presenter`** (also `pixi-effects/present`) is the player for it:
3673
3687
 
3674
3688
  ```ts
3675
- import { Presenter } from 'pixi-effects/presenter';
3689
+ import { Presenter } from 'pixi-effects/present';
3676
3690
  const presenter = new Presenter(movie, { canvas }); // instead of Controller; await presenter.start() to go fullscreen and play to the first stop
3677
3691
  ```
3678
3692
 
@@ -3690,18 +3704,19 @@ const presenter = new Presenter(movie, { canvas }); // instead of Controller
3690
3704
 
3691
3705
  **The page overview (`G`)** is a grid of every page with its picture, the current page marked: arrows choose, Enter or a click goes there, `G` or Esc closes. **The presenter view (`P`)** opens a second window for the speaker: the picture that is on screen (live), the picture of the next stop, the notes of the page, a timer (with reset), the page counter and Back / Next buttons; the keys work there too. Both use a picture of every stop that `start()` makes **before** the audience sees anything (behind a "Preparing…" cover; taking them moves the playhead through the stops and back, so it must not happen on screen); `presenter.ensureThumbs()` makes them on demand (an overview opened before that does it on the spot, with a flicker). A browser needs a click or key press to open a popup: `P` is one; if it is blocked a warning says so.
3692
3706
 
3693
- `presenter.start()`, `presenter.openPresenterView()`, `presenter.closePresenterView()`, `presenter.next()`, `presenter.prev()`, `presenter.setCover('black' \| 'white' \| 'off')`, `presenter.exit()`, `presenter.destroy()`. It uses the canvas's positioned parent, or wraps the canvas like `Controller` does. A movie with stops shown through a **`Controller`** gets a mark per stop on the seek bar and a Present button (`present: false` leaves the marks only) that hands the page to a `Presenter` (fullscreen, keys, click and swipe move through the stops; Esc gives the bar back).
3707
+ `presenter.start()`, `presenter.openPresenterView()`, `presenter.closePresenterView()`, `presenter.next()`, `presenter.prev()`, `presenter.setCover('black' \| 'white' \| 'off')`, `presenter.exit()`, `presenter.destroy()`. It uses the canvas's positioned parent, or wraps the canvas like `Controller` does. A movie with stops shown through a **`Controller`** gets a mark per stop on the seek bar and a Present button (`presentButton: false` leaves the marks only) that hands the page to a `Presenter` (fullscreen, keys, click and swipe move through the stops; Esc gives the bar back).
3694
3708
 
3695
- ### `movie.inspectStops(options?)`
3709
+ ### `inspectStops(movie, options?)`
3696
3710
 
3697
- Is the picture at each stop still changing? Each stop is compared with the picture `lookback` frames before it (default 3); when more than `tolerance` (default 0.004, a share of the pixels) differs, the audience, the page overview and a PDF would see a half-finished animation. Returns `{ stops: [{ index, page, at, frame, moving, settled }], issues: string[] }`; each issue names the stop and the page and says what to do (move the stop to after the animation has ended, or flag a settled stop of the page `pdf: true`). Leaves the playhead where it was. `pixi-effects-check` runs it for any page with `stops`.
3711
+ Is the picture at each stop still changing? Each stop is compared with the picture `lookback` frames before it (default 3); when more than `tolerance` (default 0.004, a share of the pixels) differs, the audience, the page overview and a PDF would see a half-finished animation. Returns `{ stops: [{ index, page, at, frame, moving, settled }], issues: string[] }`; each issue names the stop and the page and says what to do (move the stop to after the animation has ended, or flag a settled stop of the page `pdf: true`). Leaves the playhead where it was. `pixi-effects check` runs it for any page with `stops` (it loads the page's own `pixi-effects/present`; add that line to the import map).
3698
3712
 
3699
- ### Pictures of the stops and the deck as a PDF: `movie.stopImages()`, `movie.exportPDF()`
3713
+ ### Pictures of the stops and the deck as a PDF: `stopImages(movie)`, `exportPDF(movie)`
3700
3714
 
3701
3715
  ```ts
3702
- const pages = await movie.stopImages({ as: 'dataURL', type: 'image/jpeg', scale: 0.25 }); // one picture per PAGE: its last stop, fully built
3716
+ import { stopImages, exportPDF } from 'pixi-effects/present';
3717
+ const pages = await stopImages(movie, { as: 'dataURL', type: 'image/jpeg', scale: 0.25 }); // one picture per PAGE: its last stop, fully built
3703
3718
  // [{ stop, page, image }, …]; { which: 'stops' } is one per stop, { pick: 'first' } the first stop of each page; onImage(img) is called as each one is ready
3704
- const pdf = await movie.exportPDF({ title: 'My talk' }); // a Blob: one PDF page per page of the deck (JPEG at the canvas size)
3719
+ const pdf = await exportPDF(movie, { title: 'My talk' }); // a Blob: one PDF page per page of the deck (JPEG at the canvas size)
3705
3720
  ```
3706
3721
 
3707
3722
  Both pause the movie and leave the playhead where it was; a movie with no stops returns an empty list / throws (a PDF needs pages). The picture of a page is, in order: its last stop flagged `pdf: true`, else its last (with `pick: 'first'`, first) stop that is not `pdf: false`; a page whose stops are all `pdf: false` is left out of a PDF (a page list still shows it by its last stop); `which: 'stops'` makes a page of every stop that is not `pdf: false`. `exportPDF` options: `which`, `pick`, `scale`, `quality` (JPEG, default 0.92), `title`, `motionBlur`, `onImage`. A transparent background comes out black in a JPEG: give the movie a `background`. From a script, `npx pixi-effects render my-talk.html -o my-talk.pdf` (add `--all-stops` for a page per stop).
@@ -3786,7 +3801,7 @@ el('play').onclick = () => (movie.isPlaying ? movie.pause() : movie.play());
3786
3801
  el('seek').oninput = e => movie.gotoFrame(+e.target.value);
3787
3802
  ```
3788
3803
 
3789
- What you have: `play()`, `pause()`, `gotoFrame(frame)`, `volume`, `muted` / `toggleMute()`; the state `currentFrame`, `totalFrames`, `frameRate`, `duration`, `isPlaying`, `isReady`, `loadStages` (milliseconds `init()` spent in `assets` / `build` / `sound` / `frames`), `audioBlocked` (true while the browser keeps the movie's sound silent until a tap: show "tap for sound"; a tap or key press that arrives while `play()` is pending, or the next `play()` made from a tap, starts it); the events above; `render()` for a download button; `snapshot()` for a thumbnail; `timelineData()` / `timelineSvg()` for a timeline like `pixi-effects-view`'s. Keep the movie's `canvas` wherever your layout wants it (the page's CSS sizes it). To only change how `Controller` looks, see **Theme** below.
3804
+ What you have: `play()`, `pause()`, `gotoFrame(frame)`, `volume`, `muted` / `toggleMute()`; the state `currentFrame`, `totalFrames`, `frameRate`, `duration`, `isPlaying`, `isReady`, `loadStages` (milliseconds `init()` spent in `assets` / `build` / `sound` / `frames`), `audioBlocked` (true while the browser keeps the movie's sound silent until a tap: show "tap for sound"; a tap or key press that arrives while `play()` is pending, or the next `play()` made from a tap, starts it); the events above; `render()` for a download button; `snapshot()` for a thumbnail; `timelineData()` / `timelineSvg()` for a timeline like `pixi-effects view`'s. Keep the movie's `canvas` wherever your layout wants it (the page's CSS sizes it). To only change how `Controller` looks, see **Theme** below.
3790
3805
 
3791
3806
  ## `Controller`
3792
3807
 
@@ -3816,9 +3831,9 @@ new Controller(movie: Movie, options: ControllerOptions)
3816
3831
 
3817
3832
  interface ControllerOptions {
3818
3833
  canvas: HTMLCanvasElement; // required
3819
- showExportButton?: boolean; // default true; hides ⬇ + popover
3820
- enableKeyboardShortcuts?: boolean; // default true
3821
- present?: boolean; // default true: a movie with stops gets a Present button (marks on the seek bar are always shown)
3834
+ exportButton?: boolean; // default true; false hides ⬇ + popover
3835
+ keyboard?: boolean; // default true
3836
+ presentButton?: boolean; // default true: a movie with stops gets a Present button (marks on the seek bar are always shown)
3822
3837
  pauseAtStops?: boolean; // default true: with stops, play / Space / click on the picture play to the next stop and pause; false plays straight through
3823
3838
  clickToPlay?: boolean; // default true: a click / tap on the picture plays / pauses (like <video>); false leaves the canvas alone
3824
3839
  className?: string; // default 'movie-controller'
@@ -3853,7 +3868,7 @@ controller.setTheme({ accent: null }); // null = back to the defau
3853
3868
  | `trackHeight` | `--mc-track-height` | thickness of the progress bar (a number is px) | `3px` |
3854
3869
  | `font` | `--mc-font` | `font-family` of the bar | system UI font |
3855
3870
 
3856
- A misspelt key warns with the valid ones. The default stylesheet is exported as `CONTROLLER_CSS` (from `pixi-effects/controller`) if you want to copy and restyle more.
3871
+ A misspelt key warns with the valid ones. To restyle more than the theme covers, set the `--mc-*` custom properties (or override the `.movie-controller` classes) in your own CSS.
3857
3872
 
3858
3873
  ### `controller.destroy(): void`
3859
3874
 
@@ -3879,7 +3894,7 @@ The download is triggered by an in-page `<a download>` click, so the file lands
3879
3894
 
3880
3895
  ### Keyboard shortcuts
3881
3896
 
3882
- Active when `enableKeyboardShortcuts: true` and the key target is not `<input>`/`<textarea>`/`<select>`/contenteditable.
3897
+ Active when `keyboard: true` and the key target is not `<input>`/`<textarea>`/`<select>`/contenteditable.
3883
3898
 
3884
3899
  | Key | Action |
3885
3900
  | ----------- | --------------------------------------------------- |
@@ -3895,50 +3910,6 @@ Active when `enableKeyboardShortcuts: true` and the key target is not `<input>`/
3895
3910
 
3896
3911
  The bar uses fixed colors (`#007AFF` for the active track / fill / confirm button, white for icons, `rgba(0,0,0,0.75)` gradient background). Override by adding stricter CSS rules under `.movie-controller`. A theming API (CSS custom properties) is on the roadmap.
3897
3912
 
3898
- ---
3899
-
3900
- ## Helpers
3901
-
3902
- These pure functions are exported from `pixi-effects/controller` for consumers who want to build custom controls or reuse the parsing utilities. All are side-effect-free.
3903
-
3904
- ```ts
3905
- import { formatTime, frameToPercent, pxToFrame, pxToFraction, extensionForMimeType }
3906
- from 'pixi-effects/controller';
3907
- ```
3908
-
3909
- ### `formatTime(seconds: number): string`
3910
-
3911
- Returns `M:SS` (no leading zero on minutes). `formatTime(125)` → `"2:05"`. Negatives clamp to zero.
3912
-
3913
- ### `frameToPercent(frame: number, totalFrames: number): number`
3914
-
3915
- Returns 0..100 (clamped). `totalFrames <= 0` returns 0.
3916
-
3917
- ### `pxToFrame(clientX, rect, totalFrames): number`
3918
-
3919
- Maps a pointer X coordinate (relative to viewport) inside a `DOMRect`-shaped object to a frame index 0..totalFrames. Rounded.
3920
-
3921
- ```ts
3922
- pxToFrame(150, { left: 100, width: 200 } as DOMRect, 100) // 25
3923
- ```
3924
-
3925
- ### `pxToFraction(clientX, rect, inset?): number`
3926
-
3927
- Same as `pxToFrame` but returns a normalized 0..1 fraction. The optional `inset` shrinks the active range by that many pixels on each side (used internally for the volume slider).
3928
-
3929
- ### `extensionForMimeType(mime: string): string`
3930
-
3931
- Maps common video MIME types to file extensions. Falls back to `'mp4'`.
3932
-
3933
- | MIME contains | Extension |
3934
- | ------------------- | --------- |
3935
- | `webm` | `webm` |
3936
- | `quicktime` / `mov` | `mov` |
3937
- | `matroska` / `mkv` | `mkv` |
3938
- | anything else | `mp4` |
3939
-
3940
- ---
3941
-
3942
3913
  ## `pixi-effects/three`
3943
3914
 
3944
3915
  Optional three.js integration, imported from its own entry so the core `pixi-effects` entry never touches three.js:
package/llms.txt CHANGED
@@ -18,7 +18,7 @@ Rules that cause most failures (details in the skill and pitfalls):
18
18
  - [Cheatsheet](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/skills/pixi-effects/reference/cheatsheet.md): every layer type, prop, default and convention on one page
19
19
  - [Recipes](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/skills/pixi-effects/reference/recipes.md): tested building blocks (kinetic type, lower-third, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions, sound effects without files, music written as text, three.js, a presentation deck, visuals that follow music)
20
20
  - [Pitfalls](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/skills/pixi-effects/reference/pitfalls.md): real mistakes made by AI authors, with fixes
21
- - [Directing a film](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/skills/pixi-effects/reference/direction.md): for a product film or company intro, before building: brief, style guide, shot list, assets rule, review protocol (any renderer)
21
+ - [Directing a film](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/skills/pixi-effects/reference/direction.md): for an AI with a shell and files: a product film or company intro, before building: brief, style guide, shot list, assets rule, review protocol (any renderer; a browser chat skips it)
22
22
  - [Starter template](https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/skills/pixi-effects/template.html): copy this file to begin
23
23
 
24
24
  ## Reference