pixi-effects 0.13.1 → 0.14.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0
4
+
5
+ **Added**
6
+
7
+ - **`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.
8
+
9
+ **Fixed**
10
+
11
+ - `pixi-effects-check` no longer prints "audio: Unable to decode audio data" for a movie with no sound: it says "no audio track".
12
+
3
13
  ## 0.13.1
4
14
 
5
15
  **Fixed**
package/README.md CHANGED
@@ -1,6 +1,6 @@
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.14.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `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
 
@@ -42,8 +42,8 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
42
42
  "gsap": "https://esm.sh/gsap@3.12.5",
43
43
  "gsap/PixiPlugin": "https://esm.sh/gsap@3.12.5/PixiPlugin",
44
44
  "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"
45
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.14.0/dist/index.js",
46
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.14.0/dist/Controller.js"
47
47
  }
48
48
  }
49
49
  </script>
@@ -54,7 +54,7 @@ Drop the imports into an [importmap](https://developer.mozilla.org/docs/Web/HTML
54
54
  </script>
55
55
  ```
56
56
 
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.
57
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.14.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
58
 
59
59
  > **Using three.js?** Add two more entries (`three` and `pixi-effects/three`) to this importmap — see [Adding three.js](#adding-threejs-optional) below.
60
60
  >
@@ -70,7 +70,7 @@ Add two more entries to the importmap above: three.js itself, and the `pixi-effe
70
70
  "imports": {
71
71
  "...": "(everything from the importmap above)",
72
72
  "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"
73
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.14.0/dist/three.js"
74
74
  }
75
75
  }
76
76
  </script>
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
 
@@ -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.14.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.14.0/dist/index.js",
43
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.14.0/dist/Controller.js",
44
+ "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.14.0/dist/Presenter.js",
45
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.14.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