pixi-effects 0.18.1 → 0.19.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 (52) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +6 -6
  3. package/ai/CHAT.md +1 -1
  4. package/ai/SKILL.md +1 -1
  5. package/ai/chat-template.html +4 -4
  6. package/ai/reference/cheatsheet.md +4 -4
  7. package/ai/reference/pitfalls.md +4 -0
  8. package/ai/reference/recipes.md +65 -0
  9. package/ai/template.html +5 -5
  10. package/ai/tools/check.mjs +14 -3
  11. package/dist/{Base-BQUodYZK.d.ts → Base-B4WbUJO2.d.ts} +1 -1
  12. package/dist/{Base-OHqp8qvX.d.cts → Base-i88XXaiP.d.cts} +1 -1
  13. package/dist/Composition-KYNPRWK3.cjs +14 -0
  14. package/dist/{Composition-6JMXWNVV.cjs.map → Composition-KYNPRWK3.cjs.map} +1 -1
  15. package/dist/Composition-QBKEEJBV.js +5 -0
  16. package/dist/{Composition-MLBHYYFF.js.map → Composition-QBKEEJBV.js.map} +1 -1
  17. package/dist/Controller.d.cts +2 -2
  18. package/dist/Controller.d.ts +2 -2
  19. package/dist/{Movie-DlHQ9C02.d.ts → Movie-8IrqL3kO.d.ts} +6 -1
  20. package/dist/{Movie-K2YuVAdg.d.cts → Movie-CpmdNadg.d.cts} +6 -1
  21. package/dist/Presenter.d.cts +2 -2
  22. package/dist/Presenter.d.ts +2 -2
  23. package/dist/{chunk-UXDR3ZDU.js → chunk-B3YB2TNY.js} +4 -3
  24. package/dist/chunk-B3YB2TNY.js.map +1 -0
  25. package/dist/{chunk-JW67MDHQ.cjs → chunk-CTZRFSVU.cjs} +27 -26
  26. package/dist/chunk-CTZRFSVU.cjs.map +1 -0
  27. package/dist/{chunk-ANO5R2E6.cjs → chunk-VTBT6NNE.cjs} +755 -48
  28. package/dist/chunk-VTBT6NNE.cjs.map +1 -0
  29. package/dist/{chunk-SG3Y66FN.js → chunk-YRXTKOV5.js} +757 -51
  30. package/dist/chunk-YRXTKOV5.js.map +1 -0
  31. package/dist/index.cjs +63 -36
  32. package/dist/index.cjs.map +1 -1
  33. package/dist/index.d.cts +4 -4
  34. package/dist/index.d.ts +4 -4
  35. package/dist/index.js +40 -13
  36. package/dist/index.js.map +1 -1
  37. package/dist/loader.css +1 -1
  38. package/dist/three.cjs +5 -5
  39. package/dist/three.d.cts +2 -2
  40. package/dist/three.d.ts +2 -2
  41. package/dist/three.js +1 -1
  42. package/dist/{types-SS51lP6O.d.cts → types-bzLJ7aUx.d.cts} +16 -6
  43. package/dist/{types-SS51lP6O.d.ts → types-bzLJ7aUx.d.ts} +16 -6
  44. package/docs/dsl.md +36 -5
  45. package/llms-full.txt +110 -10
  46. package/package.json +1 -1
  47. package/dist/Composition-6JMXWNVV.cjs +0 -14
  48. package/dist/Composition-MLBHYYFF.js +0 -5
  49. package/dist/chunk-ANO5R2E6.cjs.map +0 -1
  50. package/dist/chunk-JW67MDHQ.cjs.map +0 -1
  51. package/dist/chunk-SG3Y66FN.js.map +0 -1
  52. package/dist/chunk-UXDR3ZDU.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0
4
+
5
+ **Added**
6
+
7
+ - **Spring easing.** `ease: 'spring(mass, stiffness, damping)'` (defaults 1, 100, 10) or a preset (`spring.gentle`, `snappy`, `bouncy`, `wobbly`, `slow`), and `duration: 'auto'` = the time the spring takes to settle (within 0.5 %). Closed form (no integration, no dependency), so a seek in either direction gives the same value; checked on a real browser: every frame is identical forwards, by jumps and backwards, and a spring slider overshoots its target and rests on it. The presets settle in 0.72 s, 0.42 s, 1.08 s, 1.61 s and 1.29 s. A malformed spring (`spring(1, 170`, `spring.floppy`) is reported once with what is wrong; `'auto'` without a spring warns once; a `stagger` ease may be a spring (its delays are held inside the spread). `spring(1,170,26)` is critically damped and does not bounce (damping ratio 1): use `spring(1, 170, 12)` or a preset.
8
+
9
+ - **`fillGradient` can be animated, on shapes and on text.** A keyframe's `set` / `from` / `to` takes a partial gradient (`angle`, `center`, `innerRadius`, `radius`, `stops`; what is left out stays); stop colours move through the layer's `colorSpace` (green to magenta is grey in the middle in `rgb` and vivid in `oklch`, measured on a rendered frame). A text layer takes `fillGradient` for its letters (linear only: a radial type, centre or radius is ignored with one warning). One canvas and one texture per animated gradient are repainted in place and given back when the layer is destroyed (50 animated gradients make 50 painters, and not one more after 40 seeks). Mistakes (another number of stops, a mistyped key with a did-you-mean, no starting gradient, `'fillGradient.angle'` / `gradientAngle`, a `type` change, a radial gradient on text) are said once, when the layer is built, not at every seek. Every frame is the same forwards, by jumps and backwards, also under motion blur. Cost, 1080p, headless Chrome on an M1 Pro with its GPU, including a small snapshot read-back: 50 animated gradient rectangles 10.6 ms per frame against 2.8 ms for 50 animated flat colours and 2.4 ms for 50 static gradients; 10 animated gradient texts 10.1 ms against 6.1 ms for 10 solid ones (software GL, `--use-angle=swiftshader`: 85 ms against 39 ms for the 50 rectangles). The static `fillGradient` path is unchanged: moving it onto the painter changed 0.1–0.2 % of the channels by more than 2/255 (up to 35, at the rounded edges), so the two paths stay.
10
+
11
+ - **`grain`: film grain as a named filter** (`{ type: 'grain', amount, size, seed, fps, color }`, no extra package; on a layer or on `composition.filters`). The existing `noise` is not film grain (frozen pattern, random seed, same strength at every tone, lifts blacks by about 5/255). `grain` is seeded and new every 1/`fps` s (24), weighted to the mid-tones (4L(1−L): blacks and whites are untouched, the mean does not move: under 0.7/255 on mid and dark greys), and uses an integer hash, so the same frame is the same picture reached forwards, by jumps or backwards (also in a motion-blurred frame and in a layer inside a `threeD` card: the grain gets the time of the frame being drawn, not the playhead's), and WebGL and WebGPU draw the same pattern, their 8-bit values agreeing to within 1/255 (0.02 % of the values differ by 1). Measured on a real browser: `amount` 0.08 gives a standard deviation of 20.4 of 255 (0.02 gives 5.1) whatever the `size`; another seed is uncorrelated (|r| < 0.05); under motion blur the sub-frames share one grain frame, so the grain does not average away; a keyframe on `filters.g.amount` moves it. The movie feeds each time filter the moment it draws (a list collected once after the build, not a walk of the stage). Cost at 1080p on a GPU (headless Chrome, M1 Pro, with a small snapshot read-back): 2.2 ms a frame without a filter, 2.8 ms with mono grain, 4.5 ms with `color: 1`, 2.5 ms with `noise`. **Grain costs bitrate:** with the default export settings 3 s of flat 1280×720 grew from 6.9 KB to 9.3 MB (`amount` 0.03) and 22.3 MB (0.08).
12
+
13
+ - **Keys that do not exist are warned about.** A layer key the layer does not have (`bogus: 1`, `duraton`, `anchorX` on a text layer, `radius` on a rect, `pitch` on an `sfx` layer), a property in `initial` / `set` / `to` / `from` that is not animatable (`alhpa`), and a text `style` key Pixi does not know (`fontSzie`) used to be ignored without a word; each is now one warning per layer and key, with what was probably meant ("did you mean …", "it belongs to a circle shape"). Never an error. The key tables are checked against the spec types by the compiler (a key added to a type and not to its table fails `tsc`, a table name the type does not have fails too), so the warning cannot call a real key unknown; dotted paths (`filters.g.amount`, `three.box.x`) belong to their routers and a layer kind registered elsewhere (`three`) is not checked. **The condition for shipping it was a test that runs all 39 gallery pieces and the 14 numbered examples with no warning at all, and that test found three real mistakes:** `hanabi-night`, `orbit-rig` and `shape-shift` wrote `pitch` on the layer (`{ type: 'audio', sfx: 'click', pitch: 6 }`), where it was ignored, so their sound effects were never pitched; the knob is `sfx: { preset: 'click', pitch: 6 }`. The three pieces are fixed (their sound changes: the pitches the authors wrote now apply). Bad expressions (`fontSize: 'nope'`) were already warned about, at build, with the expression; they are left as they were.
14
+
15
+ - **Two gallery pieces for the new features** (41 now): `spring-marquee` ("Now Showing": a cinema marquee whose bulbs snap, letters bounce and ticket wobbles on four spring presets, under one `grain`) and `gradient-type` ("Spectrum": a word whose four-stop gradient turns and shifts through OKLCH over four chained keyframes, over two drifting radial glows). Their authors' stumbles went into the docs: the settle time and overshoot of each spring preset (a table in the cheatsheet, the DSL reference and the motion guide), a radial gradient's `center` / `radius` are fractions of the shape and not of the canvas, `music.reverb` is 0–0.6, a pitched sfx keeps its length.
16
+
17
+ **Fixed**
18
+
19
+ - A numeric colour (`fillColor: 0xff0000`) tweened in the default `rgb` colour space was interpolated as a number: red to green passed through `0x7fff80` (a bright green) instead of olive. It is now interpolated as a colour, like the CSS strings.
20
+
3
21
  ## 0.18.1
4
22
 
5
23
  **Fixed**
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.18.1`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `0.18.1` makes `animateText` warn when `in` / `out` is given a time (an `out` has no `at`: it leaves from the end of the layer), `0.18.0` adds a rebuilt Playground (an editor on the chat template's edit block, a sandboxed run, a problems panel, share links, save as HTML), ten WebMCP tools for an AI agent in the browser, `movie.review()` and `movie.resolveAt()` (and `check` needs a page with 0.18 or newer), `0.17.0` adds part-of-the-movie and draft exports (`range`, `scale`, `draft`), a stronger `check` (`--at`, `--onion`, fonts, loudness and a waveform), `movie.onionSkin()`, a warning for a mistyped `ease`, two cookbook recipes, and fixes text masks and inverted masks, `0.16.3` fixes the export progress bar disappearing while the mouse is still and updates the landing page's speed figures, `0.16.2` adds a landing page, `AGENTS.md` and a one-sentence start for AI, camera flights (`cameraPath`, shake offsets), `trimEach`, the showpiece `ma` and a fix for 3D cards after a seek, `0.16.1` adds `musicBuffer()` and a music lab page with eight tunes, `0.16.0` adds music written as text (`{ type: 'audio', music }`, `musicEnvelope()`), `0.15.0` adds a way in for an AI in a browser chat (`ai/CHAT.md` and `ai/chat-template.html`), `0.14.0` adds `movie.inspectStops()` (and a check review) for stops that land mid-animation, `0.13.1` fixes decks starting past their first stop in the guide and gallery and draws the stop marks as dots, `0.13.0` makes the player bar play a deck stop by stop (`pauseAtStops`), marks the stops more clearly, adds PDF to the download panel and a `pdf` flag on stops, `0.12.0` adds presentations (`stops`, `Presenter`, `deck()`, page overview, presenter view, PDF export) and audio-reactive visuals (`audioEnvelope()`, `react()`), `0.11.0` adds particles, motion blur, a shared loader and a much faster build for pieces with many layers, `0.10.0` adds seeded randomness, `wiggle` and `stagger`, null layers with `parent`, `animateText`, `followPath` and path morphing, and click-to-play in the player, `0.9.0` adds a poster time (`movie.init({ poster })`) and a guide site, `0.8.0` adds events and a theme for building your own player, `0.7.0` adds filters by name, the `pixi-effects-render` and `pixi-effects-view` commands and the timeline chart, `0.6.0` adds draw-on strokes (`trimEnd`) and text that changes over time, `0.5.0` adds synthesised sound effects (`sfx`, no files) and the `check` tool, `0.4.0` makes a mask share its layer's lifetime, `0.3.0` made keyframe `at` relative to the layer).
3
+ > **Status**: experimental — current release `0.19.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `0.19.0` adds spring easing (`ease: 'spring.bouncy'`, `duration: 'auto'`), animated `fillGradient` on shapes and text, a `grain` film-grain filter, warnings for keys that do not exist (they used to be ignored in silence) and two gallery pieces, `0.18.1` makes `animateText` warn when `in` / `out` is given a time, `0.18.0` adds a rebuilt Playground (an editor on the chat template's edit block, a sandboxed run, a problems panel, share links, save as HTML), ten WebMCP tools for an AI agent in the browser, `movie.review()` and `movie.resolveAt()` (and `check` needs a page with 0.18 or newer), `0.17.0` adds part-of-the-movie and draft exports (`range`, `scale`, `draft`), a stronger `check` (`--at`, `--onion`, fonts, loudness and a waveform), `movie.onionSkin()`, a warning for a mistyped `ease`, two cookbook recipes, and fixes text masks and inverted masks, `0.16.3` fixes the export progress bar disappearing while the mouse is still and updates the landing page's speed figures, `0.16.2` adds a landing page, `AGENTS.md` and a one-sentence start for AI, camera flights (`cameraPath`, shake offsets), `trimEach`, the showpiece `ma` and a fix for 3D cards after a seek, `0.16.1` adds `musicBuffer()` and a music lab page with eight tunes, `0.16.0` adds music written as text (`{ type: 'audio', music }`, `musicEnvelope()`), `0.15.0` adds a way in for an AI in a browser chat (`ai/CHAT.md` and `ai/chat-template.html`), `0.14.0` adds `movie.inspectStops()` (and a check review) for stops that land mid-animation, `0.13.1` fixes decks starting past their first stop in the guide and gallery and draws the stop marks as dots, `0.13.0` makes the player bar play a deck stop by stop (`pauseAtStops`), marks the stops more clearly, adds PDF to the download panel and a `pdf` flag on stops, `0.12.0` adds presentations (`stops`, `Presenter`, `deck()`, page overview, presenter view, PDF export) and audio-reactive visuals (`audioEnvelope()`, `react()`), `0.11.0` adds particles, motion blur, a shared loader and a much faster build for pieces with many layers, `0.10.0` adds seeded randomness, `wiggle` and `stagger`, null layers with `parent`, `animateText`, `followPath` and path morphing, and click-to-play in the player, `0.9.0` adds a poster time (`movie.init({ poster })`) and a guide site, `0.8.0` adds events and a theme for building your own player, `0.7.0` adds filters by name, the `pixi-effects-render` and `pixi-effects-view` commands and the timeline chart, `0.6.0` adds draw-on strokes (`trimEnd`) and text that changes over time, `0.5.0` adds synthesised sound effects (`sfx`, no files) and the `check` tool, `0.4.0` makes a mask share its layer's lifetime, `0.3.0` made keyframe `at` relative to the layer).
4
4
 
5
- **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 39 portfolio pieces written as plain data by AI models · **[Examples →](https://yjmtmtk.github.io/pixi-effects/examples/)** · 15 numbered examples, a music lab + an in-browser playground.
5
+ **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 41 portfolio pieces written as plain data by AI models · **[Examples →](https://yjmtmtk.github.io/pixi-effects/examples/)** · 15 numbered examples, a music lab + an in-browser playground.
6
6
 
7
7
  ## Make a video by asking an AI
8
8
 
@@ -51,8 +51,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
51
51
  "gsap": "https://esm.sh/gsap@3.12.5",
52
52
  "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
53
53
  "mediabunny": "https://esm.sh/mediabunny",
54
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.18.1/dist/index.js",
55
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.18.1/dist/Controller.js"
54
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/index.js",
55
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/Controller.js"
56
56
  }
57
57
  }
58
58
  </script>
@@ -63,7 +63,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
63
63
  </script>
64
64
  ```
65
65
 
66
- Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.18.1/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
66
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.19.0/dist/index.js`) rather than a CDN-rebundled build such as `esm.sh/pixi-effects` or jsDelivr's `+esm`: the entries (`pixi-effects`, `…/controller`, `…/three`) share internal chunks, which only works when each file is served untouched.
67
67
 
68
68
  > **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
69
69
  >
@@ -79,7 +79,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
79
79
  "imports": {
80
80
  "...": "(everything from the importmap above)",
81
81
  "three": "https://esm.sh/three@0.178.0",
82
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.18.1/dist/three.js"
82
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/three.js"
83
83
  }
84
84
  }
85
85
  </script>
package/ai/CHAT.md CHANGED
@@ -62,7 +62,7 @@ That is a complete, working video (the block between the two markers). Change th
62
62
  - **Repeat with code, never by copying:** `Array.from({ length: 12 }, (_, i) => ({ type: 'shape', shape: 'circle', radius: 20, at: i * 0.15, duration: DURATION - i * 0.15, initial: { x: 120 + i * 90, y: 360, fillColor: '#ffd166' }, keyframes: [{ at: 0, from: { scale: 0 }, to: { scale: 1 }, duration: 0.4, ease: 'back.out(2)' }] }))` returns twelve layers: spread them into `sequences` with `...`.
63
63
  - **Helpers already imported** in the template: `kenBurns` (slow zoom for an image layer), `withFade`, `wiggle` (seeded shake as keyframes), `stagger`, `animateText` (per letter / word entrances), `followPath`, `particles` (seeded confetti / snow / sparks as layers), `deck` (slides). Each returns keyframes or layers: `keyframes: wiggle(...)`, `...particles(...)`. The full signatures are in `https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/reference/cheatsheet.md` (22 KB; read it only if you need a helper).
64
64
  - **Advanced:** `const INIT = { assets: [...], composition: { transitions: [...] } }` in the edit block adds `movie.init` options (the page merges it); most videos need nothing here.
65
- - **Not available on this page:** named filters beyond `blur`, `noise`, `alpha`, `colorMatrix` (the other 38 need the `pixi-filters` package), three.js layers, your own image / video / music files. Say so rather than guessing.
65
+ - **Not available on this page:** named filters beyond `blur`, `noise`, `alpha`, `colorMatrix`, `grain` (the other 38 need the `pixi-filters` package; film grain is `{ type: 'grain' }`), three.js layers, your own image / video / music files. Say so rather than guessing.
66
66
 
67
67
  ## Music with no file
68
68
 
package/ai/SKILL.md CHANGED
@@ -12,7 +12,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
12
12
  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.
13
13
  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`.
14
14
  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.
15
- 4. **Show the human:** `node ai/tools/view.mjs 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) (`npx pixi-effects-view …` in an installed package); it keeps running until Ctrl-C. **Deliver the file:** `node ai/tools/render.mjs my-video.html -o my-video.mp4` (`npx pixi-effects-render …` in an installed package) 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:** `node ai/tools/check.mjs my-video.html` (Node ≥ 22 and Chrome installed; no npm install; in an installed package it is `npx pixi-effects-check my-video.html`). It starts its own private Chrome (nothing is shared with other sessions), waits for `window.__ready`, and 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. 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.
15
+ 4. **Show the human:** `node ai/tools/view.mjs 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) (`npx pixi-effects-view …` in an installed package); it keeps running until Ctrl-C. **Deliver the file:** `node ai/tools/render.mjs my-video.html -o my-video.mp4` (`npx pixi-effects-render …` in an installed package) 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:** `node ai/tools/check.mjs my-video.html` (Node ≥ 22 and Chrome installed; no npm install; in an installed package it is `npx pixi-effects-check my-video.html`). It starts its own private Chrome (nothing is shared with other sessions), waits for `window.__ready`, and 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.
16
16
  **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.
17
17
  5. **Look at it** — you cannot judge motion from code. The library has the tools built in:
18
18
  - `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.
@@ -5,7 +5,7 @@
5
5
  - Edit ONLY the block between "EDIT FROM HERE" and "EDIT UNTIL HERE". Keep everything else.
6
6
  - Problems appear in a red box under the picture, with a Copy button: ask the person to paste that text back to you.
7
7
  - The player bar's download button (bottom right of the picture) saves the finished MP4. Sound starts after the first click.
8
- - Named filters other than blur / noise / alpha / colorMatrix need pixi-filters, which this page does not load.
8
+ - Named filters other than blur / noise / alpha / colorMatrix / grain need pixi-filters, which this page does not load.
9
9
  Reference: https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/CHAT.md
10
10
  -->
11
11
  <html lang="en">
@@ -61,9 +61,9 @@
61
61
  "gsap": "https://cdn.jsdelivr.net/npm/gsap@3.12.5/+esm",
62
62
  "gsap/PixiPlugin": "https://cdn.jsdelivr.net/npm/gsap@3.12.5/PixiPlugin/+esm",
63
63
  "mediabunny": "https://cdn.jsdelivr.net/npm/mediabunny@1.61.3/+esm",
64
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.18.1/dist/index.js",
65
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.18.1/dist/Controller.js",
66
- "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.18.1/dist/Presenter.js"
64
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/index.js",
65
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/Controller.js",
66
+ "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/Presenter.js"
67
67
  }
68
68
  }
69
69
  </script>
@@ -44,7 +44,7 @@ blendMode: 'normal' | 'add' | 'screen' | 'multiply' // add / screen brighten (
44
44
 
45
45
  Animatable props (`initial` / keyframes): `x y alpha rotation scale scaleX scaleY pivotX pivotY anchorX anchorY skewX skewY tint width height visible autoAlpha`, plus for shapes their style/geometry, for text `fill`, for audio `volume`, for 3D `z rotationX rotationY`, filter params as `'filters.<name>.<param>'`, three objects as `'three.<obj>.<path>'`.
46
46
 
47
- Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate to), `from` (animate from), `from`+`to`. `from` / `from+to` show their start value from the layer's start, so a delayed fade-in needs no `initial.alpha`. `repeat` (finite extra plays), `yoyo`, `repeatDelay` loop any of them (endless repeats are not allowed). `ease` = any GSAP ease (`'none'`, `'power2.out'`, `'expo.out'`, `'back.out(1.7)'`, `'elastic.out(1,0.5)'`, `'sine.inOut'`, `'bounce.out'`…). Default ease is linear.
47
+ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate to), `from` (animate from), `from`+`to`. `from` / `from+to` show their start value from the layer's start, so a delayed fade-in needs no `initial.alpha`. `repeat` (finite extra plays), `yoyo`, `repeatDelay` loop any of them (endless repeats are not allowed). `ease` = any GSAP ease (`'none'`, `'power2.out'`, `'expo.out'`, `'back.out(1.7)'`, `'elastic.out(1,0.5)'`, `'sine.inOut'`, `'bounce.out'`… and springs: `'spring(mass, stiffness, damping)'` or `'spring.gentle' | 'snappy' | 'bouncy' | 'wobbly' | 'slow'`; **`duration: 'auto'` = the time the spring takes to settle** (presets, settle time / highest value reached as a multiple of the target: `snappy` 0.42 s / 1.01, `gentle` 0.72 s / 1.02, `bouncy` 1.08 s / 1.28, `slow` 1.29 s / 1.00, `wobbly` 1.61 s / 1.40: time the next beat from these) (real physics; with a number `duration` is the settle time and only the damping ratio shapes it; a damping ratio of 1 or more never overshoots, so `spring(1,170,26)` does not bounce: use damping below 20 at stiffness 170; `'auto'` works on keyframes only, not in `animateText` / `orbit` tweens; a `stagger` ease may be a spring, its delays are held inside the spread). Default ease is linear.
48
48
 
49
49
  ## Layer types
50
50
 
@@ -56,7 +56,7 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
56
56
 
57
57
  **audio** — a file: `asset`, `loop`, `volume`; `volume` (top level, or in `initial` like every other layer's starting values); fade with volume keyframes (`{ at: -2, to: { volume: 0 }, duration: 2 }` fades the last 2 s). Omit `duration` for a one-shot clip: it lasts exactly as long as the clip. With `loop: true` and no `duration` it lasts until the composition ends. An explicit `duration` longer than the clip goes silent after the clip unless `loop: true`.
58
58
  **Or a sound effect with no file — `sfx`:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` is the whole thing. Presets, default length in s: `click` 0.04 · `pop` 0.12 · `swoosh` 0.5 (loudest 0.16 s in, moves left → right) · `swipe` 0.22 · `hit` 0.7 · `riser` 1 (loudest at its END: `at = hit − duration`) · `chime` 1.4 · `beep` 0.16 · `coin` 0.4 · `glitch` 0.35 · `typewriter` 0.06. Knobs: `sfx: { preset, pitch /* semitones ±24 */, brightness /* −1 dark … 1 bright */, seed /* another take */ }`. **The layer's `duration` IS the sound's length** (omit it; set it to stretch, e.g. a riser); `volume` is its level (1 = the preset's standard level, peaking at −12 … −18 dBFS by preset). No `loop`: one layer per hit (a JS loop). Custom sound: `sfx: { voices: [{ wave: 'noise' | 'sine' | 'square' | 'saw' | 'triangle', freq: 440 | [from, to], filter: { type: 'lowpass' | 'highpass' | 'bandpass', freq: [from, to] }, envelope: 'fall' | 'bell' | 'swell' | 'hold', pan: [-1, 1] }] }`. The export (mp4 / webm / mov) encodes this same mix (lossy AAC / Opus), on time within a few ms.
59
- **Or music with no file — `music`:** `{ type: 'audio', music: { bpm: 96, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4 Dm7:4 G7:4' }, { inst: 'bass', notes: 'c2:2 g1:2 …' }], drums: { kick: 'x...x...', snare: '....x...', hat: 'x.x.x.x.' } } }` — a tune written as text (notes `c4:2` in beats, chords `Am7` `C@4` `Am7/e`, `[c4 e4 g4]`, rest `_`, hold `~`, `!` accent; `|` ignored). Instruments `keys pluck pad bass sub lead bell musicbox`; drums `kick snare hat openhat clap rim tom crash shaker sleigh` as 16th-note step strings (`x` hit, `o` soft, `.` none; a list of `{ from, to, … }` for sections). Score options `bpm` (or `[[beat, bpm], …]` for a ritardando) `swing reverb humanize drumVol grid transpose tail seed`; track options `vol` (or `[[beat, level], …]` for a fade) `pan step strum legato tone reverb attack release ring transpose`. Lasts its notes + tail, ends with the movie, `loop: true` repeats; peaks −6 dBFS at `volume: 1`. `await musicEnvelope(music, { frameRate })` → an envelope for `react()`; `await musicBuffer(music)` → a Web Audio `AudioBuffer` to play yourself.
59
+ **Or music with no file — `music`:** `{ type: 'audio', music: { bpm: 96, tracks: [{ inst: 'keys', notes: 'Cmaj7:4 Am7:4 Dm7:4 G7:4' }, { inst: 'bass', notes: 'c2:2 g1:2 …' }], drums: { kick: 'x...x...', snare: '....x...', hat: 'x.x.x.x.' } } }` — a tune written as text (notes `c4:2` in beats, chords `Am7` `C@4` `Am7/e`, `[c4 e4 g4]`, rest `_`, hold `~`, `!` accent; `|` ignored). Instruments `keys pluck pad bass sub lead bell musicbox`; drums `kick snare hat openhat clap rim tom crash shaker sleigh` as 16th-note step strings (`x` hit, `o` soft, `.` none; a list of `{ from, to, … }` for sections). Score options `bpm` (or `[[beat, bpm], …]` for a ritardando) `swing reverb (0–0.6) humanize drumVol grid transpose tail seed`; track options `vol` (or `[[beat, level], …]` for a fade) `pan step strum legato tone reverb attack release ring transpose`. Lasts its notes + tail, ends with the movie, `loop: true` repeats; peaks −6 dBFS at `volume: 1`. `await musicEnvelope(music, { frameRate })` → an envelope for `react()`; `await musicBuffer(music)` → a Web Audio `AudioBuffer` to play yourself.
60
60
 
61
61
  **shape** — `shape: 'rect' | 'circle' | 'ellipse' | 'arc' | 'line' | 'polygon' | 'path'`:
62
62
 
@@ -72,7 +72,7 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
72
72
 
73
73
  **Draw-on:** every shape but `arc` has `trimStart` / `trimEnd` (0–1 of the outline, default 0 / 1, animatable): `trimEnd: 0` + keyframe `to: { trimEnd: 1 }` draws a line / border / path on; `trimStart` 0 → 1 after it wipes it off; both = a travelling dash. The fill is NOT trimmed. Rect outline starts top-left, circle / ellipse at 12 o'clock, both clockwise. A `path` with several sub-paths walks them in order as one length; `trimEach: true` trims each on its own (all draw on together). Use `strokeCap: 'round'`.
74
74
 
75
- Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha strokeWidth`; `colorSpace: 'oklab'|'oklch'` for clean colour tweens. rect/circle/ellipse are **centred on `x,y` by default** (`anchorX/anchorY` default 0.5): for a bar growing from its base use `anchorY: 1` (or `anchorX: 0` for left-to-right). **`fillGradient`** (top level or `initial`; instead of `fillColor`): `{ type?: 'linear'|'radial', stops: [[0, '#000'], [1, 'rgba(0,0,0,.6)']], angle? /* linear, deg, 90 = top→bottom */, center?, innerRadius?, radius? /* radial, 0–1 of bounds */ }` — stops may have alpha, so a radial transparent→dark is a vignette. Stroke ends and corners: `strokeCap: 'butt'|'round'|'square'`, `strokeJoin: 'miter'|'round'|'bevel'` (top level or in `initial`; not animated — `round` keeps sharp corners like an M from poking out).
75
+ Style (`initial` / keyframes): `fillColor fillAlpha strokeColor strokeAlpha strokeWidth`; `colorSpace: 'oklab'|'oklch'` for clean colour tweens. rect/circle/ellipse are **centred on `x,y` by default** (`anchorX/anchorY` default 0.5): for a bar growing from its base use `anchorY: 1` (or `anchorX: 0` for left-to-right). **`fillGradient`** (top level or `initial`; instead of `fillColor`): `{ type?: 'linear'|'radial', stops: [[0, '#000'], [1, 'rgba(0,0,0,.6)']], angle? /* linear, deg, 90 = top→bottom */, center?, innerRadius?, radius? /* radial, 0–1 of the SHAPE's own bounds, not the canvas: a glow on a 1500 px circle is placed with fractions of that circle */ }` — stops may have alpha, so a radial transparent→dark is a vignette. **Animate it** with a partial gradient in a keyframe (`to: { fillGradient: { angle: 360, stops: [...] } }`: keys you leave out stay; the same number of stops; `type` cannot change; a start gradient is required; use `colorSpace: 'oklch'` for a vivid fade). A text layer takes `fillGradient` too (LINEAR only: angle and stops; type / center / radius are ignored with a warning; letters re-rasterised per change). `from` alone runs from that gradient to the layer's own. The names `'fillGradient.angle'` / `gradientAngle` are not properties: write `fillGradient: { angle }`. Stroke ends and corners: `strokeCap: 'butt'|'round'|'square'`, `strokeJoin: 'miter'|'round'|'bevel'` (top level or in `initial`; not animated — `round` keeps sharp corners like an M from poking out).
76
76
 
77
77
  **composition** — `width height duration sequences transitions`. With `threeD: true` it is a **card**: its children are drawn into one texture (content outside its `width × height` is clipped: size it for a soft shadow too; a plain 2D composition does not clip, but is skipped when its whole rectangle is off screen) and move / rotate in depth together. Children use the composition's local coordinates and times. Position/rotate/scale it as a unit; to rotate/scale about its centre set `pivotX/pivotY` to the centre and `x/y` to where that point should sit.
78
78
 
@@ -100,7 +100,7 @@ Add `threeD: true` to any visual layer, then use `z`, `rotationX`, `rotationY` (
100
100
 
101
101
  ## Filters by name (no import)
102
102
 
103
- `filters: [{ type: 'glow', name: 'halo', outerStrength: 3, color: '#ffd166' }]` — `type` = the pixi / pixi-filters class name without `Filter`, camelCase (`glow dropShadow outline blur noise alpha adjustment hslAdjustment grayscale pixelate crt rgbSplit oldFilm glitch bulgePinch twist zoomBlur kawaseBlur radialBlur motionBlur emboss ascii colorOverlay advancedBloom bevel dot crossHatch tiltShift …`), the other keys are its options. `colorMatrix` takes `preset: 'sepia'|'grayscale'|'negative'|'polaroid'|'technicolor'|'vintage'|'kodachrome'|'browni'`. Name it to animate: `'filters.halo.outerStrength'` (scalars only; `pixelate` via `sizeX`/`sizeY`). Options: `glow` distance outerStrength innerStrength color alpha · `dropShadow` offset{x,y} blur alpha color · `outline` thickness color · `blur` strength · `adjustment` gamma contrast saturation brightness · `hslAdjustment` hue lightness saturation · `pixelate` size · `crt` curvature lineWidth noise vignetting · `rgbSplit` red/green/blue {x,y} · `oldFilm` sepia noise scratch vignetting · `glitch` slices offset · `twist` radius angle offset · `bulgePinch` center radius strength · `zoomBlur` strength center innerRadius · `colorOverlay` color alpha · `colorGradient` gradientType('linear'|'radial'|'conic') stops[{offset,color,alpha}] angle · `colorReplace` originalColor targetColor tolerance · `convolution` matrix(9) · `shockwave` / `godray` are driven by `time` (animate it) · `colorMap` / `simpleLightmap` need a texture (use `custom`). **Centres/offsets are CANVAS pixels** (default top-left corner): put them on the layer. Glow / shadow / blur draw outside the layer: widen `filterArea` (not for `grayscale` / `oldFilm`, which fill the margin black). Full table: `docs/dsl.md` → Named filters.
103
+ `filters: [{ type: 'glow', name: 'halo', outerStrength: 3, color: '#ffd166' }]` — `type` = the pixi / pixi-filters class name without `Filter`, camelCase, or this library's own **`grain`** (film grain: `{ type: 'grain', amount: 0.08, size: 1.5, seed: 0, fps: 24, color: 0 }`, seeded, new every 1/fps s, mid-tone weighted, same grain pattern on every GPU (values within 1/255), anchored to the filter's frame (it moves with a layer), no pixi-filters needed; on a layer or on `composition.filters`; it costs video bitrate; `noise` is NOT film grain: it is frozen and lifts blacks) (`glow dropShadow outline blur noise alpha adjustment hslAdjustment grayscale pixelate crt rgbSplit oldFilm glitch bulgePinch twist zoomBlur kawaseBlur radialBlur motionBlur emboss ascii colorOverlay advancedBloom bevel dot crossHatch tiltShift …`), the other keys are its options. `colorMatrix` takes `preset: 'sepia'|'grayscale'|'negative'|'polaroid'|'technicolor'|'vintage'|'kodachrome'|'browni'`. Name it to animate: `'filters.halo.outerStrength'` (scalars only; `pixelate` via `sizeX`/`sizeY`). Options: `glow` distance outerStrength innerStrength color alpha · `dropShadow` offset{x,y} blur alpha color · `outline` thickness color · `blur` strength · `adjustment` gamma contrast saturation brightness · `hslAdjustment` hue lightness saturation · `pixelate` size · `crt` curvature lineWidth noise vignetting · `rgbSplit` red/green/blue {x,y} · `oldFilm` sepia noise scratch vignetting · `glitch` slices offset · `twist` radius angle offset · `bulgePinch` center radius strength · `zoomBlur` strength center innerRadius · `colorOverlay` color alpha · `colorGradient` gradientType('linear'|'radial'|'conic') stops[{offset,color,alpha}] angle · `colorReplace` originalColor targetColor tolerance · `convolution` matrix(9) · `shockwave` / `godray` are driven by `time` (animate it) · `colorMap` / `simpleLightmap` need a texture (use `custom`). **Centres/offsets are CANVAS pixels** (default top-left corner): put them on the layer. Glow / shadow / blur draw outside the layer: widen `filterArea` (not for `grayscale` / `oldFilm`, which fill the margin black). Full table: `docs/dsl.md` → Named filters.
104
104
 
105
105
  ## Transitions (on the parent composition)
106
106
 
@@ -92,3 +92,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
92
92
  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.
93
93
  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`.)
94
94
  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.
95
+ 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.
96
+ 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).
97
+ 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.
98
+ 68. **A pitched sfx keeps its own length; a radial gradient is placed in the shape's bounds.** `sfx: { preset: 'chime', pitch: -5 }` lasts as long as the sound does, so near the end of the movie give the layer a `duration` that ends with it (`inspectAudio()` reports a sound cut off by the end). A radial `fillGradient`'s `center` / `radius` are fractions of the SHAPE, not the canvas: for a glow on a big circle, convert canvas pixels to fractions of the circle. `music.reverb` is 0–0.6.
@@ -67,6 +67,32 @@ return [
67
67
 
68
68
  ---
69
69
 
70
+ ## Pop in with a spring: a card that lands, a badge that bounces
71
+
72
+ A spring overshoots and settles: `duration: 'auto'` lets the physics set the length, a preset names the feel (`spring.gentle`, `snappy`, `bouncy`, `wobbly`, `slow`). Different presets on one layer's properties read as different materials.
73
+
74
+ ```js
75
+ // @recipe spring-pop
76
+ const card = { type: 'shape', shape: 'rect', name: 'card', width: 520, height: 300, cornerRadius: 28, at: 0.3, duration: 3.7,
77
+ initial: { x: 640, y: 360, fillColor: '#f4f1ea', scale: 0.4, alpha: 0 },
78
+ keyframes: [
79
+ { at: 0, to: { scale: 1 }, duration: 'auto', ease: 'spring.snappy' },
80
+ { at: 0, to: { alpha: 1 }, duration: 0.2 },
81
+ ] };
82
+ const badge = { type: 'shape', shape: 'circle', name: 'badge', radius: 46, at: 1.0, duration: 3,
83
+ initial: { x: 860, y: 230, fillColor: '#e63946', scale: 0 },
84
+ keyframes: [{ at: 0, to: { scale: 1 }, duration: 'auto', ease: 'spring.bouncy' }] };
85
+ const title = { type: 'text', text: 'Hello', name: 'title', at: 0.5, duration: 3.5, style: { fontSize: 88, fontWeight: 'bold', fill: '#1d3557' },
86
+ initial: { x: 640, y: 380, anchorX: 0.5, anchorY: 0.5 },
87
+ keyframes: [{ at: 0, from: { y: 440, alpha: 0 }, to: { y: 380, alpha: 1 }, duration: 'auto', ease: 'spring(1, 170, 12)' }] };
88
+ return [
89
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#1d3557' } },
90
+ card, badge, title,
91
+ ];
92
+ ```
93
+
94
+ ---
95
+
70
96
  ## A ticker that scrolls out completely (`w` = the text's own width)
71
97
 
72
98
  `'-w'` is the exact x at which the text has left the screen on the left (`w` is measured after the style is applied).
@@ -717,6 +743,45 @@ return [
717
743
 
718
744
  ---
719
745
 
746
+ ## A gradient that moves (a turning fade, a drifting glow, letters that change colour)
747
+
748
+ A keyframe's `fillGradient` is partial: write only what moves. `colorSpace: 'oklch'` keeps the fade vivid.
749
+
750
+ ```js
751
+ // @recipe gradient-shift
752
+ return [
753
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', colorSpace: 'oklch', initial: { x: 'GW/2', y: 'GH/2' },
754
+ fillGradient: { angle: 20, stops: [[0, '#1b2a6b'], [1, '#7b3fe4']] },
755
+ keyframes: [{ at: 0, duration: 4, ease: 'sine.inOut', to: { fillGradient: { angle: 200, stops: [[0, '#7b3fe4'], [1, '#ff6a88']] } } }] },
756
+ { type: 'shape', shape: 'circle', radius: 260, initial: { x: 'GW/2', y: 'GH/2' },
757
+ fillGradient: { type: 'radial', center: [0.2, 0.2], radius: 0.3, stops: [[0, 'rgba(255,255,255,0.9)'], [1, 'rgba(255,255,255,0)']] },
758
+ keyframes: [{ at: 0, duration: 4, ease: 'sine.inOut', to: { fillGradient: { center: [0.8, 0.8], radius: 0.6 } } }] },
759
+ { type: 'text', text: 'SHIFT', name: 'title', style: { fontSize: 220, fontWeight: '900' }, colorSpace: 'oklch',
760
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
761
+ fillGradient: { angle: 0, stops: [[0, '#ffd60a'], [1, '#22c55e']] },
762
+ keyframes: [{ at: 0, duration: 4, to: { fillGradient: { angle: 180, stops: [[0, '#00e5ff'], [1, '#ff2d55']] } } }] },
763
+ ];
764
+ ```
765
+
766
+ ---
767
+
768
+ ## Film grain over the whole picture
769
+
770
+ `grain` is seeded, new every 1/`fps` s (24 by default), mid-tone weighted and the same on every GPU. Put it on the root `filters` for the film look, or on one layer. (`noise` is not grain: it is frozen and lifts blacks.) It costs video bitrate.
771
+
772
+ ```js
773
+ // @recipe film-grain
774
+ // the scene is wrapped in a composition layer that carries the filter (on a whole movie, put the same filter in `composition: { filters: [...] }`)
775
+ const scene = [
776
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#3a4a6b' } },
777
+ { type: 'text', text: 'FILM', name: 'title', at: 0.3, duration: 3.7, style: { fontSize: 200, fontWeight: '900', fill: '#f4f1ea' },
778
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5, alpha: 0 }, keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.8 }] },
779
+ ];
780
+ return [{ type: 'composition', name: 'film', width: 'GW', height: 'GH', sequences: scene, filters: [{ type: 'grain', name: 'g', amount: 0.06, size: 2, color: 0.2 }] }];
781
+ ```
782
+
783
+ ---
784
+
720
785
  ## Generated placeholder images (no photos available)
721
786
 
722
787
  Draw on a canvas and register `canvas.toDataURL()` as an asset (`data:` URLs work). Make the image at least canvas-sized (1920×1080 for a 1280×720 movie) so `kenBurns` zooms stay sharp.
package/ai/template.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>pixi-effects video</title>
7
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.18.1/dist/loader.css" /> <!-- the loader: HTML + CSS only, so it is on screen at once and keeps moving while the page builds the video -->
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/loader.css" /> <!-- the loader: HTML + CSS only, so it is on screen at once and keeps moving while the page builds the video -->
8
8
  <style>
9
9
  body { background: #0d1220; margin: 0; padding: 16px; }
10
10
  .stage { position: relative; width: min(960px, 100%); } /* the player and the loader both sit inside this box */
@@ -39,10 +39,10 @@
39
39
  "gsap": "https://esm.sh/gsap@3.12.5",
40
40
  "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
41
41
  "mediabunny": "https://esm.sh/mediabunny",
42
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.18.1/dist/index.js",
43
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.18.1/dist/Controller.js",
44
- "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.18.1/dist/Presenter.js",
45
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.18.1/dist/three.js",
42
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/index.js",
43
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/Controller.js",
44
+ "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/Presenter.js",
45
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/three.js",
46
46
  "pixi-filters": "https://esm.sh/pixi-filters@6.1.5?external=pixi.js",
47
47
  "three": "https://esm.sh/three@0.178.0"
48
48
  }
@@ -19,7 +19,7 @@
19
19
  * --strict (text overlaps and stops where the picture is still changing fail the check; by default they are only listed for review) · --out DIR · --frames N (contact sheet tiles, default 12) · --formats mp4,webm (default mp4) · --no-export ·
20
20
  * --timeout SECONDS (default 240) · --root DIR (static server root; default: the nearest folder above the page with dist/) · --chrome PATH
21
21
  */
22
- import { spawn } from 'node:child_process';
22
+ import { spawn, spawnSync } from 'node:child_process';
23
23
  import http from 'node:http';
24
24
  import fs from 'node:fs';
25
25
  import os from 'node:os';
@@ -124,10 +124,21 @@ export class Cdp {
124
124
 
125
125
  /** Start a private headless Chrome. `extraArgs`: more command-line flags (the tests use it for `--enable-features=WebMCP`). */
126
126
  export async function launchChrome(chrome, userDataDir, extraArgs = []) {
127
- const proc = spawn(chrome, [
127
+ const args = [
128
128
  '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, '--no-first-run', '--no-default-browser-check',
129
129
  '--autoplay-policy=no-user-gesture-required', '--mute-audio', '--ignore-gpu-blocklist', '--enable-unsafe-swiftshader', '--window-size=1400,900', ...extraArgs, 'about:blank',
130
- ], { stdio: ['ignore', 'ignore', 'pipe'] });
130
+ ];
131
+ // Chrome and its helpers must not outlive this process. A run that is killed or times out never reaches its own cleanup, and a page stuck
132
+ // in an endless loop then burns a CPU core (and holds the audio device) for hours. So a small shell keeps watch: when this process (or the
133
+ // browser) is gone it stops everything that was started with this profile directory. (No shell on Windows: plain spawn there.)
134
+ const watched = process.platform !== 'win32' && fs.existsSync('/bin/sh');
135
+ const stopAll = () => { spawnSync('pkill', ['-KILL', '-f', '--', `--user-data-dir=${userDataDir}`], { stdio: 'ignore' }); };
136
+ const proc = watched
137
+ ? spawn('/bin/sh', ['-c', '"$@" & chrome=$!; owner=$PPID; ( while kill -0 "$owner" 2>/dev/null && kill -0 "$chrome" 2>/dev/null; do sleep 2; done; pkill -KILL -f -- "--user-data-dir=$PE_PROFILE" ) >/dev/null 2>&1 & wait "$chrome"', 'sh', chrome, ...args],
138
+ { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, PE_PROFILE: userDataDir } })
139
+ : spawn(chrome, args, { stdio: ['ignore', 'ignore', 'pipe'] });
140
+ if (watched) proc.kill = () => { stopAll(); return true; }; // the browser, its helpers and the shell: all of them, at once
141
+
131
142
  const endpoint = await new Promise((resolve, reject) => {
132
143
  let buf = '';
133
144
  const t = setTimeout(() => reject(new Error('Chrome did not start (no DevTools endpoint within 20 s)')), 20000);
@@ -1,5 +1,5 @@
1
1
  import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape, a0 as AudioDescriptor } from './types-SS51lP6O.js';
2
+ import { S as SequenceSpec, C as CompositionShape, a0 as AudioDescriptor } from './types-bzLJ7aUx.js';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -1,5 +1,5 @@
1
1
  import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape, a0 as AudioDescriptor } from './types-SS51lP6O.cjs';
2
+ import { S as SequenceSpec, C as CompositionShape, a0 as AudioDescriptor } from './types-bzLJ7aUx.cjs';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ var chunkCTZRFSVU_cjs = require('./chunk-CTZRFSVU.cjs');
4
+ require('./chunk-VTBT6NNE.cjs');
5
+ require('./chunk-UQBGW5DO.cjs');
6
+
7
+
8
+
9
+ Object.defineProperty(exports, "CompositionSequence", {
10
+ enumerable: true,
11
+ get: function () { return chunkCTZRFSVU_cjs.CompositionSequence; }
12
+ });
13
+ //# sourceMappingURL=Composition-KYNPRWK3.cjs.map
14
+ //# sourceMappingURL=Composition-KYNPRWK3.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-6JMXWNVV.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-KYNPRWK3.cjs"}
@@ -0,0 +1,5 @@
1
+ export { CompositionSequence } from './chunk-B3YB2TNY.js';
2
+ import './chunk-YRXTKOV5.js';
3
+ import './chunk-DKGG7WD4.js';
4
+ //# sourceMappingURL=Composition-QBKEEJBV.js.map
5
+ //# sourceMappingURL=Composition-QBKEEJBV.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-MLBHYYFF.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-QBKEEJBV.js"}
@@ -1,7 +1,7 @@
1
- import { h as Movie } from './Movie-K2YuVAdg.cjs';
1
+ import { h as Movie } from './Movie-CpmdNadg.cjs';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
- import './types-SS51lP6O.cjs';
4
+ import './types-bzLJ7aUx.cjs';
5
5
 
6
6
  /** The bar's default stylesheet. Its colours and sizes are CSS custom properties (`--mc-accent`, `--mc-fg`, `--mc-track`, `--mc-bar-bg`, `--mc-track-height`, `--mc-font`): set them in your page CSS, or with the `theme` option. */
7
7
  declare const CONTROLLER_CSS = "\n.movie-controller-wrap { position: relative; display: inline-block; line-height: 0; }\n.movie-controller {\n position: absolute;\n pointer-events: none;\n font-family: var(--mc-font, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif);\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n opacity: 1;\n transition: opacity 200ms ease;\n}\n.movie-controller[data-state=\"hidden\"] { opacity: 0; }\n.movie-controller > * { pointer-events: auto; }\n\n.mc-progress {\n position: relative;\n width: 100%;\n height: 16px;\n cursor: pointer;\n display: flex;\n align-items: center;\n outline: none;\n}\n.mc-progress:focus-visible {\n outline: 2px solid var(--mc-accent, #007AFF);\n outline-offset: 2px;\n}\n.mc-progress::before {\n content: \"\"; position: absolute; left: 12px; right: 12px;\n height: var(--mc-track-height, 3px); background: var(--mc-track, rgba(255,255,255,0.25));\n transition: height 120ms ease;\n}\n.mc-progress:hover::before, .mc-progress.mc-scrubbing::before { height: calc(var(--mc-track-height, 3px) + 2px); }\n.mc-stops { position: absolute; left: 12px; right: 12px; top: 0; bottom: 0; pointer-events: none; }\n.mc-stop-tick { position: absolute; top: 50%; width: 7px; height: 7px; transform: translate(-50%, -50%); border-radius: 50%; background: var(--mc-fg, #fff); box-shadow: 0 0 0 1.5px rgba(0,0,0,0.5); }\n.mc-stop-page { width: 11px; height: 11px; box-shadow: 0 0 0 2.5px var(--mc-accent, #007AFF), 0 0 0 4px rgba(0,0,0,0.35); }\n.mc-progress-fill {\n position: absolute; left: 12px; top: 50%;\n height: var(--mc-track-height, 3px);\n width: calc((100% - 24px) * var(--mc-fill, 0));\n background: var(--mc-accent, #007AFF);\n transform: translateY(-50%);\n transition: height 120ms ease;\n pointer-events: none;\n}\n.mc-progress:hover .mc-progress-fill,\n.mc-progress.mc-scrubbing .mc-progress-fill { height: calc(var(--mc-track-height, 3px) + 2px); }\n.mc-progress-thumb {\n position: absolute; top: 50%;\n left: calc(12px + (100% - 24px) * var(--mc-fill, 0));\n width: 12px; height: 12px; border-radius: 50%;\n background: var(--mc-accent, #007AFF);\n transform: translate(-50%, -50%) scale(0);\n transition: transform 120ms ease;\n pointer-events: none;\n}\n.mc-progress:hover .mc-progress-thumb,\n.mc-progress.mc-scrubbing .mc-progress-thumb { transform: translate(-50%, -50%) scale(1); }\n\n.mc-bar {\n position: relative;\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 6px 12px 8px 12px;\n background: var(--mc-bar-bg, linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0) 100%));\n color: var(--mc-fg, #fff);\n line-height: 1;\n}\n.mc-btn {\n background: none; border: 0; padding: 0; margin: 0;\n width: 28px; height: 28px;\n display: inline-flex; align-items: center; justify-content: center;\n color: var(--mc-fg, #fff); opacity: 0.85; cursor: pointer;\n transition: opacity 120ms ease;\n}\n.mc-btn:hover { opacity: 1; }\n.mc-btn:disabled { opacity: 0.4; cursor: not-allowed; }\n.mc-time {\n font-size: 12px; font-family: Menlo, Monaco, monospace;\n color: var(--mc-fg, #fff); opacity: 0.85;\n min-width: 90px;\n}\n.mc-spacer { flex: 1; }\n\n.mc-volume { display: flex; align-items: center; }\n.mc-vol-slider {\n position: relative;\n width: 0;\n height: 28px;\n display: flex;\n align-items: center;\n cursor: pointer;\n margin-left: 0;\n outline: none;\n transition: width 200ms ease, margin-left 200ms ease;\n overflow: hidden;\n}\n.mc-volume:hover .mc-vol-slider,\n.mc-volume:focus-within .mc-vol-slider,\n.mc-vol-slider.mc-scrubbing {\n width: 70px;\n margin-left: 6px;\n}\n.mc-vol-slider:focus-visible { outline: 2px solid var(--mc-accent, #007AFF); outline-offset: 2px; }\n.mc-vol-slider::before {\n content: \"\"; position: absolute; left: 4px; right: 4px;\n height: 3px; background: var(--mc-track, rgba(255,255,255,0.25)); border-radius: 2px;\n}\n.mc-vol-fill {\n position: absolute; left: 4px; top: 50%;\n height: 3px;\n width: calc((100% - 8px) * var(--mc-volume, 1));\n background: var(--mc-fg, #fff);\n transform: translateY(-50%);\n border-radius: 2px;\n pointer-events: none;\n}\n.mc-vol-thumb {\n position: absolute; top: 50%;\n left: calc(4px + (100% - 8px) * var(--mc-volume, 1));\n width: 10px; height: 10px; border-radius: 50%;\n background: var(--mc-fg, #fff);\n transform: translate(-50%, -50%);\n pointer-events: none;\n}\n\n[data-mc-wrap]:fullscreen {\n width: 100vw; height: 100vh;\n display: flex; align-items: center; justify-content: center;\n background: #000;\n}\n[data-mc-wrap]:fullscreen > canvas {\n width: 100% !important;\n height: 100% !important;\n max-width: none !important;\n max-height: none !important;\n object-fit: contain;\n border-radius: 0;\n}\n\n.mc-settings-popover {\n position: absolute;\n bottom: calc(100% + 8px);\n right: 12px;\n background: rgba(20, 20, 20, 0.96);\n border-radius: 8px;\n padding: 10px 12px;\n min-width: 180px;\n color: #fff;\n font-size: 12px;\n box-shadow: 0 4px 16px rgba(0,0,0,0.4);\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n.mc-settings-popover[data-open=\"false\"] { display: none; }\n.mc-settings-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n.mc-settings-row > span { color: rgba(255,255,255,0.85); }\n.mc-settings-row > select {\n font: inherit;\n background: rgba(255,255,255,0.1);\n color: #fff;\n border: 1px solid rgba(255,255,255,0.18);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n}\n.mc-export-confirm {\n margin-top: 4px;\n width: 100%;\n background: var(--mc-accent, #007AFF);\n color: #fff;\n border: 0;\n border-radius: 4px;\n padding: 6px 10px;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n transition: background 120ms ease;\n}\n.mc-export-confirm:hover { background: #0066d6; }\n.mc-export-confirm:disabled { background: rgba(255,255,255,0.18); cursor: not-allowed; }\n\n.mc-export-overlay {\n position: absolute; inset: 0;\n background: rgba(0,0,0,0.7);\n display: flex; justify-content: center; align-items: center;\n z-index: 2;\n}\n.mc-export-panel {\n background: rgba(20,20,20,0.96); color: #fff;\n border-radius: 8px; padding: 16px 20px;\n text-align: center; min-width: 200px; max-width: 80%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n}\n.mc-export-title { font-size: 13px; margin-bottom: 10px; opacity: 0.9; }\n.mc-export-track {\n width: 100%; height: 6px;\n background: rgba(255,255,255,0.15); border-radius: 3px;\n margin-bottom: 8px; overflow: hidden;\n}\n.mc-export-fill {\n height: 100%; width: 0%;\n background: linear-gradient(90deg, var(--mc-accent, #007AFF), var(--mc-accent, #0056CC));\n transition: width 0.3s ease; border-radius: 3px;\n}\n.mc-export-text {\n color: rgba(255,255,255,0.7); font-size: 11px;\n font-family: Menlo, Monaco, monospace;\n}\n";
@@ -1,7 +1,7 @@
1
- import { h as Movie } from './Movie-DlHQ9C02.js';
1
+ import { h as Movie } from './Movie-8IrqL3kO.js';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
- import './types-SS51lP6O.js';
4
+ import './types-bzLJ7aUx.js';
5
5
 
6
6
  /** The bar's default stylesheet. Its colours and sizes are CSS custom properties (`--mc-accent`, `--mc-fg`, `--mc-track`, `--mc-bar-bg`, `--mc-track-height`, `--mc-font`): set them in your page CSS, or with the `theme` option. */
7
7
  declare const CONTROLLER_CSS = "\n.movie-controller-wrap { position: relative; display: inline-block; line-height: 0; }\n.movie-controller {\n position: absolute;\n pointer-events: none;\n font-family: var(--mc-font, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif);\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n opacity: 1;\n transition: opacity 200ms ease;\n}\n.movie-controller[data-state=\"hidden\"] { opacity: 0; }\n.movie-controller > * { pointer-events: auto; }\n\n.mc-progress {\n position: relative;\n width: 100%;\n height: 16px;\n cursor: pointer;\n display: flex;\n align-items: center;\n outline: none;\n}\n.mc-progress:focus-visible {\n outline: 2px solid var(--mc-accent, #007AFF);\n outline-offset: 2px;\n}\n.mc-progress::before {\n content: \"\"; position: absolute; left: 12px; right: 12px;\n height: var(--mc-track-height, 3px); background: var(--mc-track, rgba(255,255,255,0.25));\n transition: height 120ms ease;\n}\n.mc-progress:hover::before, .mc-progress.mc-scrubbing::before { height: calc(var(--mc-track-height, 3px) + 2px); }\n.mc-stops { position: absolute; left: 12px; right: 12px; top: 0; bottom: 0; pointer-events: none; }\n.mc-stop-tick { position: absolute; top: 50%; width: 7px; height: 7px; transform: translate(-50%, -50%); border-radius: 50%; background: var(--mc-fg, #fff); box-shadow: 0 0 0 1.5px rgba(0,0,0,0.5); }\n.mc-stop-page { width: 11px; height: 11px; box-shadow: 0 0 0 2.5px var(--mc-accent, #007AFF), 0 0 0 4px rgba(0,0,0,0.35); }\n.mc-progress-fill {\n position: absolute; left: 12px; top: 50%;\n height: var(--mc-track-height, 3px);\n width: calc((100% - 24px) * var(--mc-fill, 0));\n background: var(--mc-accent, #007AFF);\n transform: translateY(-50%);\n transition: height 120ms ease;\n pointer-events: none;\n}\n.mc-progress:hover .mc-progress-fill,\n.mc-progress.mc-scrubbing .mc-progress-fill { height: calc(var(--mc-track-height, 3px) + 2px); }\n.mc-progress-thumb {\n position: absolute; top: 50%;\n left: calc(12px + (100% - 24px) * var(--mc-fill, 0));\n width: 12px; height: 12px; border-radius: 50%;\n background: var(--mc-accent, #007AFF);\n transform: translate(-50%, -50%) scale(0);\n transition: transform 120ms ease;\n pointer-events: none;\n}\n.mc-progress:hover .mc-progress-thumb,\n.mc-progress.mc-scrubbing .mc-progress-thumb { transform: translate(-50%, -50%) scale(1); }\n\n.mc-bar {\n position: relative;\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 6px 12px 8px 12px;\n background: var(--mc-bar-bg, linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0) 100%));\n color: var(--mc-fg, #fff);\n line-height: 1;\n}\n.mc-btn {\n background: none; border: 0; padding: 0; margin: 0;\n width: 28px; height: 28px;\n display: inline-flex; align-items: center; justify-content: center;\n color: var(--mc-fg, #fff); opacity: 0.85; cursor: pointer;\n transition: opacity 120ms ease;\n}\n.mc-btn:hover { opacity: 1; }\n.mc-btn:disabled { opacity: 0.4; cursor: not-allowed; }\n.mc-time {\n font-size: 12px; font-family: Menlo, Monaco, monospace;\n color: var(--mc-fg, #fff); opacity: 0.85;\n min-width: 90px;\n}\n.mc-spacer { flex: 1; }\n\n.mc-volume { display: flex; align-items: center; }\n.mc-vol-slider {\n position: relative;\n width: 0;\n height: 28px;\n display: flex;\n align-items: center;\n cursor: pointer;\n margin-left: 0;\n outline: none;\n transition: width 200ms ease, margin-left 200ms ease;\n overflow: hidden;\n}\n.mc-volume:hover .mc-vol-slider,\n.mc-volume:focus-within .mc-vol-slider,\n.mc-vol-slider.mc-scrubbing {\n width: 70px;\n margin-left: 6px;\n}\n.mc-vol-slider:focus-visible { outline: 2px solid var(--mc-accent, #007AFF); outline-offset: 2px; }\n.mc-vol-slider::before {\n content: \"\"; position: absolute; left: 4px; right: 4px;\n height: 3px; background: var(--mc-track, rgba(255,255,255,0.25)); border-radius: 2px;\n}\n.mc-vol-fill {\n position: absolute; left: 4px; top: 50%;\n height: 3px;\n width: calc((100% - 8px) * var(--mc-volume, 1));\n background: var(--mc-fg, #fff);\n transform: translateY(-50%);\n border-radius: 2px;\n pointer-events: none;\n}\n.mc-vol-thumb {\n position: absolute; top: 50%;\n left: calc(4px + (100% - 8px) * var(--mc-volume, 1));\n width: 10px; height: 10px; border-radius: 50%;\n background: var(--mc-fg, #fff);\n transform: translate(-50%, -50%);\n pointer-events: none;\n}\n\n[data-mc-wrap]:fullscreen {\n width: 100vw; height: 100vh;\n display: flex; align-items: center; justify-content: center;\n background: #000;\n}\n[data-mc-wrap]:fullscreen > canvas {\n width: 100% !important;\n height: 100% !important;\n max-width: none !important;\n max-height: none !important;\n object-fit: contain;\n border-radius: 0;\n}\n\n.mc-settings-popover {\n position: absolute;\n bottom: calc(100% + 8px);\n right: 12px;\n background: rgba(20, 20, 20, 0.96);\n border-radius: 8px;\n padding: 10px 12px;\n min-width: 180px;\n color: #fff;\n font-size: 12px;\n box-shadow: 0 4px 16px rgba(0,0,0,0.4);\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n.mc-settings-popover[data-open=\"false\"] { display: none; }\n.mc-settings-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n.mc-settings-row > span { color: rgba(255,255,255,0.85); }\n.mc-settings-row > select {\n font: inherit;\n background: rgba(255,255,255,0.1);\n color: #fff;\n border: 1px solid rgba(255,255,255,0.18);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n}\n.mc-export-confirm {\n margin-top: 4px;\n width: 100%;\n background: var(--mc-accent, #007AFF);\n color: #fff;\n border: 0;\n border-radius: 4px;\n padding: 6px 10px;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n transition: background 120ms ease;\n}\n.mc-export-confirm:hover { background: #0066d6; }\n.mc-export-confirm:disabled { background: rgba(255,255,255,0.18); cursor: not-allowed; }\n\n.mc-export-overlay {\n position: absolute; inset: 0;\n background: rgba(0,0,0,0.7);\n display: flex; justify-content: center; align-items: center;\n z-index: 2;\n}\n.mc-export-panel {\n background: rgba(20,20,20,0.96); color: #fff;\n border-radius: 8px; padding: 16px 20px;\n text-align: center; min-width: 200px; max-width: 80%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n}\n.mc-export-title { font-size: 13px; margin-bottom: 10px; opacity: 0.9; }\n.mc-export-track {\n width: 100%; height: 6px;\n background: rgba(255,255,255,0.15); border-radius: 3px;\n margin-bottom: 8px; overflow: hidden;\n}\n.mc-export-fill {\n height: 100%; width: 0%;\n background: linear-gradient(90deg, var(--mc-accent, #007AFF), var(--mc-accent, #0056CC));\n transition: width 0.3s ease; border-radius: 3px;\n}\n.mc-export-text {\n color: rgba(255,255,255,0.7); font-size: 11px;\n font-family: Menlo, Monaco, monospace;\n}\n";
@@ -1,6 +1,6 @@
1
1
  import { Application } from 'pixi.js';
2
2
  import { gsap } from 'gsap';
3
- import { U as Stop, e as AssetSpec, c as CompositionSpec } from './types-SS51lP6O.js';
3
+ import { U as Stop, e as AssetSpec, c as CompositionSpec } from './types-bzLJ7aUx.js';
4
4
 
5
5
  interface AudioInspectOptions {
6
6
  /** Width of each loudness window in seconds. Default 0.1. */
@@ -409,6 +409,11 @@ declare class Movie {
409
409
  background: string;
410
410
  private _audioContext;
411
411
  private _rootSequence;
412
+ /** The filters that take the time of the frame they draw (`grain`), collected once after the build. */
413
+ private _timeFilters;
414
+ /** While a motion-blurred frame is exposed: the frame's own time, so all its sub-frames share one grain frame (and not the playhead's). */
415
+ private _nominalTime;
416
+ private _feedTimeFilters;
412
417
  private _rootContainer;
413
418
  private _raf;
414
419
  /** What went into the mix, and its peak before limiting — kept for inspectAudio(). */
@@ -1,6 +1,6 @@
1
1
  import { Application } from 'pixi.js';
2
2
  import { gsap } from 'gsap';
3
- import { U as Stop, e as AssetSpec, c as CompositionSpec } from './types-SS51lP6O.cjs';
3
+ import { U as Stop, e as AssetSpec, c as CompositionSpec } from './types-bzLJ7aUx.cjs';
4
4
 
5
5
  interface AudioInspectOptions {
6
6
  /** Width of each loudness window in seconds. Default 0.1. */
@@ -409,6 +409,11 @@ declare class Movie {
409
409
  background: string;
410
410
  private _audioContext;
411
411
  private _rootSequence;
412
+ /** The filters that take the time of the frame they draw (`grain`), collected once after the build. */
413
+ private _timeFilters;
414
+ /** While a motion-blurred frame is exposed: the frame's own time, so all its sub-frames share one grain frame (and not the playhead's). */
415
+ private _nominalTime;
416
+ private _feedTimeFilters;
412
417
  private _rootContainer;
413
418
  private _raf;
414
419
  /** What went into the mix, and its peak before limiting — kept for inspectAudio(). */
@@ -1,7 +1,7 @@
1
- import { h as Movie } from './Movie-K2YuVAdg.cjs';
1
+ import { h as Movie } from './Movie-CpmdNadg.cjs';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
- import './types-SS51lP6O.cjs';
4
+ import './types-bzLJ7aUx.cjs';
5
5
 
6
6
  interface PresenterOptions {
7
7
  canvas: HTMLCanvasElement;
@@ -1,7 +1,7 @@
1
- import { h as Movie } from './Movie-DlHQ9C02.js';
1
+ import { h as Movie } from './Movie-8IrqL3kO.js';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
- import './types-SS51lP6O.js';
4
+ import './types-bzLJ7aUx.js';
5
5
 
6
6
  interface PresenterOptions {
7
7
  canvas: HTMLCanvasElement;
@@ -1,4 +1,4 @@
1
- import { Sequence, lintTiming, lintText, summarizeWarnings, buildSequenceTree, CameraSequence, describeLayer, NullSequence, cameraBasis, homeCamera, collectPropKeys, projectLayer, NEAR, DEG } from './chunk-SG3Y66FN.js';
1
+ import { Sequence, lintTiming, lintText, lintKeys, summarizeWarnings, buildSequenceTree, CameraSequence, describeLayer, NullSequence, cameraBasis, homeCamera, collectPropKeys, projectLayer, NEAR, DEG } from './chunk-YRXTKOV5.js';
2
2
  import { suggestName } from './chunk-DKGG7WD4.js';
3
3
  import { Container, Rectangle, AlphaMask, PerspectiveMesh, Texture, Matrix, RenderTexture } from 'pixi.js';
4
4
  import { gsap } from 'gsap';
@@ -304,6 +304,7 @@ var CompositionSequence = class extends Sequence {
304
304
  lintSequence(s);
305
305
  lintTiming(s, this.duration, (message, kind) => kind === "late-keyframe" ? lateKeyframes.push(message) : console.warn(message));
306
306
  lintText(s);
307
+ lintKeys(s);
307
308
  }
308
309
  for (const message of summarizeWarnings(lateKeyframes)) console.warn(message);
309
310
  this._children = await buildSequenceTree(
@@ -498,5 +499,5 @@ function frameOf(child) {
498
499
  }
499
500
 
500
501
  export { CompositionSequence };
501
- //# sourceMappingURL=chunk-UXDR3ZDU.js.map
502
- //# sourceMappingURL=chunk-UXDR3ZDU.js.map
502
+ //# sourceMappingURL=chunk-B3YB2TNY.js.map
503
+ //# sourceMappingURL=chunk-B3YB2TNY.js.map