pixi-effects 0.28.1 → 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 +67 -0
  2. package/README.md +11 -11
  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 +108 -16
  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-NI74UJT3.cjs.map → Composition-MUKR3FYD.cjs.map} +1 -1
  12. package/dist/Composition-RJGA7KDU.js +7 -0
  13. package/dist/{Composition-HFEVIYHN.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-WEJAQAFX.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-SBOVUZZY.cjs → chunk-WHNADXXG.cjs} +121 -114
  41. package/dist/chunk-WHNADXXG.cjs.map +1 -0
  42. package/dist/{chunk-AK54FIWM.js → chunk-ZAXYPXKW.js} +17 -6
  43. package/dist/chunk-ZAXYPXKW.js.map +1 -0
  44. package/dist/{chunk-MZCDBRTY.cjs → chunk-ZVUO325O.cjs} +34 -23
  45. package/dist/chunk-ZVUO325O.cjs.map +1 -0
  46. package/dist/index.cjs +106 -4480
  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 -4455
  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 +90 -118
  68. package/llms.txt +1 -1
  69. package/package.json +5 -5
  70. package/skills/pixi-effects/SKILL.md +6 -6
  71. package/skills/pixi-effects/reference/cheatsheet.md +9 -9
  72. package/skills/pixi-effects/reference/direction.md +4 -2
  73. package/skills/pixi-effects/reference/pitfalls.md +7 -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-HFEVIYHN.js +0 -7
  79. package/dist/Composition-NI74UJT3.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-AK54FIWM.js.map +0 -1
  86. package/dist/chunk-MZCDBRTY.cjs.map +0 -1
  87. package/dist/chunk-P4GY3Z4G.cjs.map +0 -1
  88. package/dist/chunk-SBOVUZZY.cjs.map +0 -1
  89. package/dist/chunk-TYWFKCPY.js.map +0 -1
  90. package/dist/chunk-WEJAQAFX.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.1. 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
 
@@ -17,17 +17,17 @@ You describe a video as a tree of plain objects; the library plays it in a canva
17
17
  1. **Start from `template.html`** (copy it; it also has the shared loader (`<div class="pe-loader" data-label="LOADING">` + `pixi-effects/loader.css`: HTML and CSS only, animating just `transform` / `opacity`, so it keeps moving while the page builds the video; `movie.init()` fades it out). Keep it; a loader of your own is fine (`pe-loader--custom`) as long as it only animates `transform` / `opacity`, never with JavaScript or `requestAnimationFrame`; it loads the library from a CDN, collects every console warning into `window.__logs`, and exposes `window.movie`). One HTML file, plain JS. Default canvas 1280×720 @ 30 fps.
18
18
  2. **Give the movie a poster time.** In `movie.init` add `poster: <seconds>`: the most striking moment (a title fully on screen, not the first frame, which is often black). The canvas shows that picture before play (the playhead stays at 0 and play starts from 0), and the check writes it as `poster.jpg`.
19
19
  3. **Write the composition as data.** Anything repeated (letters, bars, particles, scenes) is a JS function or loop that returns specs — never copy-paste objects. Derive coordinates and times from constants/data, not magic numbers.
20
- 4. **Show the human:** `npx pixi-effects view my-video.html` opens their browser on the page with a scrubbable, zoomable timeline under it (click / drag to seek, + / − or Ctrl/⌘ + wheel to zoom); it keeps running until Ctrl-C. **Deliver the file:** `npx pixi-effects render my-video.html -o my-video.mp4` renders the page headless to mp4 / webm / mov / mkv and exits 0 only when the file is written; run it AFTER the check below passes. **Run the check — one command does steps 3, 4 and the export:** `npx pixi-effects check my-video.html` (Node ≥ 22 and Chrome installed). **If `npx` says command not found, or prints nothing** (your npm may hold back a release that is a few days old, or an old release is cached): `npm i pixi-effects`, then `node node_modules/pixi-effects/ai/tools/check.mjs my-video.html` (the same for `render.mjs` and `view.mjs`). It starts its own private Chrome (nothing is shared with other sessions), waits for `window.__ready`, and writes `timeline.html` (every layer as a bar on a time axis: ◆ keyframes, shaded transitions; hand it to the human to open) and prints: console warnings, `movie.inspect` over the WHOLE timeline (grouped), `movie.inspectAudio()`, then exports an mp4 and decodes it again (video size and length, audio length, loudness per second, silence). It writes `check-out/<name>/sheet.png` — **open that picture and look at it** — plus the mp4 and `report.json`. Exit 0 = nothing to fix; exit 1 = a list of problems; fix them and run it again. A key a layer does not have (a typo in a layer key, a keyframe property or a text style key; `anchorX` outside `initial`; `pitch` outside `sfx`) is a warning that names the key and what you probably meant: treat it like any other warning. Text that overlaps text is listed under `review` (not a failure: ghost layers, glow copies and per-letter boxes are often intentional; look at the sheet; `--strict` makes them fail). The manual steps below are for looking at one frame closely. The same judgement is in the page: `await movie.review({ at: 'title@end' })` returns `{ frames, problems, review, fonts, audio, at }` (what `check` prints, as data) and `movie.resolveAt('3.5, title@end')` turns moments into frames.
20
+ 4. **Show the human, EARLY: the first address goes out within two minutes.** Do it right after step 1, BEFORE you write the brief or collect assets: the copied template is already a page. Run `npx pixi-effects view my-video.html` (`--no-open` only prints the address; in the background, it keeps running), tell the human the address in your first message, and keep editing the same file: it opens their browser on the page with a scrubbable, zoomable timeline under it (click / drag to seek, + / − or Ctrl/⌘ + wheel to zoom) and runs until Ctrl-C. When the folder changes the viewer shows "Updated hh:mm:ss — reload (R)" and nothing else: the picture they are looking at never switches by itself, a reload returns to the frame they were on, and the switch "auto" (off by default) reloads only while the movie is paused. Do not restart it after every edit; they see the work grow instead of waiting for the end. **Deliver the file:** `npx pixi-effects render my-video.html -o my-video.mp4` renders the page headless to mp4 / webm / mov / mkv and exits 0 only when the file is written; run it AFTER the check below passes. **Run the check — one command does steps 3, 4 and the export:** `npx pixi-effects check my-video.html` (Node ≥ 22 and Chrome installed). **If `npx` says command not found, or prints nothing** (your npm may hold back a release that is a few days old, or an old release is cached): `npm i pixi-effects`, then `node node_modules/pixi-effects/ai/tools/check.mjs my-video.html` (the same for `render.mjs` and `view.mjs`). It starts its own private Chrome (nothing is shared with other sessions), waits for `window.__ready`, and writes `timeline.html` (every layer as a bar on a time axis: ◆ keyframes, shaded transitions; hand it to the human to open) and prints: console warnings, `movie.inspect` over the WHOLE timeline (grouped), `movie.inspectAudio()`, then exports an mp4 and decodes it again (video size and length, audio length, loudness per second, silence). It writes `check-out/<name>/sheet.png` — **open that picture and look at it** — plus the mp4 and `report.json`. Exit 0 = nothing to fix; exit 1 = a list of problems; fix them and run it again. A key a layer does not have (a typo in a layer key, a keyframe property or a text style key; `anchorX` outside `initial`; `pitch` outside `sfx`) is a warning that names the key and what you probably meant: treat it like any other warning. Text that overlaps text is listed under `review` (not a failure: ghost layers, glow copies and per-letter boxes are often intentional; look at the sheet; `--strict` makes them fail). The manual steps below are for looking at one frame closely. The same judgement is in the page: `await movie.review({ at: 'title@end' })` returns `{ frames, problems, review, fonts, audio, at }` (what `check` prints, as data) and `movie.resolveAt('3.5, title@end')` turns moments into frames.
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
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).
@@ -392,6 +392,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
392
392
  100. **Read the score back before you trust it.** `check` prints a `music` block: every track's length in bars (a track that ends early or has a short bar is named), the names that start in each bar with their beat (`Am(2)` starts on beat 2; no mark = the downbeat; the first four names of a bar are shown, `musicEvents()` lists everything), and the drum span. Count bars by the read-back, not by hand: a bar of 3 beats in a score with no `meter` is shown as bars of 4, so set `meter: 3` for a waltz. Drums repeat by themselves and `from` / `to` are beats; `swing` only moves notes that start off the beat, so a comp on beats 2 and 4 stays straight.
393
393
  101. **A film built from one adjective looks like every other one.** Read `reference/direction.md` and write `brief.md` / `style-guide.md` / `shotlist.md` first: real assets with paths, the one sentence to remember, a row per beat with entry and exit state. If a real asset is missing, ask: a drawn fake (a logo, a dashboard, a figure) is the failure that matters. Judge the contact sheet cold: three defects, each with a timestamp, evidence and a local fix.
394
394
  102. **A vertical cut is a second composition, not a crop, and only when someone asked for it.** One source, a layout function, `?f=9x16`; `check` and `render` take `--query f=9x16` (recipe `two-formats`). Look at a contact sheet of each format: type that fits wide is tiny tall. Ask which formats are wanted; make one if the answer is open. In a 1080p film keep every line of text at 34 px or more; if you cannot read it in a sheet tile, a phone cannot.
395
+ 103. **Load a web font BEFORE you measure with it.** `measureText` / `splitText` / `animateText` measure once, when the composition is built. Measured in Chrome: "Respect the Local, Loop the Value." at 80 px Lato 900 is 1487 px wide before the Google font has arrived (the fallback) and 1231 px after; Japanese text in a similar fallback was within 3 px. Every box, line break and centred position built from the wrong width stays wrong. `await document.fonts.load('900 80px "Lato"', theTextYouUse)` first (the text argument loads exactly the glyphs you need), then build the composition.
395
396
 
396
397
 
397
398
  <!-- ===== skills/pixi-effects/reference/recipes.md ===== -->
@@ -1056,8 +1057,8 @@ return { duration: d.duration, sequences: d.composition.sequences, transitions:
1056
1057
  ```js
1057
1058
  // @docs-only
1058
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
1059
- import { Movie, deck } from 'pixi-effects';
1060
- 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)
1061
1062
  const movie = new Movie();
1062
1063
  await movie.init({ canvas, width: 1280, height: 720, frameRate: 30, ...deck({ /* as above */ }) });
1063
1064
  new Presenter(movie, { canvas }); // arrows / Space / Enter / click / swipe = next, ← = back, B = black screen, F = fullscreen, ? = keys
@@ -1464,7 +1465,7 @@ three({
1464
1465
 
1465
1466
  ## Subtitles from an SRT file
1466
1467
 
1467
- 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.
1468
1469
 
1469
1470
  ```js
1470
1471
  // @recipe subtitles
@@ -1528,7 +1529,7 @@ const scene = { type: 'composition', name: 'hook', at: 0, duration: 5.5, width:
1528
1529
 
1529
1530
  ## A batch of videos from a table (one video per row)
1530
1531
 
1531
- 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.
1532
1533
 
1533
1534
  ```js
1534
1535
  // @docs-only batch-page
@@ -1559,6 +1560,8 @@ done < table.csv
1559
1560
 
1560
1561
  # Directing a film (for any AI that makes video with code)
1561
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
+
1562
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.
1563
1566
 
1564
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.
@@ -1656,7 +1659,7 @@ Many AI films look finished with the sound off; then the click lands after the b
1656
1659
 
1657
1660
  ## Review the rendered frames, not your intent
1658
1661
 
1659
- Do not start by watching a full export ten times, and do not describe what you meant to make. Ask the renderer for a contact sheet: one frame at every beat, a short strip around the fastest transitions, and a frame from the middle of each cut taken from the exported file. Look cold, then write the **three largest defects**, each with a **timestamp**, the **evidence** (what you see) and a **local fix**; patch only those, render those frames again. Ask for a second pair of eyes on the sheet whenever one is available: the author reads their own intent into it.
1662
+ Do not start by watching a full export ten times, and do not describe what you meant to make. Ask the renderer for a contact sheet: one frame at every beat, a short strip around the fastest transitions, and a frame from the middle of each cut taken from the exported file. Look cold, then write the **three largest defects** into `reviews/notes.md`, each with a **timestamp**, the **evidence** (what you see) and a **local fix**; patch only those, render those frames again, and note the result. A defect you fixed but did not write down is lost for the next film. Two things are easy to miss on a sheet, so do them on purpose: **measure the smallest text** on screen (its pixel size scaled to a 1080p frame, not the look of it), and look at **both ends of every cut and at every state a card passes through** (an empty frame that holds content a beat later is a defect). Ask for a second pair of eyes on the sheet whenever one is available: the author reads their own intent into it.
1660
1663
 
1661
1664
  ```
1662
1665
  [ ] text readable at phone size (about 34 px or more on a 1080p frame)
@@ -1686,7 +1689,7 @@ Long runs need places to stop. With a human present:
1686
1689
  5. repair the three highest-impact defects
1687
1690
  6. check sound, formats and deliverables
1688
1691
 
1689
- Between gates the agent may work for hours; it never makes an irreversible brand decision (logo, claim, number) for the human, and a missing real asset is a blocker, not an invitation to invent one. If the reference is unclear, the right output is a question or one small test frame, not nine hundred polished wrong ones. With nobody to ask, record each decision you took as a "ruling" in the review notes, with what it costs if wrong, and finish with a short note of what still needs a human.
1692
+ Show the work early: serve the page the moment it exists (a copied template is enough, before the brief is written) and give the human the address in your first message; every later save then shows up as a notice they can act on when they choose. A first draft of the opening scene, served where the human can open it, is worth more than a perfect film at the end; a wrong direction is cheap to fix in the first minutes. Between gates the agent may work for hours; it never makes an irreversible brand decision (logo, claim, number) for the human, and a missing real asset is a blocker, not an invitation to invent one. If the reference is unclear, the right output is a question or one small test frame, not nine hundred polished wrong ones. With nobody to ask, record each decision you took as a "ruling" in the review notes, with what it costs if wrong, and finish with a short note of what still needs a human.
1690
1693
 
1691
1694
  ## The loop
1692
1695
 
@@ -2491,31 +2494,33 @@ The mask is itself a sequence, so it can have `keyframes` of its own — useful
2491
2494
  Notes:
2492
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.
2493
2496
  - Any sequence type works as a mask (shape / image / text / video / nested composition); shapes are the natural choice for geometric reveals.
2494
- - 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)).
2495
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.
2496
2499
 
2497
- #### `maskInverted`
2500
+ #### An inverted mask: `mask: { layer, invert: true }`
2498
2501
 
2499
- 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.
2500
2503
 
2501
2504
  ```ts
2502
2505
  // Photo with a circular hole punched through the middle
2503
2506
  {
2504
2507
  type: 'image', asset: 'photo',
2505
2508
  initial: { x: 'W/2', y: 'H/2', anchorX: 0.5, anchorY: 0.5 },
2506
- maskInverted: true,
2507
2509
  mask: {
2508
- type: 'shape', shape: 'circle', radius: 80,
2509
- initial: { x: 'W/2', y: 'H/2', fillColor: '#ffffff' },
2510
- keyframes: [
2511
- { at: 0, to: { radius: 120 }, duration: 1.5, ease: 'sine.inOut' },
2512
- { at: 1.5, to: { radius: 80 }, duration: 1.5, ease: 'sine.inOut' },
2513
- ],
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
+ },
2514
2519
  },
2515
2520
  }
2516
2521
  ```
2517
2522
 
2518
- 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.)
2519
2524
 
2520
2525
  #### Named mattes and luma
2521
2526
 
@@ -2820,7 +2825,7 @@ initial: { x: 'GW/2', y: 'GH * 0.96', anchorX: 0.5, anchorY: 1 }
2820
2825
 
2821
2826
  ## Stops (presentations)
2822
2827
 
2823
- `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.
2824
2829
 
2825
2830
  ## Transitions
2826
2831
 
@@ -3301,10 +3306,10 @@ See [the API reference](api.md#audio-reactive-audioenvelope-react-bpmenvelope) f
3301
3306
 
3302
3307
  ### `deck`
3303
3308
 
3304
- 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.
3305
3310
 
3306
3311
  ```ts
3307
- import { deck } from 'pixi-effects';
3312
+ import { deck } from 'pixi-effects/present';
3308
3313
 
3309
3314
  await movie.init({ canvas, width: 1280, height: 720, frameRate: 30, ...deck({
3310
3315
  transition: { kind: 'slide', direction: 'left', duration: 0.6, ease: 'power3.inOut' },
@@ -3376,7 +3381,6 @@ The added keyframes are layered on top of any keyframes the spec already has. `w
3376
3381
 
3377
3382
  - [`Movie`](#movie) — composition runtime: init, playback, render
3378
3383
  - [`Controller`](#controller) — drop-in player UI overlay
3379
- - [Helpers](#helpers) — pure utilities exported from `pixi-effects/controller`
3380
3384
  - [`pixi-effects/three`](#pixi-effectsthree) — optional three.js integration
3381
3385
 
3382
3386
  For DSL types (composition spec, sequences, filters, keyframes, expressions), see [DSL reference](./dsl.md).
@@ -3445,13 +3449,21 @@ Seeks to a specific frame. Pauses if currently playing? **No** — does not chan
3445
3449
 
3446
3450
  `force=true` skips the early-return when the requested frame equals `currentFrame`. Use it after a composition rebuild.
3447
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
+
3448
3460
  ### `movie.snapshot(frame?, options?): Promise<Blob | string>`
3449
3461
 
3450
3462
  ```ts
3451
- 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>
3452
3464
  ```
3453
3465
 
3454
- 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.
3455
3467
 
3456
3468
  ### `movie.contactSheet(options?): Promise<Blob | string>`
3457
3469
 
@@ -3548,10 +3560,10 @@ interface RenderOptions {
3548
3560
 
3549
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.
3550
3562
 
3551
- - 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.
3552
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.
3553
3565
  - After a blurred snapshot, contact sheet or render the playhead and the stage are on an exact frame again, not a sample.
3554
- - `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.
3555
3567
 
3556
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.
3557
3569
 
@@ -3577,13 +3589,13 @@ const blob = await movie.posterImage(); // the picture at that time
3577
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**.
3578
3590
  - `posterImage()` leaves the movie as it was: still showing the poster, or back at the frame it was at. `contactSheet()` does the same.
3579
3591
  - A time past the end is clamped to the last frame, and something that is not a number is ignored; both warn.
3580
- - `pixi-effects-check` writes the poster as `poster.jpg`.
3592
+ - `pixi-effects check` writes the poster as `poster.jpg`.
3581
3593
 
3582
3594
  ### `movie.timelineData(): TimelineData` and `movie.timelineChart(options?): string`
3583
3595
 
3584
- 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`.
3585
3597
 
3586
- `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.
3587
3599
 
3588
3600
  ### Your own layer type: `Sequence` and `registerSequenceType(type, ctor)`
3589
3601
 
@@ -3626,7 +3638,6 @@ movie.off(event, fn): this
3626
3638
  | `'progress'`| `{ progress: number; frame: number; totalFrames: number }` | during `render()`, once per encoded frame |
3627
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 |
3628
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()` |
3629
- | `'stop'` | `{ index: number; stop: Stop; pageIndex: number }` | a presentation is on a stop: `next()` played to it, or `prev()` / `goToStop()` / `goToPage()` jumped to it |
3630
3641
  | `'ended'` | none | playback ran off the end (after `'pause'`); pausing by hand does not emit it |
3631
3642
  | `'volumechange'` | `{ volume: number; muted: boolean }` | `volume` or `muted` really changed (clamping counts) |
3632
3643
  | `'error'` | `{ where: 'init' \| 'render' \| 'playback'; message: string; error: unknown }` | `init()` / `render()` failed (the call still rejects), or a frame failed during playback |
@@ -3650,28 +3661,32 @@ The names are the ones an HTML5 `<video>` uses, so a player written for `<video>
3650
3661
  | `muted` | boolean | getter/setter; immediate |
3651
3662
  | `app` | `pixi.js Application \| null` | PIXI Application instance (advanced/escape hatch) |
3652
3663
  | `timeline` | `Timeline \| null` | the movie's timeline (advanced): `time(t)`, `duration()`, `segments()` |
3653
- | `stops` | `Stop[]` | the composition's stops (see Presentations); empty for an ordinary movie |
3654
- | `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 |
3655
3665
 
3656
- ### Presentations: `stops`, `next()`, `prev()`, `Presenter`
3666
+ ### Presentations: `stops` and `pixi-effects/present`
3657
3667
 
3658
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.
3659
3669
 
3660
3670
  ```ts
3661
- movie.stops // Stop[]: { index, at, frame, page: string | null, pageIndex, pageStart, notes?, advance?, pdf? }
3662
- 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;
3663
3675
  // after the last stop play to the end. Resolves when it has landed (or stopped for another reason).
3664
- await movie.prev(); // jump back to the previous stop at once (no reverse playback); from the first stop, to the start
3665
- await movie.goToStop(i); // 0-based
3666
- await movie.goToPage(n); // 0-based: the first stop of page n
3667
- movie.stopIndex / movie.pageIndex / movie.pageCount / movie.currentStop
3668
- 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
3669
3681
  ```
3670
3682
 
3671
- `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:
3672
3687
 
3673
3688
  ```ts
3674
- import { Presenter } from 'pixi-effects/presenter';
3689
+ import { Presenter } from 'pixi-effects/present';
3675
3690
  const presenter = new Presenter(movie, { canvas }); // instead of Controller; await presenter.start() to go fullscreen and play to the first stop
3676
3691
  ```
3677
3692
 
@@ -3689,18 +3704,19 @@ const presenter = new Presenter(movie, { canvas }); // instead of Controller
3689
3704
 
3690
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.
3691
3706
 
3692
- `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).
3693
3708
 
3694
- ### `movie.inspectStops(options?)`
3709
+ ### `inspectStops(movie, options?)`
3695
3710
 
3696
- 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).
3697
3712
 
3698
- ### 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)`
3699
3714
 
3700
3715
  ```ts
3701
- 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
3702
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
3703
- 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)
3704
3720
  ```
3705
3721
 
3706
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).
@@ -3785,7 +3801,7 @@ el('play').onclick = () => (movie.isPlaying ? movie.pause() : movie.play());
3785
3801
  el('seek').oninput = e => movie.gotoFrame(+e.target.value);
3786
3802
  ```
3787
3803
 
3788
- 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.
3789
3805
 
3790
3806
  ## `Controller`
3791
3807
 
@@ -3815,9 +3831,9 @@ new Controller(movie: Movie, options: ControllerOptions)
3815
3831
 
3816
3832
  interface ControllerOptions {
3817
3833
  canvas: HTMLCanvasElement; // required
3818
- showExportButton?: boolean; // default true; hides ⬇ + popover
3819
- enableKeyboardShortcuts?: boolean; // default true
3820
- 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)
3821
3837
  pauseAtStops?: boolean; // default true: with stops, play / Space / click on the picture play to the next stop and pause; false plays straight through
3822
3838
  clickToPlay?: boolean; // default true: a click / tap on the picture plays / pauses (like <video>); false leaves the canvas alone
3823
3839
  className?: string; // default 'movie-controller'
@@ -3852,7 +3868,7 @@ controller.setTheme({ accent: null }); // null = back to the defau
3852
3868
  | `trackHeight` | `--mc-track-height` | thickness of the progress bar (a number is px) | `3px` |
3853
3869
  | `font` | `--mc-font` | `font-family` of the bar | system UI font |
3854
3870
 
3855
- 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.
3856
3872
 
3857
3873
  ### `controller.destroy(): void`
3858
3874
 
@@ -3878,7 +3894,7 @@ The download is triggered by an in-page `<a download>` click, so the file lands
3878
3894
 
3879
3895
  ### Keyboard shortcuts
3880
3896
 
3881
- 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.
3882
3898
 
3883
3899
  | Key | Action |
3884
3900
  | ----------- | --------------------------------------------------- |
@@ -3894,50 +3910,6 @@ Active when `enableKeyboardShortcuts: true` and the key target is not `<input>`/
3894
3910
 
3895
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.
3896
3912
 
3897
- ---
3898
-
3899
- ## Helpers
3900
-
3901
- 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.
3902
-
3903
- ```ts
3904
- import { formatTime, frameToPercent, pxToFrame, pxToFraction, extensionForMimeType }
3905
- from 'pixi-effects/controller';
3906
- ```
3907
-
3908
- ### `formatTime(seconds: number): string`
3909
-
3910
- Returns `M:SS` (no leading zero on minutes). `formatTime(125)` → `"2:05"`. Negatives clamp to zero.
3911
-
3912
- ### `frameToPercent(frame: number, totalFrames: number): number`
3913
-
3914
- Returns 0..100 (clamped). `totalFrames <= 0` returns 0.
3915
-
3916
- ### `pxToFrame(clientX, rect, totalFrames): number`
3917
-
3918
- Maps a pointer X coordinate (relative to viewport) inside a `DOMRect`-shaped object to a frame index 0..totalFrames. Rounded.
3919
-
3920
- ```ts
3921
- pxToFrame(150, { left: 100, width: 200 } as DOMRect, 100) // 25
3922
- ```
3923
-
3924
- ### `pxToFraction(clientX, rect, inset?): number`
3925
-
3926
- 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).
3927
-
3928
- ### `extensionForMimeType(mime: string): string`
3929
-
3930
- Maps common video MIME types to file extensions. Falls back to `'mp4'`.
3931
-
3932
- | MIME contains | Extension |
3933
- | ------------------- | --------- |
3934
- | `webm` | `webm` |
3935
- | `quicktime` / `mov` | `mov` |
3936
- | `matroska` / `mkv` | `mkv` |
3937
- | anything else | `mp4` |
3938
-
3939
- ---
3940
-
3941
3913
  ## `pixi-effects/three`
3942
3914
 
3943
3915
  Optional three.js integration, imported from its own entry so the core `pixi-effects` entry never touches three.js: