pixi-effects 0.13.1 → 0.15.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0
4
+
5
+ **Added**
6
+
7
+ - **A way in for an AI in a browser chat (ChatGPT, Claude) with no shell and no files.** `ai/CHAT.md` is a short guide (the rules that prevent most failures and a worked example) and `ai/chat-template.html` a self-contained starter page that loads only from `cdn.jsdelivr.net` (so it also runs in a sandboxed preview), shows every warning in a red box with a Copy button to paste back to the chat, and has the player bar's download button for the MP4. Tested in a real Chrome under a strict content security policy: no warnings, ready, an MP4 exported, and the worked example runs. The README, `llms.txt`, SKILL and the guide's "Working with an AI" page point to it with a one-paragraph prompt to paste.
8
+
9
+ ## 0.14.0
10
+
11
+ **Added**
12
+
13
+ - **`movie.inspectStops({ lookback?, tolerance? })`**: is the picture at each stop still changing? It compares each stop with the picture 3 frames before it and reports the stops where more than 0.4 % of the pixels differ (a stop that lands before its animation ends, so the audience, the page overview and a PDF see it half-finished), naming the stop, its page and what to do. `pixi-effects-check` runs it for a deck and lists the result as a review (`--strict` fails on it). It found a real fault in `quiet-hours` (page four's first stop was a few frames early), now fixed.
14
+
15
+ **Fixed**
16
+
17
+ - `pixi-effects-check` no longer prints "audio: Unable to decode audio data" for a movie with no sound: it says "no audio track".
18
+
3
19
  ## 0.13.1
4
20
 
5
21
  **Fixed**
package/README.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.13.1`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `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.15.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `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
5
  **[Guide →](https://yjmtmtk.github.io/pixi-effects/guide/)** · **[Gallery →](https://yjmtmtk.github.io/pixi-effects/examples/gallery/)** · 37 portfolio pieces written as plain data by AI models · **[Live demos →](https://yjmtmtk.github.io/pixi-effects/)** · 12 numbered examples + an in-browser playground.
6
6
 
7
+ **Make a video with an AI chat** (no install): paste this to ChatGPT or Claude in the browser, then open the file it gives you and use the download button in the player bar for the MP4:
8
+
9
+ > Read https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/CHAT.md and follow it. Make a 10-second 1280×720 title video for … (what you see, the colours, what you hear). Give me the whole HTML file.
10
+
11
+ (For an AI that has a shell and a browser, such as Claude Code: [`ai/SKILL.md`](./ai/SKILL.md).)
12
+
7
13
  Declarative composition and video rendering for the web. After Effects-style timelines on top of [PixiJS v8](https://pixijs.com/) and [GSAP](https://gsap.com/), with strict TypeScript types. Render to MP4 / WebM / MOV via [mediabunny](https://mediabunny.dev/).
8
14
 
9
15
  - **Declarative DSL** — describe your composition as a tree of typed sequences (text, image, video, audio, shapes, nested compositions). No imperative tween code.
@@ -42,8 +48,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
42
48
  "gsap": "https://esm.sh/gsap@3.12.5",
43
49
  "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
44
50
  "mediabunny": "https://esm.sh/mediabunny",
45
- "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.1/dist/index.js",
46
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.1/dist/Controller.js"
51
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.15.0/dist/index.js",
52
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.15.0/dist/Controller.js"
47
53
  }
48
54
  }
49
55
  </script>
@@ -54,7 +60,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
54
60
  </script>
55
61
  ```
56
62
 
57
- Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.13.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.
63
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.15.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.
58
64
 
59
65
  > **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
60
66
  >
@@ -70,7 +76,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
70
76
  "imports": {
71
77
  "...": "(everything from the importmap above)",
72
78
  "three": "https://esm.sh/three@0.178.0",
73
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.1/dist/three.js"
79
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.15.0/dist/three.js"
74
80
  }
75
81
  }
76
82
  </script>
package/ai/CHAT.md ADDED
@@ -0,0 +1,72 @@
1
+ # pixi-effects for an AI in a chat (no shell, no files)
2
+
3
+ You write ONE HTML file. The person opens it (or your chat previews it), watches, and can download an MP4 with the button in the player bar. You cannot run commands or look at the result, so you rely on two things: the rules below, and the red box the page shows when something is wrong (ask the person to paste its text back to you).
4
+
5
+ ## Do this
6
+
7
+ 1. Fetch `https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/chat-template.html` and copy it whole. (Use the `raw.githubusercontent.com` address; the normal github.com page does not return the file.) If you cannot fetch it, ask the person to paste it.
8
+ 2. Replace only what is between `EDIT FROM HERE` and `EDIT UNTIL HERE`. Keep the rest: it loads the library from one CDN, collects warnings into a red box with a Copy button, and adds the player bar.
9
+ 3. Give the person the whole file. Tell them: open it, press play (sound starts after the first click), use the download button (bottom right of the picture) for the MP4, and **if a red box appears, copy its text back to you**.
10
+ 4. When they paste warnings, fix exactly what each one names. The messages say what to change ("did you mean …").
11
+
12
+ ## The idea
13
+
14
+ A video is **data**: a list of layers in `sequences`. Each layer has `type`, a start `at` and a `duration` (seconds), `initial` values and `keyframes`. Repeated structure is a JS loop or function that returns layers. There is no per-frame code and no `Math.random()`.
15
+
16
+ ```js
17
+ // @chat-example
18
+ const W = 1280, H = 720, FPS = 30, DURATION = 8;
19
+ const BACKGROUND = '#f3eee4';
20
+ const INK = '#2b2118', ACCENT = '#c0583a';
21
+
22
+ const sequences = [
23
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: BACKGROUND } },
24
+ {
25
+ type: 'text', text: 'Rye & Sons', name: 'title', at: 0.4, duration: DURATION - 0.4,
26
+ style: { fontFamily: 'Georgia, serif', fontSize: 110, fill: INK },
27
+ initial: { x: 'GW/2', y: 300, anchorX: 0.5, anchorY: 0.5 },
28
+ keyframes: [{ at: 0, from: { alpha: 0, y: 330 }, to: { alpha: 1, y: 300 }, duration: 0.9, ease: 'power3.out' }],
29
+ },
30
+ {
31
+ type: 'shape', shape: 'line', from: [420, 380], to: [860, 380], name: 'rule', at: 1.2, duration: DURATION - 1.2,
32
+ initial: { strokeColor: ACCENT, strokeWidth: 4, trimEnd: 0 },
33
+ keyframes: [{ at: 0, to: { trimEnd: 1 }, duration: 1, ease: 'power2.inOut' }],
34
+ },
35
+ {
36
+ type: 'text', text: 'Bakers since 1952', name: 'tagline', at: 2.2, duration: DURATION - 2.2,
37
+ style: { fontFamily: 'Georgia, serif', fontSize: 40, fill: INK, letterSpacing: 4 },
38
+ initial: { x: 'GW/2', y: 430, anchorX: 0.5, anchorY: 0.5 },
39
+ keyframes: [{ at: 0, from: { alpha: 0 }, to: { alpha: 1 }, duration: 0.8 }],
40
+ },
41
+ { type: 'audio', sfx: 'chime', at: 2.2 },
42
+ ];
43
+ const POSTER = 4; // the picture that stands for the video before play: its most striking moment
44
+ ```
45
+
46
+ That is a complete, working video (the block between the two markers). Change the content, not the structure.
47
+
48
+ ## Rules that prevent most failures
49
+
50
+ - **Time.** A layer's `at` is seconds from the start of the movie. A keyframe's `at` is seconds from the start of **its own layer** (negative = back from the layer's end). A layer is hidden after `at + duration`; give it a `duration` that reaches the end of its scene.
51
+ - **Order.** Layers are drawn in array order: later = on top. Put the background rect first.
52
+ - **Units.** Pixels from the top-left; angles in **degrees**; colours `'#rrggbb'`. Any number may be an expression string: `'GW/2'`, `'GH - 80'`, `'min(GW, GH) * 0.4'`.
53
+ - **Anchors.** `rect`, `circle`, `ellipse` are centred on `x, y`. **Text and image are top-left**: add `anchorX: 0.5, anchorY: 0.5` to centre them. A bar that grows from its base needs `anchorY: 1`.
54
+ - **Animate with keyframes.** `{ at, duration, ease, to: {...} }` animates to a value, `from: {...}` from one, `from` + `to` both (a delayed fade-in needs no `initial.alpha`), `set: {...}` jumps. Eases are GSAP names: `'power2.out'`, `'back.out(1.7)'`, `'none'`.
55
+ - **Draw a line on:** `trimEnd: 0` in `initial`, then a keyframe `to: { trimEnd: 1 }` (every shape except `arc`).
56
+ - **Shapes:** `shape: 'rect' | 'circle' | 'ellipse' | 'arc' | 'line' | 'polygon' | 'path'`. `rect`: `width height cornerRadius`; `circle`: `radius`; `line`: `from: [x, y], to: [x, y]` (canvas coordinates, no `x, y`); `path`: `d` (SVG path data). Style: `fillColor strokeColor strokeWidth fillAlpha`.
57
+ - **Text:** `style` is a PixiJS text style (`fontSize fontFamily fontWeight fill letterSpacing align wordWrap wordWrapWidth lineHeight`). Use fonts that exist everywhere (`Georgia, serif`, `Arial, sans-serif`, `ui-monospace, monospace`); this page loads no web fonts. Estimate a text's width at about 0.6 × `fontSize` per character, keep important text 5 % away from the edges, and keep the bottom 60 px free (the player bar covers it in the preview, not in the file).
58
+ - **Scenes.** Stack layers with `at` / `duration` over an opaque background rect. Do not remove layers.
59
+ - **Sound with no files:** `{ type: 'audio', sfx: 'swoosh', at: 2 }`. Presets: `click pop swoosh swipe hit riser chime beep coin glitch typewriter`; optional `pitch`, `brightness`. A `riser` is loudest at its **end**: start it `duration` seconds before the hit. This page cannot load music or images from your own files; use shapes, text and sfx.
60
+ - **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 `...`.
61
+ - **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).
62
+ - **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.
63
+
64
+ ## Check your own work before you answer
65
+
66
+ Read your file once for these: every layer has a `type`; every keyframe `at` is inside its layer's lifetime; text is anchored where you meant; layer `duration`s reach the end of their scene; colours have enough contrast; nothing important sits near the edges. A mistake in the data prints a warning in the red box; the person's paste is your test run.
67
+
68
+ ## If the preview is blank
69
+
70
+ A blank preview with no red box usually means the chat's preview window blocks external scripts or does not allow `eval`. Tell the person to **save the file as `video.html` and open it in a browser** (Chrome, Edge or Firefox on a computer); that always works.
71
+
72
+ More: the full skill (`ai/SKILL.md`) and pitfalls (`ai/reference/pitfalls.md`) are for an AI with a shell and a browser; this page is the short version for a chat.
package/ai/SKILL.md CHANGED
@@ -19,7 +19,7 @@ You describe a video as a tree of plain objects; the library plays it in a canva
19
19
  - `await movie.inspect(frame)` → every layer's canvas bounds and visibility, plus `issues` (text off the canvas, cut off by an edge, empty, or overlapping other text). Run it at several frames; fix every issue it names.
20
20
  - `await movie.snapshot(frame, { as: 'dataURL' })` → one frame, canvas only (no player bar).
21
21
  - `movie.inspectAudio()` → **hear with numbers**: for every sound its `start`, `sound.length`, `sound.peakDb`, `sound.loudestAt` (line this up with the picture) and `sound.brightnessHz` (< 500 dark, > 8000 airy), plus `issues` (limited mix, inaudible or cut-off sounds). Check it whenever the movie has audio; `render()` encodes this same mix.
22
- - **A deck** (`composition.stops`, `deck()`): `await movie.contactSheet({ frames: movie.stops.map(s => s.frame), as: 'dataURL' })` is one picture of every stop (the check writes it as `stops.png`); what the audience reads while a page waits is that picture, so check every one. `await movie.stopImages({ which: 'pages' })` / `movie.exportPDF()` give the pages themselves.
22
+ - **A deck** (`composition.stops`, `deck()`): `await movie.contactSheet({ frames: movie.stops.map(s => s.frame), as: 'dataURL' })` is one picture of every stop (the check writes it as `stops.png`); what the audience reads while a page waits is that picture, so check every one. `await movie.inspectStops()` lists the stops where the picture is still changing (a stop landing before its animation ends); the check prints it as a review list: move the stop later or flag a settled one `pdf: true`. `await movie.stopImages({ which: 'pages' })` / `movie.exportPDF()` give the pages themselves.
23
23
  From a browser tool the result is a `data:` URL string: `agent-browser eval "movie.contactSheet({ count: 6, as: 'dataURL' })" | python3 ai/tools/save-image.py /absolute/sheet.png`, then open the PNG. Sheet tiles are small (~480 px): use `snapshot` for detail. `inspect` output is long on busy scenes: pass `{ layers: 'none' }` and read `issues` first. Give layers a `name` so inspect paths are readable. A moving ticker is not flagged as cut off.
24
24
  6. **Iterate** on what you saw (clipped text, collisions, wrong timing). Fast motion looks choppy: add `motionBlur: true` to `movie.init` (render, snapshot and contact sheet then show a film-style blur; renders take ~8× longer). Then export: `const blob = await movie.render({ format: 'mp4' })` (about real time; `movie.on('progress', …)` reports 0–100).
25
25
 
@@ -43,6 +43,7 @@ Centred text must be positioned by `anchorX/anchorY: 0.5`; estimate text width a
43
43
 
44
44
  ## Files
45
45
 
46
+ - `CHAT.md` and `chat-template.html` — the short version for an AI in a chat with no shell: one self-contained page that loads from a single CDN, shows warnings in a red box, and a worked example.
46
47
  - `reference/cheatsheet.md` — every layer type, prop, default and rule on one page.
47
48
  - `reference/recipes.md` — tested building blocks: slam type, lower-third, marquee, count-up, bar chart, 2.5D title, camera orbit, slideshow with transitions + music, looping, particles, gradients/vignette, three.js metal.
48
49
  - `reference/pitfalls.md` — the full list of real mistakes, with status.
@@ -0,0 +1,113 @@
1
+ <!DOCTYPE html>
2
+ <!--
3
+ pixi-effects starter for an AI that works in a CHAT (no shell, no files, a preview window or a file the person opens).
4
+ - Everything loads from cdn.jsdelivr.net only (no esm.sh, no extra stylesheet), so it also runs in sandboxed previews.
5
+ - Edit ONLY the block between "EDIT FROM HERE" and "EDIT UNTIL HERE". Keep everything else.
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
+ - 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.
9
+ Reference: https://raw.githubusercontent.com/yjmtmtk/pixi-effects/main/ai/CHAT.md
10
+ -->
11
+ <html lang="en">
12
+ <head>
13
+ <meta charset="UTF-8" />
14
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
15
+ <title>pixi-effects video</title>
16
+ <style>
17
+ body { background: #0d1220; margin: 0; padding: 16px; font-family: system-ui, sans-serif; color: #e8ecf4; }
18
+ .stage { position: relative; width: min(960px, 100%); }
19
+ .stage canvas { display: block; width: 100%; height: auto; border-radius: 8px; }
20
+ .loading { position: absolute; inset: 0; display: grid; place-items: center; letter-spacing: 0.3em; font-size: 12px; opacity: 0.6; pointer-events: none; }
21
+ #problems { display: none; max-width: 960px; margin-top: 12px; padding: 10px 12px; border: 1px solid #ff6b6b; border-radius: 8px; background: #2a1216; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; }
22
+ #problems button { float: right; margin-left: 8px; }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <div class="stage">
27
+ <canvas id="stage" width="1280" height="720"></canvas>
28
+ <div class="loading" id="loading">LOADING</div>
29
+ </div>
30
+ <div id="problems"><button id="copy" type="button">Copy</button><b>Paste this back to the AI:</b>
31
+ <span id="problems-text"></span></div>
32
+
33
+ <script>
34
+ // Every warning and error is collected in window.__logs and shown in the red box. pixi-effects warnings are INSTRUCTIONS: they say what to change.
35
+ window.__logs = [];
36
+ const show = () => {
37
+ const box = document.getElementById('problems');
38
+ box.style.display = 'block';
39
+ document.getElementById('problems-text').textContent = '\n' + window.__logs.join('\n');
40
+ };
41
+ const add = (line) => { window.__logs.push(line); show(); };
42
+ for (const k of ['warn', 'error']) {
43
+ const orig = console[k].bind(console);
44
+ console[k] = (...a) => { add(k + ': ' + a.map(x => (x && x.message) || String(x)).join(' ')); orig(...a); };
45
+ }
46
+ window.addEventListener('error', e => add('uncaught: ' + e.message));
47
+ window.addEventListener('unhandledrejection', e => add('unhandled: ' + (e.reason && e.reason.message || e.reason)));
48
+ document.getElementById('copy').onclick = () => {
49
+ const text = window.__logs.join('\n');
50
+ (navigator.clipboard ? navigator.clipboard.writeText(text) : Promise.reject()).catch(() => {
51
+ const r = document.createRange(); r.selectNodeContents(document.getElementById('problems-text')); getSelection().removeAllRanges(); getSelection().addRange(r);
52
+ });
53
+ };
54
+ window.__add = add;
55
+ </script>
56
+
57
+ <script type="importmap">
58
+ {
59
+ "imports": {
60
+ "pixi.js": "https://cdn.jsdelivr.net/npm/pixi.js@8.22.0/dist/pixi.min.mjs",
61
+ "gsap": "https://cdn.jsdelivr.net/npm/gsap@3.12.5/+esm",
62
+ "gsap/PixiPlugin": "https://cdn.jsdelivr.net/npm/gsap@3.12.5/PixiPlugin/+esm",
63
+ "mediabunny": "https://cdn.jsdelivr.net/npm/mediabunny@1.61.3/+esm",
64
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.15.0/dist/index.js",
65
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.15.0/dist/Controller.js",
66
+ "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.15.0/dist/Presenter.js"
67
+ }
68
+ }
69
+ </script>
70
+
71
+ <script type="module">
72
+ import { Movie, kenBurns, withFade, wiggle, stagger, animateText, followPath, particles, deck } from 'pixi-effects';
73
+ import { Controller } from 'pixi-effects/controller';
74
+ // A talk instead of a video: import { Presenter } from 'pixi-effects/presenter'; use deck({ pages: [...] }) and new Presenter(movie, { canvas }) instead of the Controller.
75
+
76
+ // ===================== EDIT FROM HERE =====================
77
+ const W = 1280, H = 720, FPS = 30, DURATION = 6;
78
+ const BACKGROUND = '#0a0a0f';
79
+
80
+ // The video is DATA: a list of layers. Repeated structure = a JS loop or function that returns layers.
81
+ // Keyframe `at` counts from the START OF ITS OWN LAYER. Angles are degrees. Numbers can be expressions: 'GW/2', 'GH - 80'.
82
+ const sequences = [
83
+ { type: 'shape', shape: 'rect', width: 'GW', height: 'GH', initial: { x: 'GW/2', y: 'GH/2', fillColor: BACKGROUND } },
84
+ {
85
+ type: 'text', text: 'Hello', at: 0.5, duration: DURATION - 0.5,
86
+ style: { fontSize: 120, fontWeight: 'bold', fill: '#ffffff' },
87
+ initial: { x: 'GW/2', y: 'GH/2', anchorX: 0.5, anchorY: 0.5, alpha: 0 },
88
+ keyframes: [{ at: 0, to: { alpha: 1 }, duration: 0.6, ease: 'power2.out' }],
89
+ },
90
+ ];
91
+ const POSTER = DURATION * 0.75; // the moment that stands for the video before it plays: pick the most striking one
92
+ // ===================== EDIT UNTIL HERE =====================
93
+
94
+ const movie = new Movie();
95
+ window.movie = movie;
96
+ new Controller(movie, { canvas: document.getElementById('stage') });
97
+
98
+ try {
99
+ await movie.init({
100
+ canvas: document.getElementById('stage'),
101
+ width: W, height: H, duration: DURATION, frameRate: FPS,
102
+ poster: POSTER,
103
+ background: BACKGROUND,
104
+ composition: { sequences },
105
+ });
106
+ window.__ready = true;
107
+ } catch (e) {
108
+ window.__add('init failed: ' + (e && e.message || e));
109
+ }
110
+ document.getElementById('loading').remove();
111
+ </script>
112
+ </body>
113
+ </html>
@@ -130,6 +130,7 @@ new Presenter(movie, { canvas }); // import { Presenter } from 'pixi-eff
130
130
  movie.next() / prev() / goToStop(i) / goToPage(n) / movie.stops / movie.on('stop', …) // Controller: marks on the seek bar, play / Space / click = play to the next stop (pauseAtStops: false = straight through), a Present button, PDF in the download panel
131
131
  // Presenter extras: G = page overview (pictures), P = presenter view (a second window: live picture, next picture, notes, timer); start() makes the pictures before the audience sees anything
132
132
  await movie.stopImages({ as: 'dataURL', scale: 0.25 }) // [{ stop, page, image }] one per page (its last stop); { which: 'stops' } one per stop (a page's picture honours `pdf` flags)
133
+ await movie.inspectStops() // { stops, issues }: stops whose picture is still changing (a stop landing before its animation ends); the check lists them
133
134
  await movie.exportPDF({ title }) // Blob: a PDF page per page of the deck; CLI: pixi-effects-render talk.html -o talk.pdf (--all-stops)
134
135
  const env = await audioEnvelope('music.mp3', { frameRate: 30 }) // BEFORE movie.init: .series.level|bass|mid|treble (0–1 per frame), .beats [s], .bpm, .at(t, band); bands: { name: [Hz, Hz] }
135
136
  const env = bpmEnvelope(120, { duration: 12 }) // no audio file: kick / snare / hats exactly on a tempo
@@ -81,4 +81,4 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
81
81
  54. **A deck page lasts until its last stop plus the transition.** `deck()` lays pages out one after another in page-local time and a `transition` overlaps them: a page shorter than its last stop (or shorter than the transition) loses its final step or flashes. A step is a layer that starts at the previous stop (`at: <previous stop>`), so the thing that appears on a press is fully visible when the movie stops. The transition option is `kind`, not `type`. If the last stop is mid-exit on paper, flag the stop you want in the PDF: `{ at, pdf: true }` (in `deck()`: `stops: [1.2, { at: 3, pdf: true }]`), and `pdf: false` keeps one out.
82
82
  55. **Presenter and Controller both listen to click and keys.** Use the `Presenter` for a talk (or let the Controller's Present button start one); do not run both on the same canvas yourself. `movie.play()` ignores stops, but the Controller's play button / Space / click-on-picture play to the next stop and pause (`pauseAtStops: false` plays through); `movie.next()` plays to the next stop (a second press skips to it), `prev()` jumps back (nothing plays in reverse).
83
83
  56. **Analyse audio before `init`, and give it the movie's frame rate.** `const env = await audioEnvelope('music.mp3', { frameRate })` is async; `react()` is pure and bakes the keyframes, so there is no per-frame callback. A custom `bands` list replaces the defaults (include `bass` if you want it; beats are picked there). No file yet: `bpmEnvelope(120, { duration })` is a stand-in with a kick on every beat. Offset the music with `audioOffset` when the audio layer does not start at 0.
84
- 57. **Review a deck by its stops, not by its timeline.** `pixi-effects-check` writes `stops.png` (one picture per stop, in order) for a page with `stops`; the picture at a stop is what the audience reads while it waits.
84
+ 57. **Review a deck by its stops, not by its timeline.** `pixi-effects-check` writes `stops.png` (one picture per stop, in order) for a page with `stops`; the picture at a stop is what the audience reads while it waits. A stop that lands a few frames before an animation ends shows it half-finished (also in the PDF): `movie.inspectStops()` / the check's review list names such stops; give an animation a little more time than its step (e.g. the next stop 0.3 s after it ends).
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.13.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.15.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.13.1/dist/index.js",
43
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.1/dist/Controller.js",
44
- "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.1/dist/Presenter.js",
45
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.1/dist/three.js",
42
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.15.0/dist/index.js",
43
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.15.0/dist/Controller.js",
44
+ "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.15.0/dist/Presenter.js",
45
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.15.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
  }
@@ -13,7 +13,7 @@
13
13
  * Needs: Node >= 22 (built-in WebSocket), Chrome / Chromium installed (or --chrome PATH / CHROME=PATH). No npm dependencies.
14
14
  * The page must follow ai/template.html: it exposes `window.movie` and sets `window.__ready = true` (and `window.__logs`).
15
15
  *
16
- * Options: --strict (text overlaps 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 ·
16
+ * Options: --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 ·
17
17
  * --timeout SECONDS (default 240) · --root DIR (static server root; default: the nearest folder above the page with dist/) · --chrome PATH
18
18
  */
19
19
  import { spawn } from 'node:child_process';
@@ -187,6 +187,7 @@ const exportScript = format => `(() => {
187
187
  info.video = { duration: v.duration, width: v.videoWidth, height: v.videoHeight };
188
188
  } catch (e) { info.video = { error: String(e.message || e) }; }
189
189
  try {
190
+ if (!movie.audioBuffer) throw null; // a movie with no sound has no audio track to decode: not an error
190
191
  const buf = await new OfflineAudioContext(2, 1, 48000).decodeAudioData(await blob.arrayBuffer());
191
192
  const L = buf.getChannelData(0), R = buf.getChannelData(buf.numberOfChannels > 1 ? 1 : 0), sr = buf.sampleRate;
192
193
  const rmsDb = [];
@@ -197,7 +198,7 @@ const exportScript = format => `(() => {
197
198
  rmsDb.push(Math.round(20 * Math.log10(Math.max(Math.sqrt(sum / Math.max(1, n)), 1e-6)) * 10) / 10);
198
199
  }
199
200
  info.audio = { duration: Math.round(buf.duration * 1000) / 1000, sampleRate: sr, peakDb: Math.round(20 * Math.log10(Math.max(peak, 1e-6)) * 10) / 10, rmsDbPerSecond: rmsDb };
200
- } catch (e) { info.audio = { error: String(e.message || e) }; }
201
+ } catch (e) { info.audio = e === null ? null : { error: String(e.message || e) }; }
201
202
  info.dataUrl = await new Promise(r => { const f = new FileReader(); f.onload = () => r(f.result); f.readAsDataURL(blob); });
202
203
  window.__x = { state: 'done', info };
203
204
  } catch (e) { window.__x = { state: 'error', message: String((e && e.message) || e) }; }
@@ -283,6 +284,13 @@ export async function runCheck(opts, log = console.log) {
283
284
  const stops = await cdp.eval(`(() => { const s = movie.stops || []; return s.length ? { count: s.length, pages: movie.pageCount, items: s.map(x => ({ at: Math.round(x.at * 100) / 100, frame: x.frame, page: x.page, pageStart: x.pageStart, notes: !!x.notes, pdf: x.pdf })) } : null; })()`).catch(() => null);
284
285
  if (stops) {
285
286
  report.stops = stops;
287
+ // is the picture at each stop still changing? (the audience, the page overview and a PDF would see a half-finished animation)
288
+ const settled = await cdp.eval('movie.inspectStops()').catch(() => null);
289
+ if (settled) {
290
+ report.stops.review = settled.issues;
291
+ report.stops.moving = settled.stops.map(s => Math.round(s.moving * 10000) / 10000);
292
+ if (opts.strict && settled.issues.length) report.problems.push(`${settled.issues.length} stop(s) where the picture is still changing (movie.inspectStops)`);
293
+ }
286
294
  const stopsSheet = await cdp.eval(`movie.contactSheet({ frames: movie.stops.map(s => s.frame), as: 'dataURL' })`).catch(() => null);
287
295
  if (stopsSheet) {
288
296
  const stopsFile = path.join(outDir, 'stops.png');
@@ -382,6 +390,11 @@ function finish(report, outDir, log) {
382
390
  if (report.stops) {
383
391
  const s = report.stops;
384
392
  L.push(` stops ${s.count} stop(s) on ${s.pages} page(s) — stops.png shows each one in order; the picture at a stop is what the audience sees while it waits`);
393
+ if (s.review?.length) {
394
+ L.push(` review ${s.review.length} stop(s) where the picture is still changing (may be on purpose, e.g. something always moving; use --strict to fail on them):`);
395
+ for (const r of s.review.slice(0, 4)) L.push(` - ${r}`);
396
+ if (s.review.length > 4) L.push(` … ${s.review.length - 4} more (report.json)`);
397
+ } else if (s.review) L.push(' settled every stop is a settled picture (movie.inspectStops)');
385
398
  }
386
399
  if (report.audio !== undefined) {
387
400
  const a = report.audio;
@@ -1,4 +1,4 @@
1
- import { f as Movie } from './Movie-Tl5dRLJl.cjs';
1
+ import { f as Movie } from './Movie-qmgOU4in.cjs';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
4
  import './types-CBSiUKau.cjs';
@@ -1,4 +1,4 @@
1
- import { f as Movie } from './Movie-DnR4H1OO.js';
1
+ import { f as Movie } from './Movie-BwT6XtGz.js';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
4
  import './types-CBSiUKau.js';
@@ -370,6 +370,10 @@ declare class Movie {
370
370
  snapshot(frame: number | undefined, opts: SnapshotOptions & {
371
371
  as: 'dataURL';
372
372
  }): Promise<string>;
373
+ /** Seek to `frame` (quietly, and stay there) and draw the canvas on a new canvas of `scale` times its size. */
374
+ private _frameCanvas;
375
+ /** The pixels of `frame`, small (about 320 px wide) and without motion blur, to compare pictures. */
376
+ private _framePixels;
373
377
  /** Seek to `frame` (quietly, and stay there) and encode the canvas as a picture. */
374
378
  private _captureFrame;
375
379
  /**
@@ -382,6 +386,25 @@ declare class Movie {
382
386
  image: string;
383
387
  }>>;
384
388
  stopImages(opts?: StopImagesOptions): Promise<StopImage[]>;
389
+ /**
390
+ * Look at every stop: is the picture there still changing? A stop is compared with the picture `lookback` frames before it (default 3):
391
+ * when more than `tolerance` (default 0.4 %) of the picture differs, the audience, a page overview and a PDF all see a half-finished
392
+ * animation. `issues` says which stop, which page and what to do; leaves the playhead where it was.
393
+ */
394
+ inspectStops(opts?: {
395
+ lookback?: number;
396
+ tolerance?: number;
397
+ }): Promise<{
398
+ stops: Array<{
399
+ index: number;
400
+ page: string | null;
401
+ at: number;
402
+ frame: number;
403
+ moving: number;
404
+ settled: boolean;
405
+ }>;
406
+ issues: string[];
407
+ }>;
385
408
  /**
386
409
  * The deck as a PDF: one page per page of the talk (the page's last stop, fully built, unless a stop says `pdf: true` or `pdf: false`;
387
410
  * `which: 'stops'` makes a page of every stop that is not `pdf: false`), each a
@@ -370,6 +370,10 @@ declare class Movie {
370
370
  snapshot(frame: number | undefined, opts: SnapshotOptions & {
371
371
  as: 'dataURL';
372
372
  }): Promise<string>;
373
+ /** Seek to `frame` (quietly, and stay there) and draw the canvas on a new canvas of `scale` times its size. */
374
+ private _frameCanvas;
375
+ /** The pixels of `frame`, small (about 320 px wide) and without motion blur, to compare pictures. */
376
+ private _framePixels;
373
377
  /** Seek to `frame` (quietly, and stay there) and encode the canvas as a picture. */
374
378
  private _captureFrame;
375
379
  /**
@@ -382,6 +386,25 @@ declare class Movie {
382
386
  image: string;
383
387
  }>>;
384
388
  stopImages(opts?: StopImagesOptions): Promise<StopImage[]>;
389
+ /**
390
+ * Look at every stop: is the picture there still changing? A stop is compared with the picture `lookback` frames before it (default 3):
391
+ * when more than `tolerance` (default 0.4 %) of the picture differs, the audience, a page overview and a PDF all see a half-finished
392
+ * animation. `issues` says which stop, which page and what to do; leaves the playhead where it was.
393
+ */
394
+ inspectStops(opts?: {
395
+ lookback?: number;
396
+ tolerance?: number;
397
+ }): Promise<{
398
+ stops: Array<{
399
+ index: number;
400
+ page: string | null;
401
+ at: number;
402
+ frame: number;
403
+ moving: number;
404
+ settled: boolean;
405
+ }>;
406
+ issues: string[];
407
+ }>;
385
408
  /**
386
409
  * The deck as a PDF: one page per page of the talk (the page's last stop, fully built, unless a stop says `pdf: true` or `pdf: false`;
387
410
  * `which: 'stops'` makes a page of every stop that is not `pdf: false`), each a
@@ -1,4 +1,4 @@
1
- import { f as Movie } from './Movie-Tl5dRLJl.cjs';
1
+ import { f as Movie } from './Movie-qmgOU4in.cjs';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
4
  import './types-CBSiUKau.cjs';
@@ -1,4 +1,4 @@
1
- import { f as Movie } from './Movie-DnR4H1OO.js';
1
+ import { f as Movie } from './Movie-BwT6XtGz.js';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
4
  import './types-CBSiUKau.js';
package/dist/index.cjs CHANGED
@@ -1648,6 +1648,16 @@ function pictureStops(stops, opts = {}) {
1648
1648
  }
1649
1649
  return picks;
1650
1650
  }
1651
+ function changedFraction(a, b, threshold = 12) {
1652
+ const n = Math.min(a.length, b.length) >> 2;
1653
+ if (n === 0) return 0;
1654
+ let changed = 0;
1655
+ for (let i = 0; i < n; i++) {
1656
+ const k = i << 2;
1657
+ if (Math.abs(a[k] - b[k]) > threshold || Math.abs(a[k + 1] - b[k + 1]) > threshold || Math.abs(a[k + 2] - b[k + 2]) > threshold) changed++;
1658
+ }
1659
+ return changed / n;
1660
+ }
1651
1661
 
1652
1662
  // src/core/loader.ts
1653
1663
  var FADE_FALLBACK_MS = 900;
@@ -1935,18 +1945,28 @@ var Movie = class {
1935
1945
  this._requireReady("snapshot");
1936
1946
  return this._captureFrame(clampFrame(frame, this.totalFrames), { scale: opts.scale ?? 1, type: opts.type ?? "image/png", as: opts.as ?? "blob", motionBlur: opts.motionBlur });
1937
1947
  }
1938
- /** Seek to `frame` (quietly, and stay there) and encode the canvas as a picture. */
1939
- async _captureFrame(frame, o) {
1940
- const mb = this._blurFor(o.motionBlur, "a picture");
1948
+ /** Seek to `frame` (quietly, and stay there) and draw the canvas on a new canvas of `scale` times its size. */
1949
+ async _frameCanvas(frame, scale, motionBlur) {
1950
+ const mb = this._blurFor(motionBlur, "a picture");
1941
1951
  let source = this.app.canvas;
1942
1952
  if (mb) {
1943
1953
  source = this._blurCanvas();
1944
1954
  await this._quietly(() => this._exposeFrame(frame, mb, source, true));
1945
1955
  } else await this._quietly(() => this.gotoFrame(frame, true));
1946
1956
  const out = document.createElement("canvas");
1947
- out.width = Math.max(1, Math.round(this.width * o.scale));
1948
- out.height = Math.max(1, Math.round(this.height * o.scale));
1957
+ out.width = Math.max(1, Math.round(this.width * scale));
1958
+ out.height = Math.max(1, Math.round(this.height * scale));
1949
1959
  out.getContext("2d").drawImage(source, 0, 0, out.width, out.height);
1960
+ return out;
1961
+ }
1962
+ /** The pixels of `frame`, small (about 320 px wide) and without motion blur, to compare pictures. */
1963
+ async _framePixels(frame) {
1964
+ const c = await this._frameCanvas(frame, Math.min(1, 320 / Math.max(1, this.width)), false);
1965
+ return c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
1966
+ }
1967
+ /** Seek to `frame` (quietly, and stay there) and encode the canvas as a picture. */
1968
+ async _captureFrame(frame, o) {
1969
+ const out = await this._frameCanvas(frame, o.scale, o.motionBlur);
1950
1970
  return encodeCanvas(out, o.type, o.as, o.quality);
1951
1971
  }
1952
1972
  async stopImages(opts = {}) {
@@ -1976,6 +1996,40 @@ var Movie = class {
1976
1996
  }
1977
1997
  return out;
1978
1998
  }
1999
+ /**
2000
+ * Look at every stop: is the picture there still changing? A stop is compared with the picture `lookback` frames before it (default 3):
2001
+ * when more than `tolerance` (default 0.4 %) of the picture differs, the audience, a page overview and a PDF all see a half-finished
2002
+ * animation. `issues` says which stop, which page and what to do; leaves the playhead where it was.
2003
+ */
2004
+ async inspectStops(opts = {}) {
2005
+ chunkUQBGW5DO_cjs.warnUnknownOptions("movie.inspectStops()", opts, ["lookback", "tolerance"]);
2006
+ this._requireReady("inspectStops");
2007
+ const out = { stops: [], issues: [] };
2008
+ if (this.stops.length === 0) return out;
2009
+ const lookback = Math.max(1, Math.round(opts.lookback ?? 3));
2010
+ const tolerance = opts.tolerance ?? 4e-3;
2011
+ this.pause();
2012
+ const back = this.currentFrame, wasAtPoster = this._atPoster;
2013
+ try {
2014
+ for (const stop of this.stops) {
2015
+ const before = Math.max(0, stop.frame - lookback);
2016
+ let moving = 0;
2017
+ if (before < stop.frame) {
2018
+ const now = await this._framePixels(stop.frame);
2019
+ moving = changedFraction(now, await this._framePixels(before));
2020
+ }
2021
+ const settled = moving <= tolerance;
2022
+ out.stops.push({ index: stop.index, page: stop.page, at: stop.at, frame: stop.frame, moving, settled });
2023
+ if (!settled) {
2024
+ out.issues.push(`stop ${stop.index + 1}${stop.page ? ` (page "${stop.page}")` : ""} at ${stop.at.toFixed(1)} s: the picture is still changing there (${moving < 0.1 ? (moving * 100).toFixed(1) : Math.round(moving * 100)}% of it changed in the last ${stop.frame - before} frames), so the audience, the page overview and a PDF see it mid-animation. Move the stop to after the animation has finished, or flag a settled stop of the page with \`pdf: true\`.`);
2025
+ }
2026
+ }
2027
+ } finally {
2028
+ if (wasAtPoster) await this._showPoster();
2029
+ else await this._quietly(() => this.gotoFrame(back, true));
2030
+ }
2031
+ return out;
2032
+ }
1979
2033
  /**
1980
2034
  * The deck as a PDF: one page per page of the talk (the page's last stop, fully built, unless a stop says `pdf: true` or `pdf: false`;
1981
2035
  * `which: 'stops'` makes a page of every stop that is not `pdf: false`), each a