pixi-effects 0.12.0 → 0.13.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ **Changed**
6
+
7
+ - **The `Controller` plays a deck stop by stop.** For a movie with `stops`, the play button, Space and a click on the picture now play to the next stop and pause there (a second press while playing pauses; after the end, play starts again from the first page). `new Controller(movie, { pauseAtStops: false })` plays straight through as before; `movie.play()` itself still ignores stops. Scrubbing and the export resume use the same rule.
8
+ - **The stop marks are easier to see**: a taller white mark with a dark outline on the seek bar, and a page start is taller still, with a dot on top.
9
+
10
+ **Added**
11
+
12
+ - **`pdf` on a stop** chooses which moment stands for a page in a PDF and in the page overview: `{ at: 4, pdf: true }` is the page's picture, `{ at: 5, pdf: false }` keeps the stop out (default: the page's last stop). With `which: 'stops'` a PDF leaves out the stops flagged `false`; a page whose stops are all `false` is left out of the PDF. `deck()` takes `stops: [1.2, { at: 3, pdf: true }]`, and `pictureStops()` is the helper behind it. `movie.stopImages({ pdf: true })` applies the same rule for your own PDFs.
13
+ - **PDF in the player bar's download panel.** For a movie with stops the format list also offers PDF (pages) and PDF (every step); the quality choice becomes the JPEG quality, and the progress bar counts pages.
14
+
3
15
  ## 0.12.0
4
16
 
5
17
  **Added**
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pixi-effects
2
2
 
3
- > **Status**: experimental — current release `0.12.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `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.13.0`. The API may still change between minor versions (see [CHANGELOG](./CHANGELOG.md): `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.12.0/dist/index.js",
46
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/Controller.js"
45
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.0/dist/index.js",
46
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.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.12.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.
57
+ Load the `dist/` files **as they are** (jsDelivr's `/npm/…/dist/…`, or unpkg's `https://unpkg.com/pixi-effects@0.13.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.12.0/dist/three.js"
73
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.0/dist/three.js"
74
74
  }
75
75
  }
76
76
  </script>
@@ -123,13 +123,13 @@ keyframes: followPath({ d: 'M 120 560 C 360 120 920 120 1160 560', duration: 4,
123
123
  ...particles({ count: 90, at: 2, emit: 0 /* 0 = burst; or seconds to spread births */, life: [1, 1.8], area: { x: 640, y: 330 /*, width, height */ }, angle: [0, 360] /* 0 right, 90 down, −90 up */,
124
124
  speed: [120, 420], gravity: 240, wind: 0, drag: 1.2, sway: { amp: 30, freq: 0.6 }, size: [2, 4.5], scale: [1, 0], fade: { in: 0.2, out: 0.6 },
125
125
  colors: ['#ffd166', '#ff6b9d'], shape: 'circle'|'rect'|'star', spin: [-200, 200], blendMode: 'add', seed: 1, name: 'burst', template: { type: 'image', asset } }) // seeded layers, paths baked into keyframes
126
- // a presentation: stops pause playback (`composition.stops: [2, { at: 5, page: 'Name', notes, advance }]`; a `page` stop begins a page, the others are steps); deck() lays pages out
126
+ // a presentation: stops pause playback (`composition.stops: [2, { at: 5, page: 'Name', notes, advance, pdf }]`; `pdf: true` = the stop that pictures its page in a PDF, `pdf: false` = keep it out; default the page's last stop; a `page` stop begins a page, the others are steps); deck() lays pages out
127
127
  await movie.init({ canvas, width, height, frameRate, ...deck({ transition: { kind: 'slide', direction: 'left', duration: 0.6 },
128
128
  pages: [{ name: 'Hello', duration: 3.2 /* last stop + transition */, stops: [2.6] /* page-local */, notes, sequences: [ /* page-local time; a step = a layer starting at the previous stop */ ] }] }) });
129
129
  new Presenter(movie, { canvas }); // import { Presenter } from 'pixi-effects/presenter': → ↓ Space Enter click = next, ← = back, Home / End, number+Enter, B / W, F, ?
130
- movie.next() / prev() / goToStop(i) / goToPage(n) / movie.stops / movie.on('stop', …) // Controller shows the stops on the seek bar + a Present button
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
- await movie.stopImages({ as: 'dataURL', scale: 0.25 }) // [{ stop, page, image }] one per page (its last stop); { which: 'stops' } one per stop
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
133
  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
134
  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
135
  const env = bpmEnvelope(120, { duration: 12 }) // no audio file: kick / snare / hats exactly on a tempo
@@ -78,7 +78,7 @@ A good habit that catches most of these: after `init`, read `window.__logs` (see
78
78
  51. **Arcs and rings are `shape: 'arc'`** (`radius`, `innerRadius`, `startAngle`, `endAngle`, degrees; 0° = 3 o'clock, clockwise). A progress ring is an arc with `startAngle: -90` and an animated `endAngle` (−90 → 270); `strokeCap: 'round'` rounds BOTH ends. Fill it for a pie slice, add `innerRadius` for a donut slice (a 120-polygon donut is no longer needed). Style (`strokeColor`, `strokeWidth`, `fillColor`) goes in `initial`; `strokeCap` / `strokeJoin` may be top level. *(fixed)*
79
79
  52. **Measure and split text with `measureText` / `splitText`, not by hand.** `splitText(text, style, { by, x, y, align })` returns each piece's x / y / width in the renderer's own font metrics (kerning kept; verified: a whole line vs the same line in per-letter layers differs only by anti-aliasing). The pieces are for text layers with the default top-left anchor and the same `style`. Load a web font before measuring (`await document.fonts.load('24px "Name"')`) or the fallback font's widths are used. Size a pill from `measureText(...).width + padding` instead of `0.58 × fontSize × length`. *(fixed)*
80
80
  53. **Draw-on and typewriter need no masks or per-letter layers.** `trimStart` / `trimEnd` (0–1 of the outline, animatable, every shape but `arc`) draw a stroke on: `trimEnd: 0` plus a keyframe `to: { trimEnd: 1 }`. The fill is not trimmed, so fade `fillAlpha` in afterwards; use `strokeCap: 'round'`. Text changes over time with `set: { text }` (swap at a time) and `visibleChars` (typewriter; left-align it so it grows rightward). A `to: { text }` cannot tween a string and warns. *(fixed)*
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`.
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. Ordinary `play()` ignores stops; `movie.next()` plays to the next stop (a second press skips to it), `prev()` jumps back (nothing plays in reverse).
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
+ 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
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.
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.12.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.13.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.12.0/dist/index.js",
43
- "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/Controller.js",
44
- "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/Presenter.js",
45
- "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.12.0/dist/three.js",
42
+ "pixi-effects": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.0/dist/index.js",
43
+ "pixi-effects/controller": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.0/dist/Controller.js",
44
+ "pixi-effects/presenter": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.0/dist/Presenter.js",
45
+ "pixi-effects/three": "https://cdn.jsdelivr.net/npm/pixi-effects@0.13.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
  }
@@ -280,7 +280,7 @@ export async function runCheck(opts, log = console.log) {
280
280
  report.files.sheet = shown(sheetFile);
281
281
 
282
282
  // a presentation (composition.stops): one picture of every stop, in order, so the pages and steps can be read at a glance
283
- 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 })) } : null; })()`).catch(() => null);
283
+ 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
284
  if (stops) {
285
285
  report.stops = stops;
286
286
  const stopsSheet = await cdp.eval(`movie.contactSheet({ frames: movie.stops.map(s => s.frame), as: 'dataURL' })`).catch(() => null);
@@ -1,5 +1,5 @@
1
1
  import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape, M as AudioDescriptor } from './types-mpqQO8MJ.cjs';
2
+ import { S as SequenceSpec, C as CompositionShape, O as AudioDescriptor } from './types-CBSiUKau.cjs';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -1,5 +1,5 @@
1
1
  import { Filter, Container, RenderTexture, Matrix } from 'pixi.js';
2
- import { S as SequenceSpec, C as CompositionShape, M as AudioDescriptor } from './types-mpqQO8MJ.js';
2
+ import { S as SequenceSpec, C as CompositionShape, O as AudioDescriptor } from './types-CBSiUKau.js';
3
3
  import { gsap } from 'gsap';
4
4
 
5
5
  /**
@@ -49,7 +49,9 @@ var CONTROLLER_CSS = `
49
49
  }
50
50
  .mc-progress:hover::before, .mc-progress.mc-scrubbing::before { height: calc(var(--mc-track-height, 3px) + 2px); }
51
51
  .mc-stops { position: absolute; left: 12px; right: 12px; top: 0; bottom: 0; pointer-events: none; }
52
- .mc-stop-tick { position: absolute; top: 50%; width: 2px; height: calc(var(--mc-track-height, 3px) + 6px); transform: translate(-50%, -50%); background: var(--mc-fg, #fff); opacity: 0.6; border-radius: 1px; }
52
+ .mc-stop-tick { position: absolute; top: 50%; width: 2px; height: calc(var(--mc-track-height, 3px) + 10px); transform: translate(-50%, -50%); background: var(--mc-fg, #fff); opacity: 0.9; border-radius: 1px; box-shadow: 0 0 0 1px rgba(0,0,0,0.55); }
53
+ .mc-stop-page { width: 4px; height: calc(var(--mc-track-height, 3px) + 20px); opacity: 1; }
54
+ .mc-stop-page::before { content: ''; position: absolute; left: 50%; top: -3px; width: 8px; height: 8px; transform: translateX(-50%); border-radius: 50%; background: var(--mc-fg, #fff); box-shadow: 0 0 0 1px rgba(0,0,0,0.55); }
53
55
  .mc-progress-fill {
54
56
  position: absolute; left: 12px; top: 50%;
55
57
  height: var(--mc-track-height, 3px);
@@ -271,8 +273,10 @@ function pxToFraction(clientX, rect, inset = 0) {
271
273
  const ratio = (clientX - innerLeft) / innerWidth;
272
274
  return Math.min(Math.max(ratio, 0), 1);
273
275
  }
276
+ var PDF_QUALITY = { "low": 0.6, "medium": 0.75, "high": 0.88, "very-high": 0.95 };
274
277
  function extensionForMimeType(mime) {
275
278
  const t = (mime || "").toLowerCase();
279
+ if (t.includes("pdf")) return "pdf";
276
280
  if (t.includes("webm")) return "webm";
277
281
  if (t.includes("quicktime") || t.includes("mov")) return "mov";
278
282
  if (t.includes("matroska") || t.includes("mkv")) return "mkv";
@@ -319,6 +323,7 @@ var Controller = class _Controller {
319
323
  wasPlayingBeforeScrub = false;
320
324
  activePointerId = null;
321
325
  isExporting = false;
326
+ exportPages = null;
322
327
  exportOverlay = null;
323
328
  exportFillEl = null;
324
329
  exportTextEl = null;
@@ -359,6 +364,7 @@ var Controller = class _Controller {
359
364
  enableKeyboardShortcuts: options.enableKeyboardShortcuts ?? true,
360
365
  clickToPlay: options.clickToPlay ?? true,
361
366
  present: options.present ?? true,
367
+ pauseAtStops: options.pauseAtStops ?? true,
362
368
  className: options.className ?? "movie-controller"
363
369
  };
364
370
  installStyles();
@@ -566,6 +572,7 @@ var Controller = class _Controller {
566
572
  refreshStops() {
567
573
  this.progressEl.querySelector(".mc-stops")?.remove();
568
574
  const stops = this.movie.stops ?? [];
575
+ this.refreshPdfChoices(stops.length > 0);
569
576
  if (stops.length === 0) {
570
577
  this.presentBtn?.remove();
571
578
  this.presentBtn = null;
@@ -576,7 +583,7 @@ var Controller = class _Controller {
576
583
  holder.className = "mc-stops";
577
584
  for (const s of stops) {
578
585
  const tick = document.createElement("div");
579
- tick.className = "mc-stop-tick";
586
+ tick.className = s.pageStart ? "mc-stop-tick mc-stop-page" : "mc-stop-tick";
580
587
  tick.style.left = `${s.frame / total * 100}%`;
581
588
  if (s.page) tick.title = s.page;
582
589
  holder.appendChild(tick);
@@ -595,6 +602,41 @@ var Controller = class _Controller {
595
602
  this.presentBtn = btn;
596
603
  }
597
604
  }
605
+ /** A deck can be downloaded as a PDF: the format list gets "PDF" choices while the movie has stops. */
606
+ refreshPdfChoices(has) {
607
+ const select = this.settingsFormatSelect;
608
+ if (!select) return;
609
+ const present = !!select.querySelector('option[value="pdf"]');
610
+ if (has && !present) {
611
+ for (const [value, label] of [["pdf", "PDF (pages)"], ["pdf-steps", "PDF (every step)"]]) {
612
+ const o = document.createElement("option");
613
+ o.value = value;
614
+ o.textContent = label;
615
+ select.appendChild(o);
616
+ }
617
+ } else if (!has && present) {
618
+ select.querySelectorAll('option[value="pdf"], option[value="pdf-steps"]').forEach((o) => o.remove());
619
+ if (this.exportFormat === "pdf" || this.exportFormat === "pdf-steps") {
620
+ this.exportFormat = "mp4";
621
+ select.value = "mp4";
622
+ }
623
+ }
624
+ }
625
+ /** Play, or pause: a movie with stops plays to the next stop (and from the end, again from the start). */
626
+ async togglePlay() {
627
+ if (this.movie.isPlaying) {
628
+ this.movie.pause();
629
+ return;
630
+ }
631
+ await this.startPlaying();
632
+ }
633
+ /** Start playing: to the next stop for a movie with stops, to the end for any other (or with `pauseAtStops: false`). */
634
+ async startPlaying() {
635
+ if (this.options.pauseAtStops && (this.movie.stops?.length ?? 0) > 0) {
636
+ if (this.movie.currentFrame >= this.movie.totalFrames) await this.movie.gotoFrame(0, true);
637
+ await this.movie.next();
638
+ } else this.movie.play();
639
+ }
598
640
  /** Hand the page over to a Presenter (fullscreen, keys, click and swipe move through the stops); Escape gives it back. */
599
641
  async startPresenting() {
600
642
  if (this.presenter || this.destroyed) return;
@@ -611,11 +653,7 @@ var Controller = class _Controller {
611
653
  }
612
654
  bindPlayButton() {
613
655
  this.playBtn.addEventListener("click", () => {
614
- if (this.movie.isPlaying) {
615
- this.movie.pause();
616
- } else {
617
- this.movie.play();
618
- }
656
+ void this.togglePlay();
619
657
  });
620
658
  }
621
659
  /** Clicking the picture plays / pauses (a tap on a phone too). */
@@ -632,8 +670,7 @@ var Controller = class _Controller {
632
670
  this.swallowCanvasClick = false;
633
671
  return;
634
672
  }
635
- if (this.movie.isPlaying) this.movie.pause();
636
- else this.movie.play();
673
+ void this.togglePlay();
637
674
  };
638
675
  canvas.addEventListener("pointerdown", this.canvasPointerDownHandler);
639
676
  canvas.addEventListener("click", this.canvasClickHandler);
@@ -789,7 +826,7 @@ var Controller = class _Controller {
789
826
  } catch {
790
827
  }
791
828
  if (this.wasPlayingBeforeScrub) {
792
- this.movie.play();
829
+ void this.startPlaying();
793
830
  this.refreshPlayIcon();
794
831
  }
795
832
  };
@@ -838,7 +875,7 @@ var Controller = class _Controller {
838
875
  if (this.settingsFormatSelect) {
839
876
  this.settingsFormatSelect.addEventListener("change", (e) => {
840
877
  const v = e.target.value;
841
- if (v === "mp4" || v === "webm" || v === "mov") this.exportFormat = v;
878
+ if (v === "mp4" || v === "webm" || v === "mov" || v === "pdf" || v === "pdf-steps") this.exportFormat = v;
842
879
  });
843
880
  }
844
881
  if (this.settingsQualitySelect) {
@@ -888,9 +925,19 @@ var Controller = class _Controller {
888
925
  this.movie.pause();
889
926
  this.refreshPlayIcon();
890
927
  }
891
- this.showExportOverlay();
928
+ const pdf = this.exportFormat === "pdf" || this.exportFormat === "pdf-steps";
929
+ if (pdf) {
930
+ const stops = this.movie.stops ?? [];
931
+ const total = this.exportFormat === "pdf-steps" ? stops.filter((s) => s.pdf !== false).length : new Set(stops.map((s) => s.pageIndex)).size;
932
+ this.showExportOverlay("Exporting PDF...", `0% (0 / ${total} pages)`);
933
+ this.exportPages = { done: 0, total };
934
+ } else this.showExportOverlay();
892
935
  try {
893
- const blob = await this.movie.render({
936
+ const blob = pdf ? await this.movie.exportPDF({
937
+ which: this.exportFormat === "pdf-steps" ? "stops" : "pages",
938
+ quality: PDF_QUALITY[this.exportQuality],
939
+ onImage: () => this.pdfPageDone()
940
+ }) : await this.movie.render({
894
941
  format: this.exportFormat,
895
942
  video: { bitrate: this.exportQuality },
896
943
  audio: { bitrate: this.exportQuality }
@@ -906,7 +953,7 @@ var Controller = class _Controller {
906
953
  this.hideExportOverlay();
907
954
  this.isExporting = false;
908
955
  if (wasPlaying) {
909
- this.movie.play();
956
+ void this.startPlaying();
910
957
  this.refreshPlayIcon();
911
958
  }
912
959
  }
@@ -929,16 +976,27 @@ var Controller = class _Controller {
929
976
  const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
930
977
  return `movie-${ts}.${ext}`;
931
978
  }
932
- showExportOverlay() {
979
+ /** One more PDF page is ready: move the bar. */
980
+ pdfPageDone() {
981
+ const p = this.exportPages;
982
+ if (!p || !this.exportFillEl || !this.exportTextEl) return;
983
+ p.done++;
984
+ const percent = p.total ? Math.min(100, Math.round(p.done / p.total * 100)) : 100;
985
+ this.exportFillEl.style.width = `${percent}%`;
986
+ this.exportTextEl.textContent = `${percent}% (${p.done} / ${p.total} pages)`;
987
+ }
988
+ showExportOverlay(title = "Exporting video...", text = "0% (0 / 0 frames)") {
933
989
  const overlay = document.createElement("div");
934
990
  overlay.className = "mc-export-overlay";
935
991
  overlay.innerHTML = `
936
992
  <div class="mc-export-panel">
937
- <div class="mc-export-title">Exporting video...</div>
993
+ <div class="mc-export-title"></div>
938
994
  <div class="mc-export-track"><div class="mc-export-fill"></div></div>
939
- <div class="mc-export-text">0% (0 / 0 frames)</div>
995
+ <div class="mc-export-text"></div>
940
996
  </div>
941
997
  `;
998
+ overlay.querySelector(".mc-export-title").textContent = title;
999
+ overlay.querySelector(".mc-export-text").textContent = text;
942
1000
  this.root.appendChild(overlay);
943
1001
  this.exportOverlay = overlay;
944
1002
  this.exportFillEl = overlay.querySelector(".mc-export-fill");
@@ -951,6 +1009,7 @@ var Controller = class _Controller {
951
1009
  this.exportFillEl = null;
952
1010
  this.exportTextEl = null;
953
1011
  }
1012
+ this.exportPages = null;
954
1013
  }
955
1014
  bindVisibility() {
956
1015
  const wrap = this.wrapper;