pixi-effects 0.19.0 → 0.20.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 (69) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +6 -6
  3. package/ai/SKILL.md +1 -0
  4. package/ai/chat-template.html +3 -3
  5. package/ai/reference/cheatsheet.md +7 -6
  6. package/ai/reference/pitfalls.md +5 -0
  7. package/ai/reference/recipes.md +52 -0
  8. package/ai/template.html +5 -5
  9. package/ai/tools/check.mjs +15 -3
  10. package/dist/{Base-B4WbUJO2.d.ts → Base-CzfLGoDr.d.ts} +9 -1
  11. package/dist/{Base-i88XXaiP.d.cts → Base-wYJQ2g5Q.d.cts} +9 -1
  12. package/dist/Composition-6PA4C4TV.js +5 -0
  13. package/dist/{Composition-QBKEEJBV.js.map → Composition-6PA4C4TV.js.map} +1 -1
  14. package/dist/Composition-LL2XXU5N.cjs +14 -0
  15. package/dist/{Composition-KYNPRWK3.cjs.map → Composition-LL2XXU5N.cjs.map} +1 -1
  16. package/dist/Controller.cjs +2 -2
  17. package/dist/Controller.d.cts +2 -2
  18. package/dist/Controller.d.ts +2 -2
  19. package/dist/Controller.js +1 -1
  20. package/dist/{Movie-8IrqL3kO.d.ts → Movie-CBxGH5ms.d.ts} +8 -2
  21. package/dist/{Movie-CpmdNadg.d.cts → Movie-CzzJw8A-.d.cts} +8 -2
  22. package/dist/Presenter.cjs +2 -2
  23. package/dist/Presenter.d.cts +2 -2
  24. package/dist/Presenter.d.ts +2 -2
  25. package/dist/Presenter.js +1 -1
  26. package/dist/{chunk-YRXTKOV5.js → chunk-DQHXVNOI.js} +282 -23
  27. package/dist/chunk-DQHXVNOI.js.map +1 -0
  28. package/dist/{chunk-CTZRFSVU.cjs → chunk-FFYA3UBU.cjs} +144 -37
  29. package/dist/chunk-FFYA3UBU.cjs.map +1 -0
  30. package/dist/{chunk-DKGG7WD4.js → chunk-MNM56B3X.js} +6 -3
  31. package/dist/chunk-MNM56B3X.js.map +1 -0
  32. package/dist/{chunk-VTBT6NNE.cjs → chunk-NO26HWR7.cjs} +294 -30
  33. package/dist/chunk-NO26HWR7.cjs.map +1 -0
  34. package/dist/{chunk-B3YB2TNY.js → chunk-TSPMKV2F.js} +120 -13
  35. package/dist/chunk-TSPMKV2F.js.map +1 -0
  36. package/dist/{chunk-UQBGW5DO.cjs → chunk-WTRKDIKH.cjs} +6 -3
  37. package/dist/chunk-WTRKDIKH.cjs.map +1 -0
  38. package/dist/index.cjs +213 -90
  39. package/dist/index.cjs.map +1 -1
  40. package/dist/index.d.cts +4 -4
  41. package/dist/index.d.ts +4 -4
  42. package/dist/index.js +154 -31
  43. package/dist/index.js.map +1 -1
  44. package/dist/loader.css +1 -1
  45. package/dist/{music-4HDFULNR.js → music-CFEC7HKL.js} +3 -3
  46. package/dist/{music-4HDFULNR.js.map → music-CFEC7HKL.js.map} +1 -1
  47. package/dist/{music-3OF5C6X5.cjs → music-UAN7DXK4.cjs} +7 -7
  48. package/dist/{music-3OF5C6X5.cjs.map → music-UAN7DXK4.cjs.map} +1 -1
  49. package/dist/{sfx-MZJZXYY3.cjs → sfx-EX4PUBMV.cjs} +8 -8
  50. package/dist/{sfx-MZJZXYY3.cjs.map → sfx-EX4PUBMV.cjs.map} +1 -1
  51. package/dist/{sfx-S37DDC66.js → sfx-U3GM2MED.js} +3 -3
  52. package/dist/{sfx-S37DDC66.js.map → sfx-U3GM2MED.js.map} +1 -1
  53. package/dist/three.cjs +6 -6
  54. package/dist/three.d.cts +2 -2
  55. package/dist/three.d.ts +2 -2
  56. package/dist/three.js +2 -2
  57. package/dist/{types-bzLJ7aUx.d.ts → types-YmUaViL1.d.cts} +24 -0
  58. package/dist/{types-bzLJ7aUx.d.cts → types-YmUaViL1.d.ts} +24 -0
  59. package/docs/dsl.md +22 -0
  60. package/llms-full.txt +87 -6
  61. package/package.json +3 -1
  62. package/dist/Composition-KYNPRWK3.cjs +0 -14
  63. package/dist/Composition-QBKEEJBV.js +0 -5
  64. package/dist/chunk-B3YB2TNY.js.map +0 -1
  65. package/dist/chunk-CTZRFSVU.cjs.map +0 -1
  66. package/dist/chunk-DKGG7WD4.js.map +0 -1
  67. package/dist/chunk-UQBGW5DO.cjs.map +0 -1
  68. package/dist/chunk-VTBT6NNE.cjs.map +0 -1
  69. package/dist/chunk-YRXTKOV5.js.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.20.0
4
+
5
+ **Added**
6
+
7
+ - **Time remap: `speed` and `time` on video, audio-file and composition layers.** `speed` is a fixed multiplier on the layer (`2` twice as fast, `0.5` slow motion, negative backward; with no `duration` a video or audio file lasts its length ÷ |speed|). `time` is the layer's own clock as an ordinary animatable property, in seconds of its content (a file's position, a composition's local playhead), in `initial` and `keyframes` with every keyframe word (`at`, `duration`, `ease`, `repeat`, `yoyo`, `from`, `to`, `set`): a freeze is the same value twice, a loop is `repeat`, a rewind is `to: { time: 0 }`. A negative `time` counts back from the end of the content. **A composition with its own time** plays the layers inside in its local seconds (their `at`, `duration` and keyframes are written in it; the composition's own `at`, `duration` and keyframes stay in the movie's time, as in After Effects' pre-composition time remap); everything inside follows: particles, grain, springs, gradients, cameras, nested compositions and videos, sounds and `sfx`. The sound follows like a tape (the pitch moves with the speed; no time-stretch).
8
+ - **`cubic-bezier(x1, y1, x2, y2)` easing**, the CSS curve exactly, no plugin (`ease: 'cubic-bezier(.2, .8, .2, 1)'`, a fast start with a long glide). A malformed one warns once and runs as `'none'`.
9
+ - **The mistakes of the new keys are warned about, with what to write:** `speed` of 0 or not a number, `speed` together with keyframed `time` (`time` wins), `speed` in `initial` or a keyframe (it is a setting: animate `time`), `speed` / `time` on a layer that cannot have them (a shape, text, an `sfx`: its `pitch` is in semitones), other names (`playbackRate`, `timeScale`, `rate`, `reverse: true`, `timeRemap`), and a layer inside a remapped composition that its clock never reaches (`at: 3` in a 4 s composition at `speed: 0.5`).
10
+ - **The timeline says which times are local.** `movie.timelineData()` rows and transitions inside a remapped composition carry `local` (`stage ×0.5`, `stage time keyframes`) and are in that composition's seconds; the chart's tooltip says so; `--at inner@end` for such a layer says to name the composition; `inspect()` reads a remapped composition's transition windows in its own time; `inspectAudio()` reports a sound inside one when it plays in the movie (every pass, forward, backward, replayed), and an `sfx` that its composition's clock never lets finish is reported as cut off by the clock, with how far the clock reaches (it used to be blamed on the end of the movie).
11
+ - `transitions` inside a composition with its own time are measured against its content (in its local seconds; a negative `at` counts back from the end of the content), and a frame-driven layer (`pixi-effects/three`) inside one is asked for the composition's local time.
12
+ - Playground preset 15 (rewind and replay a title), the recipe `rewind-and-replay`, pitfalls 69–73, and the guide (Motion: Time; Images and video).
13
+ - **One gallery piece for the new feature** (42 now): `rewind-title` ("Again.": a title card is built, rewound like a tape with its sounds running backward, held on an empty frame and replayed in slow motion; one composition with its own clock, an on-screen display that keeps real time). Its author worked from the docs alone, and its stumble notes corrected them: `cubic-bezier(.2, .8, .2, 1)` is front-loaded (half of the change in the first 13 % of the time, 99 % by 74 %), GSAP's `power2` is a cubic (`power2.in` has done 12.5 % at half time), what children of a `time`-keyframed composition default to, and what `inspectAudio` reports for a remapped sound.
14
+
15
+ **Fixed**
16
+
17
+ - A video frame was looked up by a time rounded to a millisecond, so two different times could share one cached frame and the answer depended on which was asked first; it is a microsecond now. A time that is a frame boundary up to float noise (k / 30) could pick the frame before it; it is nudged by 5e-5 s. A looping video wraps a negative time.
18
+ - `grain`: the grain frame of a time exactly on a boundary (k / fps) could be the previous one when the time had passed through a remapped clock (gsap rounds time to about 1e-8 s); the slack is a thousandth of a grain frame.
19
+
20
+ **Measured (headless Chrome, M1 Pro)**
21
+
22
+ - A video under `speed` 2, 0.5, 0.37, −1, a ramp, a freeze, `yoyo`, `repeat`, `initial.time`, a negative `time`: the clip frame shown is the one the clock says (within one frame) and every seek order (forward, backward, two shuffles) gives the identical picture; the export, played back, shows the same frames.
23
+ - A composition at `speed` 2, −1, 0.5 and a keyframed loop + hold + backward ramp, holding `to` / `from` / `set` keyframes, a gradient, a spring and a nested video, equals the unremapped composition at the mapped time (largest difference 60/255 allowed, on antialiased edges) and every seek order is identical; the same for particles, grain, a threeD card with a camera, and (up to 6/255) motion blur.
24
+ - Sound, a 440 Hz tone: `speed: 2` → 880 Hz, `speed: 0.5` → 220 Hz; a 200 → 1000 Hz sweep played backward starts at 950 Hz and ends at 250 Hz; a held `time` is silent (RMS 0); an `sfx` inside a composition at `speed: 0.5` is exactly half as high.
25
+ - Cost: a composition of 200 layers takes 0.47 ms a frame plain and 0.46 ms with its own time (0.45–0.48 ms with `speed: 1`, `time` keyframes or two nested remaps; two runs).
26
+ - `cubic-bezier` is within 9.8e-7 of Chrome's own CSS easing (12 curves, 1001 samples each).
27
+ - Sound that is not remapped is unchanged: the mix checksum of a three-sound test mix is pinned; 25 of the example and gallery pages with sound mix to the identical samples before and after (the 10 music pages differ at the 7th–8th digit of the sum, and so does the previous build from one run to the next: Web Audio float noise).
28
+
29
+ **Known limits**
30
+
31
+ - No time-stretch: the pitch follows the speed. Speeds above 2 are not low-pass filtered (aliasing).
32
+ - A composition has no `loop` (an audio or video file does): loop its time with a finite `repeat`.
33
+
3
34
  ## 0.19.0
4
35
 
5
36
  **Added**
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # pixi-effects
2
2
 
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).
3
+ > **Status**: experimental — current release `0.20.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `0.20.0` adds time remap (`speed` and `time` on video, audio-file and composition layers: slow motion, reverse, freeze, a composition with its own clock whose sounds follow it), `cubic-bezier(…)` easing and one gallery piece, `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/)** · 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.
5
+ **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 42 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.19.0/dist/index.js",
55
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.19.0/dist/Controller.js"
54
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.20.0/dist/index.js",
55
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.20.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.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.
66
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.20.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.19.0/dist/three.js"
82
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.20.0/dist/three.js"
83
83
  }
84
84
  }
85
85
  </script>
package/ai/SKILL.md CHANGED
@@ -34,6 +34,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
34
34
  - **Masks live in the parent's coordinates and don't follow the masked layer.** **`filterArea` is in the layer's own coordinates.**
35
35
  - **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.
36
36
  - **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.
37
+ - **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.
37
38
  - **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`) `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), shared masks. (Draw-on strokes `trimEnd`, text that changes over time `set: { text }` / `visibleChars`, shake `wiggle()` and particles are built in.)
38
39
  - **Blend modes:** `blendMode: 'add' | 'screen' | 'multiply'` on any layer (glows, light leaks).
39
40
  - **One `Movie` per page.** A sound effect needs no `duration` (it lasts as long as its clip). Seeking is safe: counters, colours and growing shapes are right after any jump.
@@ -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.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"
64
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.20.0/dist/index.js",
65
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.20.0/dist/Controller.js",
66
+ "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.20.0/dist/Presenter.js"
67
67
  }
68
68
  }
69
69
  </script>
@@ -16,6 +16,7 @@ A video is **data**: a tree of plain objects. Everything below is a JS object yo
16
16
  | Time of a keyframe's `at` | seconds from the start of **its own layer**; **negative = back from the layer's end** |
17
17
  | Time of a transition's `at` | seconds from the start of the **parent composition**; negative = back from its end |
18
18
  | `duration` | defaults to the parent composition's duration |
19
+ | Time **inside** a layer | `speed` (a multiplier on the layer: 1 = as is, 2 = twice as fast, 0.5 = slow motion, **negative = backward**) and `time` (seconds of the layer's own content, in `initial` / keyframes: a video's or audio file's position in the file, a composition's local playhead; **negative = back from the end of the content**) on **video, audio file and composition** layers. **The layers inside a composition write `at` / `duration` / `keyframes` in THAT composition's own seconds** (its local time); the composition's own `at`, `duration` and keyframes stay in the outer time. `duration` is always the layer's life in the outer time |
19
20
 
20
21
  ## Expressions
21
22
 
@@ -44,7 +45,7 @@ blendMode: 'normal' | 'add' | 'screen' | 'multiply' // add / screen brighten (
44
45
 
45
46
  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
47
 
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
+ 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'`, **`'cubic-bezier(.4, 0, .2, 1)'`** (CSS syntax, x1 and x2 in 0…1; `.4, 0, .2, 1` is a gentle in-out; `.2, .8, .2, 1` is FRONT-LOADED, not slow motion: half of the change in the first 13 % of the time, 90 % by 39 %, 99 % by 74 %, then a long settle, so what sits at the END of the content gets the glide; `.2, .8, .5, 1` has a shorter tail). GSAP's `power1…4` are the powers 2…5: `power2` is a cubic, `power2.in` has done only 12.5 % at half time (42 % at three quarters)… 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
49
 
49
50
  ## Layer types
50
51
 
@@ -52,10 +53,10 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
52
53
 
53
54
  **image** — `asset`, `tint`, `colorSpace: 'rgb'|'oklab'|'oklch'`. Default anchor top-left; natural size = `w`,`h`.
54
55
 
55
- **video** — `asset`, `loop`, `audio`, `volume`; `initial: { scale: 'cover' }`.
56
+ **video** — `asset`, `loop`, `audio`, `volume`, **`speed`**; `initial: { scale: 'cover' }`. `speed: 0.5` slow motion, `speed: -1` backward (it starts at |speed| × `duration`, so a 3 s file with no `duration` plays whole, backward); with no `duration` the layer lasts the file's length ÷ |speed|. For anything that is not one straight speed — a freeze, a ramp, a loop — animate **`time`** (seconds in the file) like any property: `keyframes: [{ at: 0, from: { time: 0 }, to: { time: 2 }, duration: 1 }, { at: 1, to: { time: 2 }, duration: 0.5 }, { at: 1.5, to: { time: 6 }, duration: 3, ease: 'power2.inOut' }]` (play 2 s, hold, then accelerate). `repeat` loops it, `yoyo` ping-pongs. `speed` itself is not animatable (it warns): animate `time`. The sound follows (pitch too, like a tape).
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
- **Or a sound effect with no file — `sfx`:** `{ type: 'audio', sfx: 'swoosh', at: 2 }` is the whole thing. Presets, default length in s: `click` 0.04 · `pop` 0.12 · `swoosh` 0.5 (loudest 0.16 s in, moves left → right) · `swipe` 0.22 · `hit` 0.7 · `riser` 1 (loudest at its END: `at = hit − duration`) · `chime` 1.4 · `beep` 0.16 · `coin` 0.4 · `glitch` 0.35 · `typewriter` 0.06. Knobs: `sfx: { preset, pitch /* semitones ±24 */, brightness /* −1 dark … 1 bright */, seed /* another take */ }`. **The layer's `duration` IS the sound's length** (omit it; set it to stretch, e.g. a riser); `volume` is its level (1 = the preset's standard level, peaking at −12 … −18 dBFS by preset). No `loop`: one layer per hit (a JS loop). Custom sound: `sfx: { voices: [{ wave: 'noise' | 'sine' | 'square' | 'saw' | 'triangle', freq: 440 | [from, to], filter: { type: 'lowpass' | 'highpass' | 'bandpass', freq: [from, to] }, envelope: 'fall' | 'bell' | 'swell' | 'hold', pan: [-1, 1] }] }`. The export (mp4 / webm / mov) encodes this same mix (lossy AAC / Opus), on time within a few ms.
58
+ **audio** — a file: `asset`, `loop`, `volume`, `speed` (the pitch follows; `time` keyframes work as for video; not for `sfx` / `music`: change an sfx's `pitch` instead, or put it in a composition and remap that); `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`.
59
+ **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; at most 10 s); `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
60
  **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
61
 
61
62
  **shape** — `shape: 'rect' | 'circle' | 'ellipse' | 'arc' | 'line' | 'polygon' | 'path'`:
@@ -74,7 +75,7 @@ Keyframe kinds: `set` (jump at `at`; undone when you seek back), `to` (animate t
74
75
 
75
76
  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
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
+ **composition** — `width height duration sequences transitions`, **`speed`** (the composition's own time: its content plays backward, in slow motion, … — `time` keyframes give a freeze, a rewind, a ramp; a composition's sound and sfx follow; its own x / alpha keyframes stay in the outer time; **children are placed in the composition's local seconds**; the content is as long as the larger of `duration` and the farthest `time` it reaches (`speed: 2` → twice `duration`; `time` keyframes → their largest number), and children default to that length. A child whose life ends exactly where a held `time` stands is hidden during the hold (a life is [at, at + duration)): give it a `duration` that reaches past. `time` keyframes and ordinary ones (`x`, `alpha`, a `wiggle`) may share one `keyframes` list. A sound inside goes silent while the clock stands still). With `threeD: true` it is a **card**: its children are drawn into one texture (content outside its `width × height` is clipped: size it for a soft shadow too; a plain 2D composition does not clip, but is skipped when its whole rectangle is off screen) and move / rotate in depth together. Children use the composition's local coordinates and times. Position/rotate/scale it as a unit; to rotate/scale about its centre set `pivotX/pivotY` to the centre and `x/y` to where that point should sit.
78
79
 
79
80
  **null + `parent`** — `{ type: 'null', name: 'rig', initial: { x, y }, keyframes: [...] }` draws nothing; layers with `parent: 'rig'` are drawn inside it and follow its `x y rotation scale skew alpha pivotX pivotY`. Their `x / y` are measured **from the null's origin** (null at (640,360), child `x: 200` → drawn at 840), so rotating the null makes the child circle it. Nulls chain (`parent` on a null). Children are drawn at the null's place in the stack, together. Only `null` layers can be parents; not for `threeD` layers; a mask follows its layer.
80
81
 
@@ -173,6 +174,6 @@ await movie.onionSkin({ from: 1, to: 3, count: 8, as: 'dataURL' }) // frames ov
173
174
  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
174
175
  movie.resolveAt('3.5, 50%, f120, title@end') // moments → [{ label, frame }]; a mistake says what could not be read
175
176
  movie.inspectFonts() // { missing: [{ layer, family }] (no font of the list is installed), failed: [web fonts whose file did not load] }
176
- 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 } }, windows (rms / peak / brightness per 0.1 s), issues (limited mix, inaudible / cut-off sounds)
177
+ 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 (rms / peak / brightness per 0.1 s), issues (limited mix, inaudible / cut-off sounds)
177
178
  new Controller(movie, { canvas }) // optional player bar (overlays the canvas bottom ~60px; not in the export)
178
179
  ```
@@ -96,3 +96,8 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
96
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
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
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.
99
+ 69. **Inside a remapped composition, `at` is in THAT composition's seconds.** With `speed: 0.5` a 4 s composition shows only 2 s of its content; a layer at `at: 3` inside is never seen (warns: "its clock only reaches 0–2s"). With `speed: 2` the content is 8 s long, so children default to 8 s and `at: 5` is fine. The composition's own `at`, `duration`, x / alpha keyframes are outer time. To freeze, hold `time` at one value (`to` the same number); to rewind, `to: { time: 0 }`. A sound inside goes silent while the clock stands still, and an `sfx` longer than the clock's reach is reported as cut off by the clock: give it a shorter `duration` (the sfx limit is 10 s) or let the clock run on. A child whose life ends exactly where a held `time` stands is hidden during the hold: give it a `duration` that reaches past (the content is as long as the larger of `duration` and the largest `time`).
100
+ 70. **`speed` is a setting, `time` is the property.** `speed: 2` on the layer; to change the speed over time animate `time` (a curve of seconds in the file), never `keyframes: [{ to: { speed: 3 } }]` (warns). `speed: 0` is not allowed (hold a moment with `time` to the same value twice); `speed` together with keyframed `time`: `time` wins. Other names (`playbackRate`, `timeScale`, `rate`, `reverse: true`, `timeRemap`) are warned about with what to write. `particles({ speed })` is something else: launch speed in px per second.
101
+ 71. **Sound follows `speed` like a tape: the pitch moves with it.** An audio file or a video's sound at `speed: 2` is an octave up; backward plays reversed. Not for `sfx` / `music` layers (warns): change an sfx with `pitch` (semitones), or put the sfx **inside a remapped composition** and it follows that clock (slower and lower, or backward).
102
+ 72. **A video at `speed: 2` with no `duration` is half as long; with a `duration` longer than the file it holds the last frame** (add `loop: true` to repeat). A negative `time` counts back from the end of the file (`initial: { time: -2 }` = two seconds before the end).
103
+ 73. **Know the shape of the curve before you time a beat to it.** `cubic-bezier(.2, .8, .2, 1)` does half of its change in the first 13 % of the time and 99 % by 74 % (not slow motion: over 6 s the first 2 s of content pass in 0.6 s); `.2, .8, .5, 1` has a shorter tail. GSAP's `power2` is a cubic (`power1` = t², `power3` = t⁴): `power2.in` has done 12.5 % at half time and 42 % at three quarters, so a rewind with it dismantles most of the title in the last third.
@@ -93,6 +93,58 @@ return [
93
93
 
94
94
  ---
95
95
 
96
+ ## Rewind and play again: a composition whose own time you control
97
+
98
+ Put the part you want to rewind, slow down or freeze **in a composition**, then give that composition `speed` (one straight speed: `0.5` slow motion, `-1` backward) or `time` keyframes (any curve of its own time: play, hold, rewind, play slowly). The layers inside write `at` / `duration` / `keyframes` in the composition's own seconds; the composition's own `at` / `duration` stay in the outer time. A sound or `sfx` inside follows the clock (backward and lower when it rewinds, like a tape). `cubic-bezier(.2, .8, .2, 1)` is front-loaded, not slow motion: half of the replay passes in the first 13 % of its time and 99 % by 74 %, so the replay opens quickly and what sits at the END of the content (the ring, the settle) gets the long glide. GSAP's `power2` is a cubic: `power2.in` has done only 12.5 % at half time, so the dismantling bunches up in the last third of the rewind.
99
+
100
+ ```js
101
+ // @recipe rewind-and-replay
102
+ const act = {
103
+ type: 'composition', name: 'act', duration: 11,
104
+ // its own time: play 0 → 2.2 s, hold half a second, rewind to 0 (accelerating), hold, then replay (4.5 s of content over 6 s: a fast start, a long glide at the end)
105
+ keyframes: [
106
+ { at: 0, from: { time: 0 }, to: { time: 2.2 }, duration: 2.2, ease: 'none' },
107
+ { at: 2.2, to: { time: 2.2 }, duration: 0.5 },
108
+ { at: 2.7, to: { time: 0 }, duration: 1.3, ease: 'power2.in' },
109
+ { at: 4, to: { time: 0 }, duration: 0.4 },
110
+ { at: 4.4, to: { time: 4.5 }, duration: 6, ease: 'cubic-bezier(.2, .8, .2, 1)' },
111
+ ],
112
+ sequences: [
113
+ // everything in here is in the composition's own seconds: 0 … 4.5
114
+ { type: 'shape', shape: 'rect', name: 'bar', width: 'GW', height: 16, anchorX: 0, anchorY: 0, at: 0, duration: 4.5,
115
+ initial: { x: 0, y: 330, fillColor: '#ff4d3d', scaleX: 0 }, keyframes: [{ at: 0, to: { scaleX: 1 }, duration: 0.8, ease: 'expo.out' }] },
116
+ { type: 'text', text: 'AGAIN.', name: 'title', at: 0.4, duration: 4.1, style: { fontSize: 150, fontWeight: 'bold', fill: '#f2efe8' },
117
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5 },
118
+ keyframes: [{ at: 0, from: { y: 430, alpha: 0 }, to: { y: 360, alpha: 1 }, duration: 0.9, ease: 'expo.out' }] },
119
+ { type: 'audio', sfx: 'hit', at: 0.4 },
120
+ ],
121
+ };
122
+ return [
123
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: '#0b0b10' } },
124
+ act,
125
+ // an overlay in the OUTER time: it shows while the act rewinds (2.7 s … 4 s of the movie)
126
+ { type: 'text', text: '◀◀ REW', name: 'rew', at: 2.7, duration: 1.3, style: { fontSize: 36, fontWeight: 'bold', fill: '#ff4d3d' }, initial: { x: 60, y: 50 } },
127
+ ];
128
+ ```
129
+
130
+ The shortcuts, when one straight speed is enough: `{ type: 'composition', duration: 4, speed: 0.5, sequences: [ … ] }` (slow motion, the content plays half of its length), `speed: -1` (backward). For a **video file** the same words work on the layer itself:
131
+
132
+ ```js
133
+ // @docs-only video-speed
134
+ // needs a real clip: assets: [{ name: 'clip', src: 'clip.mp4' }]
135
+ return [
136
+ { type: 'video', asset: 'clip', speed: 0.5 }, // slow motion; with no duration the layer lasts 2x the file
137
+ { type: 'video', asset: 'clip', speed: -1, at: 4 }, // backward, the whole file
138
+ { type: 'video', asset: 'clip', duration: 5, keyframes: [ // play 2 s, hold, then accelerate
139
+ { at: 0, from: { time: 0 }, to: { time: 2 }, duration: 1 },
140
+ { at: 1, to: { time: 2 }, duration: 0.5 },
141
+ { at: 1.5, to: { time: 6 }, duration: 3.5, ease: 'power2.inOut' },
142
+ ] },
143
+ ];
144
+ ```
145
+
146
+ ---
147
+
96
148
  ## A ticker that scrolls out completely (`w` = the text's own width)
97
149
 
98
150
  `'-w'` is the exact x at which the text has left the screen on the left (`w` is measured after the style is applied).
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.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 -->
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pixi-effects@0.20.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.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",
42
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.20.0/dist/index.js",
43
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.20.0/dist/Controller.js",
44
+ "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.20.0/dist/Presenter.js",
45
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.20.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
  }
@@ -98,7 +98,9 @@ export function findRoot(dir, exists = fs.existsSync) {
98
98
 
99
99
  export class Cdp {
100
100
  constructor(ws) {
101
- this.ws = ws; this.id = 0; this.pending = new Map(); this.listeners = [];
101
+ this.ws = ws; this.id = 0; this.pending = new Map(); this.listeners = []; this.closed = false;
102
+ /** Opt-in limit (ms) for one call; none by default, because an export or a render legitimately takes minutes. */
103
+ this.timeout = null;
102
104
  ws.onmessage = ev => {
103
105
  const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : Buffer.from(ev.data).toString());
104
106
  if (msg.id && this.pending.has(msg.id)) {
@@ -107,11 +109,21 @@ export class Cdp {
107
109
  msg.error ? reject(new Error(`${msg.error.message}`)) : resolve(msg.result);
108
110
  } else if (msg.method) for (const fn of this.listeners) fn(msg);
109
111
  };
112
+ // a browser that is gone (killed, crashed) never answers: end every call that waits for it, instead of waiting for ever
113
+ ws.onclose = () => {
114
+ this.closed = true;
115
+ for (const [, p] of this.pending) p.reject(new Error('connection to Chrome closed'));
116
+ this.pending.clear();
117
+ };
110
118
  }
111
119
  send(method, params = {}) {
120
+ if (this.closed) return Promise.reject(new Error('connection to Chrome closed'));
112
121
  const id = ++this.id;
113
122
  this.ws.send(JSON.stringify({ id, method, params }));
114
- return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
123
+ return new Promise((resolve, reject) => {
124
+ const timer = this.timeout ? setTimeout(() => { this.pending.delete(id); reject(new Error(`Chrome did not answer ${method} in ${this.timeout} ms`)); }, this.timeout) : null;
125
+ this.pending.set(id, { resolve: v => { clearTimeout(timer); resolve(v); }, reject: e => { clearTimeout(timer); reject(e); } });
126
+ });
115
127
  }
116
128
  on(fn) { this.listeners.push(fn); }
117
129
  /** Run an expression in the page and return its value (JSON-able). Awaits promises. */
@@ -150,7 +162,7 @@ export async function launchChrome(chrome, userDataDir, extraArgs = []) {
150
162
  const page = targets.find(t => t.type === 'page');
151
163
  const ws = new WebSocket(page.webSocketDebuggerUrl);
152
164
  await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error('could not connect to Chrome')); });
153
- return { proc, cdp: new Cdp(ws) };
165
+ return { proc, cdp: new Cdp(ws), port: Number(port), endpoint };
154
166
  }
155
167
 
156
168
  // ───────────────────────────── in-page scripts ─────────────────────────────
@@ -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-bzLJ7aUx.js';
2
+ import { S as SequenceSpec, C as CompositionShape, a0 as AudioDescriptor } from './types-YmUaViL1.js';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -83,6 +83,14 @@ declare abstract class Sequence {
83
83
  * pixi-effects/three). Base sequences route nothing extra.
84
84
  */
85
85
  protected pathRouters(): PathRouters;
86
+ /**
87
+ * The `initial` and `keyframes` that go onto the display object. A layer whose `time` runs a clock (video, composition) takes `time`
88
+ * out of them: it is not a property of the display object.
89
+ */
90
+ protected displayProps(): {
91
+ initial: SequenceSpec['initial'];
92
+ keyframes: SequenceSpec['keyframes'];
93
+ };
86
94
  bindTimeline(timeline: Timeline, offset?: number): void;
87
95
  collectAudio(_out: AudioDescriptor[], _baseTime: number): void;
88
96
  /**
@@ -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-bzLJ7aUx.cjs';
2
+ import { S as SequenceSpec, C as CompositionShape, a0 as AudioDescriptor } from './types-YmUaViL1.cjs';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -83,6 +83,14 @@ declare abstract class Sequence {
83
83
  * pixi-effects/three). Base sequences route nothing extra.
84
84
  */
85
85
  protected pathRouters(): PathRouters;
86
+ /**
87
+ * The `initial` and `keyframes` that go onto the display object. A layer whose `time` runs a clock (video, composition) takes `time`
88
+ * out of them: it is not a property of the display object.
89
+ */
90
+ protected displayProps(): {
91
+ initial: SequenceSpec['initial'];
92
+ keyframes: SequenceSpec['keyframes'];
93
+ };
86
94
  bindTimeline(timeline: Timeline, offset?: number): void;
87
95
  collectAudio(_out: AudioDescriptor[], _baseTime: number): void;
88
96
  /**
@@ -0,0 +1,5 @@
1
+ export { CompositionSequence } from './chunk-TSPMKV2F.js';
2
+ import './chunk-DQHXVNOI.js';
3
+ import './chunk-MNM56B3X.js';
4
+ //# sourceMappingURL=Composition-6PA4C4TV.js.map
5
+ //# sourceMappingURL=Composition-6PA4C4TV.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-QBKEEJBV.js"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-6PA4C4TV.js"}
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ var chunkFFYA3UBU_cjs = require('./chunk-FFYA3UBU.cjs');
4
+ require('./chunk-NO26HWR7.cjs');
5
+ require('./chunk-WTRKDIKH.cjs');
6
+
7
+
8
+
9
+ Object.defineProperty(exports, "CompositionSequence", {
10
+ enumerable: true,
11
+ get: function () { return chunkFFYA3UBU_cjs.CompositionSequence; }
12
+ });
13
+ //# sourceMappingURL=Composition-LL2XXU5N.cjs.map
14
+ //# sourceMappingURL=Composition-LL2XXU5N.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-KYNPRWK3.cjs"}
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"Composition-LL2XXU5N.cjs"}
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkUQBGW5DO_cjs = require('./chunk-UQBGW5DO.cjs');
3
+ var chunkWTRKDIKH_cjs = require('./chunk-WTRKDIKH.cjs');
4
4
 
5
5
  // src/Controller.ts
6
6
  var ICONS = {
@@ -456,7 +456,7 @@ var Controller = class _Controller {
456
456
  }
457
457
  /** Change the look of the bar (see {@link ControllerTheme}): only the keys you give change, `null` restores the default. */
458
458
  setTheme(theme) {
459
- chunkUQBGW5DO_cjs.warnUnknownOptions("Controller theme", theme, Object.keys(THEME_PROPERTIES));
459
+ chunkWTRKDIKH_cjs.warnUnknownOptions("Controller theme", theme, Object.keys(THEME_PROPERTIES));
460
460
  for (const key of Object.keys(THEME_PROPERTIES)) {
461
461
  if (!(key in theme)) continue;
462
462
  const value = theme[key];
@@ -1,7 +1,7 @@
1
- import { h as Movie } from './Movie-CpmdNadg.cjs';
1
+ import { h as Movie } from './Movie-CzzJw8A-.cjs';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
- import './types-bzLJ7aUx.cjs';
4
+ import './types-YmUaViL1.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-8IrqL3kO.js';
1
+ import { h as Movie } from './Movie-CBxGH5ms.js';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
- import './types-bzLJ7aUx.js';
4
+ import './types-YmUaViL1.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,4 +1,4 @@
1
- import { warnUnknownOptions } from './chunk-DKGG7WD4.js';
1
+ import { warnUnknownOptions } from './chunk-MNM56B3X.js';
2
2
 
3
3
  // src/Controller.ts
4
4
  var ICONS = {
@@ -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-bzLJ7aUx.js';
3
+ import { U as Stop, e as AssetSpec, c as CompositionSpec } from './types-YmUaViL1.js';
4
4
 
5
5
  interface AudioInspectOptions {
6
6
  /** Width of each loudness window in seconds. Default 0.1. */
@@ -185,12 +185,18 @@ interface TimelineRow {
185
185
  partNames?: string[];
186
186
  /** A short text for the tooltip (a text layer's words, an asset name). */
187
187
  detail?: string;
188
+ /**
189
+ * Set on every row inside a time-remapped composition: where its time comes from (`stage ×0.5`, `stage time keyframes`). Such rows
190
+ * are in that composition's LOCAL seconds (start, end, keys), not the movie's.
191
+ */
192
+ local?: string;
188
193
  }
189
194
  interface TimelineTransition {
190
195
  from: string;
191
196
  to: string;
192
197
  start: number;
193
- end: number;
198
+ end: number; /** as `TimelineRow.local`: the window is in local seconds */
199
+ local?: string;
194
200
  }
195
201
  interface TimelineData {
196
202
  duration: number;
@@ -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-bzLJ7aUx.cjs';
3
+ import { U as Stop, e as AssetSpec, c as CompositionSpec } from './types-YmUaViL1.cjs';
4
4
 
5
5
  interface AudioInspectOptions {
6
6
  /** Width of each loudness window in seconds. Default 0.1. */
@@ -185,12 +185,18 @@ interface TimelineRow {
185
185
  partNames?: string[];
186
186
  /** A short text for the tooltip (a text layer's words, an asset name). */
187
187
  detail?: string;
188
+ /**
189
+ * Set on every row inside a time-remapped composition: where its time comes from (`stage ×0.5`, `stage time keyframes`). Such rows
190
+ * are in that composition's LOCAL seconds (start, end, keys), not the movie's.
191
+ */
192
+ local?: string;
188
193
  }
189
194
  interface TimelineTransition {
190
195
  from: string;
191
196
  to: string;
192
197
  start: number;
193
- end: number;
198
+ end: number; /** as `TimelineRow.local`: the window is in local seconds */
199
+ local?: string;
194
200
  }
195
201
  interface TimelineData {
196
202
  duration: number;
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunkUQBGW5DO_cjs = require('./chunk-UQBGW5DO.cjs');
3
+ var chunkWTRKDIKH_cjs = require('./chunk-WTRKDIKH.cjs');
4
4
 
5
5
  // src/Presenter.ts
6
6
  var OPTION_KEYS = ["canvas", "keyboard", "clickToAdvance", "swipe", "indicator", "autoAdvance", "loop", "accent", "onExit"];
@@ -147,7 +147,7 @@ var Presenter = class {
147
147
  };
148
148
  constructor(movie, options) {
149
149
  if (!options || !options.canvas) throw new Error("Presenter requires options.canvas (HTMLCanvasElement).");
150
- chunkUQBGW5DO_cjs.warnUnknownOptions("Presenter", options, OPTION_KEYS);
150
+ chunkWTRKDIKH_cjs.warnUnknownOptions("Presenter", options, OPTION_KEYS);
151
151
  this.movie = movie;
152
152
  this.options = {
153
153
  canvas: options.canvas,
@@ -1,7 +1,7 @@
1
- import { h as Movie } from './Movie-CpmdNadg.cjs';
1
+ import { h as Movie } from './Movie-CzzJw8A-.cjs';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
- import './types-bzLJ7aUx.cjs';
4
+ import './types-YmUaViL1.cjs';
5
5
 
6
6
  interface PresenterOptions {
7
7
  canvas: HTMLCanvasElement;
@@ -1,7 +1,7 @@
1
- import { h as Movie } from './Movie-8IrqL3kO.js';
1
+ import { h as Movie } from './Movie-CBxGH5ms.js';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
- import './types-bzLJ7aUx.js';
4
+ import './types-YmUaViL1.js';
5
5
 
6
6
  interface PresenterOptions {
7
7
  canvas: HTMLCanvasElement;