pixi-effects 0.11.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +11 -8
  3. package/ai/SKILL.md +2 -1
  4. package/ai/reference/cheatsheet.md +12 -1
  5. package/ai/reference/pitfalls.md +4 -0
  6. package/ai/reference/recipes.md +57 -0
  7. package/ai/template.html +8 -4
  8. package/ai/tools/check.mjs +17 -1
  9. package/ai/tools/render.mjs +13 -9
  10. package/dist/{Base-CwUxTfja.d.cts → Base-C6ZnJ9pD.d.cts} +1 -1
  11. package/dist/{Base-CyvSLoCP.d.ts → Base-CQOlB2VH.d.ts} +1 -1
  12. package/dist/Controller.cjs +132 -15
  13. package/dist/Controller.cjs.map +1 -1
  14. package/dist/Controller.d.cts +25 -3
  15. package/dist/Controller.d.ts +25 -3
  16. package/dist/Controller.js +132 -15
  17. package/dist/Controller.js.map +1 -1
  18. package/dist/{Movie-DSSNOF3T.d.cts → Movie-DnR4H1OO.d.ts} +93 -2
  19. package/dist/{Movie-nzmWsxWX.d.ts → Movie-Tl5dRLJl.d.cts} +93 -2
  20. package/dist/Presenter.cjs +671 -0
  21. package/dist/Presenter.cjs.map +1 -0
  22. package/dist/Presenter.d.cts +123 -0
  23. package/dist/Presenter.d.ts +123 -0
  24. package/dist/Presenter.js +669 -0
  25. package/dist/Presenter.js.map +1 -0
  26. package/dist/index.cjs +665 -36
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +165 -5
  29. package/dist/index.d.ts +165 -5
  30. package/dist/index.js +659 -37
  31. package/dist/index.js.map +1 -1
  32. package/dist/loader.css +1 -1
  33. package/dist/three.d.cts +2 -2
  34. package/dist/three.d.ts +2 -2
  35. package/dist/{types-BXu28nJj.d.cts → types-CBSiUKau.d.cts} +56 -1
  36. package/dist/{types-BXu28nJj.d.ts → types-CBSiUKau.d.ts} +56 -1
  37. package/docs/api.md +80 -0
  38. package/docs/dsl.md +48 -0
  39. package/llms-full.txt +203 -2
  40. package/llms.txt +3 -3
  41. package/package.json +5 -1
@@ -1,10 +1,10 @@
1
- import { f as Movie } from './Movie-nzmWsxWX.js';
1
+ import { f as Movie } from './Movie-DnR4H1OO.js';
2
2
  import 'pixi.js';
3
3
  import 'gsap';
4
- import './types-BXu28nJj.js';
4
+ import './types-CBSiUKau.js';
5
5
 
6
6
  /** The bar's default stylesheet. Its colours and sizes are CSS custom properties (`--mc-accent`, `--mc-fg`, `--mc-track`, `--mc-bar-bg`, `--mc-track-height`, `--mc-font`): set them in your page CSS, or with the `theme` option. */
7
- declare const CONTROLLER_CSS = "\n.movie-controller-wrap { position: relative; display: inline-block; line-height: 0; }\n.movie-controller {\n position: absolute;\n pointer-events: none;\n font-family: var(--mc-font, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif);\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n opacity: 1;\n transition: opacity 200ms ease;\n}\n.movie-controller[data-state=\"hidden\"] { opacity: 0; }\n.movie-controller > * { pointer-events: auto; }\n\n.mc-progress {\n position: relative;\n width: 100%;\n height: 16px;\n cursor: pointer;\n display: flex;\n align-items: center;\n outline: none;\n}\n.mc-progress:focus-visible {\n outline: 2px solid var(--mc-accent, #007AFF);\n outline-offset: 2px;\n}\n.mc-progress::before {\n content: \"\"; position: absolute; left: 12px; right: 12px;\n height: var(--mc-track-height, 3px); background: var(--mc-track, rgba(255,255,255,0.25));\n transition: height 120ms ease;\n}\n.mc-progress:hover::before, .mc-progress.mc-scrubbing::before { height: calc(var(--mc-track-height, 3px) + 2px); }\n.mc-progress-fill {\n position: absolute; left: 12px; top: 50%;\n height: var(--mc-track-height, 3px);\n width: calc((100% - 24px) * var(--mc-fill, 0));\n background: var(--mc-accent, #007AFF);\n transform: translateY(-50%);\n transition: height 120ms ease;\n pointer-events: none;\n}\n.mc-progress:hover .mc-progress-fill,\n.mc-progress.mc-scrubbing .mc-progress-fill { height: calc(var(--mc-track-height, 3px) + 2px); }\n.mc-progress-thumb {\n position: absolute; top: 50%;\n left: calc(12px + (100% - 24px) * var(--mc-fill, 0));\n width: 12px; height: 12px; border-radius: 50%;\n background: var(--mc-accent, #007AFF);\n transform: translate(-50%, -50%) scale(0);\n transition: transform 120ms ease;\n pointer-events: none;\n}\n.mc-progress:hover .mc-progress-thumb,\n.mc-progress.mc-scrubbing .mc-progress-thumb { transform: translate(-50%, -50%) scale(1); }\n\n.mc-bar {\n position: relative;\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 6px 12px 8px 12px;\n background: var(--mc-bar-bg, linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0) 100%));\n color: var(--mc-fg, #fff);\n line-height: 1;\n}\n.mc-btn {\n background: none; border: 0; padding: 0; margin: 0;\n width: 28px; height: 28px;\n display: inline-flex; align-items: center; justify-content: center;\n color: var(--mc-fg, #fff); opacity: 0.85; cursor: pointer;\n transition: opacity 120ms ease;\n}\n.mc-btn:hover { opacity: 1; }\n.mc-btn:disabled { opacity: 0.4; cursor: not-allowed; }\n.mc-time {\n font-size: 12px; font-family: Menlo, Monaco, monospace;\n color: var(--mc-fg, #fff); opacity: 0.85;\n min-width: 90px;\n}\n.mc-spacer { flex: 1; }\n\n.mc-volume { display: flex; align-items: center; }\n.mc-vol-slider {\n position: relative;\n width: 0;\n height: 28px;\n display: flex;\n align-items: center;\n cursor: pointer;\n margin-left: 0;\n outline: none;\n transition: width 200ms ease, margin-left 200ms ease;\n overflow: hidden;\n}\n.mc-volume:hover .mc-vol-slider,\n.mc-volume:focus-within .mc-vol-slider,\n.mc-vol-slider.mc-scrubbing {\n width: 70px;\n margin-left: 6px;\n}\n.mc-vol-slider:focus-visible { outline: 2px solid var(--mc-accent, #007AFF); outline-offset: 2px; }\n.mc-vol-slider::before {\n content: \"\"; position: absolute; left: 4px; right: 4px;\n height: 3px; background: var(--mc-track, rgba(255,255,255,0.25)); border-radius: 2px;\n}\n.mc-vol-fill {\n position: absolute; left: 4px; top: 50%;\n height: 3px;\n width: calc((100% - 8px) * var(--mc-volume, 1));\n background: var(--mc-fg, #fff);\n transform: translateY(-50%);\n border-radius: 2px;\n pointer-events: none;\n}\n.mc-vol-thumb {\n position: absolute; top: 50%;\n left: calc(4px + (100% - 8px) * var(--mc-volume, 1));\n width: 10px; height: 10px; border-radius: 50%;\n background: var(--mc-fg, #fff);\n transform: translate(-50%, -50%);\n pointer-events: none;\n}\n\n[data-mc-wrap]:fullscreen {\n width: 100vw; height: 100vh;\n display: flex; align-items: center; justify-content: center;\n background: #000;\n}\n[data-mc-wrap]:fullscreen > canvas {\n width: 100% !important;\n height: 100% !important;\n max-width: none !important;\n max-height: none !important;\n object-fit: contain;\n border-radius: 0;\n}\n\n.mc-settings-popover {\n position: absolute;\n bottom: calc(100% + 8px);\n right: 12px;\n background: rgba(20, 20, 20, 0.96);\n border-radius: 8px;\n padding: 10px 12px;\n min-width: 180px;\n color: #fff;\n font-size: 12px;\n box-shadow: 0 4px 16px rgba(0,0,0,0.4);\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n.mc-settings-popover[data-open=\"false\"] { display: none; }\n.mc-settings-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n.mc-settings-row > span { color: rgba(255,255,255,0.85); }\n.mc-settings-row > select {\n font: inherit;\n background: rgba(255,255,255,0.1);\n color: #fff;\n border: 1px solid rgba(255,255,255,0.18);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n}\n.mc-export-confirm {\n margin-top: 4px;\n width: 100%;\n background: var(--mc-accent, #007AFF);\n color: #fff;\n border: 0;\n border-radius: 4px;\n padding: 6px 10px;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n transition: background 120ms ease;\n}\n.mc-export-confirm:hover { background: #0066d6; }\n.mc-export-confirm:disabled { background: rgba(255,255,255,0.18); cursor: not-allowed; }\n\n.mc-export-overlay {\n position: absolute; inset: 0;\n background: rgba(0,0,0,0.7);\n display: flex; justify-content: center; align-items: center;\n z-index: 2;\n}\n.mc-export-panel {\n background: rgba(20,20,20,0.96); color: #fff;\n border-radius: 8px; padding: 16px 20px;\n text-align: center; min-width: 200px; max-width: 80%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n}\n.mc-export-title { font-size: 13px; margin-bottom: 10px; opacity: 0.9; }\n.mc-export-track {\n width: 100%; height: 6px;\n background: rgba(255,255,255,0.15); border-radius: 3px;\n margin-bottom: 8px; overflow: hidden;\n}\n.mc-export-fill {\n height: 100%; width: 0%;\n background: linear-gradient(90deg, var(--mc-accent, #007AFF), var(--mc-accent, #0056CC));\n transition: width 0.3s ease; border-radius: 3px;\n}\n.mc-export-text {\n color: rgba(255,255,255,0.7); font-size: 11px;\n font-family: Menlo, Monaco, monospace;\n}\n";
7
+ declare const CONTROLLER_CSS = "\n.movie-controller-wrap { position: relative; display: inline-block; line-height: 0; }\n.movie-controller {\n position: absolute;\n pointer-events: none;\n font-family: var(--mc-font, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif);\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n opacity: 1;\n transition: opacity 200ms ease;\n}\n.movie-controller[data-state=\"hidden\"] { opacity: 0; }\n.movie-controller > * { pointer-events: auto; }\n\n.mc-progress {\n position: relative;\n width: 100%;\n height: 16px;\n cursor: pointer;\n display: flex;\n align-items: center;\n outline: none;\n}\n.mc-progress:focus-visible {\n outline: 2px solid var(--mc-accent, #007AFF);\n outline-offset: 2px;\n}\n.mc-progress::before {\n content: \"\"; position: absolute; left: 12px; right: 12px;\n height: var(--mc-track-height, 3px); background: var(--mc-track, rgba(255,255,255,0.25));\n transition: height 120ms ease;\n}\n.mc-progress:hover::before, .mc-progress.mc-scrubbing::before { height: calc(var(--mc-track-height, 3px) + 2px); }\n.mc-stops { position: absolute; left: 12px; right: 12px; top: 0; bottom: 0; pointer-events: none; }\n.mc-stop-tick { position: absolute; top: 50%; width: 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); }\n.mc-stop-page { width: 4px; height: calc(var(--mc-track-height, 3px) + 20px); opacity: 1; }\n.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); }\n.mc-progress-fill {\n position: absolute; left: 12px; top: 50%;\n height: var(--mc-track-height, 3px);\n width: calc((100% - 24px) * var(--mc-fill, 0));\n background: var(--mc-accent, #007AFF);\n transform: translateY(-50%);\n transition: height 120ms ease;\n pointer-events: none;\n}\n.mc-progress:hover .mc-progress-fill,\n.mc-progress.mc-scrubbing .mc-progress-fill { height: calc(var(--mc-track-height, 3px) + 2px); }\n.mc-progress-thumb {\n position: absolute; top: 50%;\n left: calc(12px + (100% - 24px) * var(--mc-fill, 0));\n width: 12px; height: 12px; border-radius: 50%;\n background: var(--mc-accent, #007AFF);\n transform: translate(-50%, -50%) scale(0);\n transition: transform 120ms ease;\n pointer-events: none;\n}\n.mc-progress:hover .mc-progress-thumb,\n.mc-progress.mc-scrubbing .mc-progress-thumb { transform: translate(-50%, -50%) scale(1); }\n\n.mc-bar {\n position: relative;\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 6px 12px 8px 12px;\n background: var(--mc-bar-bg, linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0) 100%));\n color: var(--mc-fg, #fff);\n line-height: 1;\n}\n.mc-btn {\n background: none; border: 0; padding: 0; margin: 0;\n width: 28px; height: 28px;\n display: inline-flex; align-items: center; justify-content: center;\n color: var(--mc-fg, #fff); opacity: 0.85; cursor: pointer;\n transition: opacity 120ms ease;\n}\n.mc-btn:hover { opacity: 1; }\n.mc-btn:disabled { opacity: 0.4; cursor: not-allowed; }\n.mc-time {\n font-size: 12px; font-family: Menlo, Monaco, monospace;\n color: var(--mc-fg, #fff); opacity: 0.85;\n min-width: 90px;\n}\n.mc-spacer { flex: 1; }\n\n.mc-volume { display: flex; align-items: center; }\n.mc-vol-slider {\n position: relative;\n width: 0;\n height: 28px;\n display: flex;\n align-items: center;\n cursor: pointer;\n margin-left: 0;\n outline: none;\n transition: width 200ms ease, margin-left 200ms ease;\n overflow: hidden;\n}\n.mc-volume:hover .mc-vol-slider,\n.mc-volume:focus-within .mc-vol-slider,\n.mc-vol-slider.mc-scrubbing {\n width: 70px;\n margin-left: 6px;\n}\n.mc-vol-slider:focus-visible { outline: 2px solid var(--mc-accent, #007AFF); outline-offset: 2px; }\n.mc-vol-slider::before {\n content: \"\"; position: absolute; left: 4px; right: 4px;\n height: 3px; background: var(--mc-track, rgba(255,255,255,0.25)); border-radius: 2px;\n}\n.mc-vol-fill {\n position: absolute; left: 4px; top: 50%;\n height: 3px;\n width: calc((100% - 8px) * var(--mc-volume, 1));\n background: var(--mc-fg, #fff);\n transform: translateY(-50%);\n border-radius: 2px;\n pointer-events: none;\n}\n.mc-vol-thumb {\n position: absolute; top: 50%;\n left: calc(4px + (100% - 8px) * var(--mc-volume, 1));\n width: 10px; height: 10px; border-radius: 50%;\n background: var(--mc-fg, #fff);\n transform: translate(-50%, -50%);\n pointer-events: none;\n}\n\n[data-mc-wrap]:fullscreen {\n width: 100vw; height: 100vh;\n display: flex; align-items: center; justify-content: center;\n background: #000;\n}\n[data-mc-wrap]:fullscreen > canvas {\n width: 100% !important;\n height: 100% !important;\n max-width: none !important;\n max-height: none !important;\n object-fit: contain;\n border-radius: 0;\n}\n\n.mc-settings-popover {\n position: absolute;\n bottom: calc(100% + 8px);\n right: 12px;\n background: rgba(20, 20, 20, 0.96);\n border-radius: 8px;\n padding: 10px 12px;\n min-width: 180px;\n color: #fff;\n font-size: 12px;\n box-shadow: 0 4px 16px rgba(0,0,0,0.4);\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n.mc-settings-popover[data-open=\"false\"] { display: none; }\n.mc-settings-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n}\n.mc-settings-row > span { color: rgba(255,255,255,0.85); }\n.mc-settings-row > select {\n font: inherit;\n background: rgba(255,255,255,0.1);\n color: #fff;\n border: 1px solid rgba(255,255,255,0.18);\n border-radius: 4px;\n padding: 2px 6px;\n cursor: pointer;\n}\n.mc-export-confirm {\n margin-top: 4px;\n width: 100%;\n background: var(--mc-accent, #007AFF);\n color: #fff;\n border: 0;\n border-radius: 4px;\n padding: 6px 10px;\n font: inherit;\n font-weight: 600;\n cursor: pointer;\n transition: background 120ms ease;\n}\n.mc-export-confirm:hover { background: #0066d6; }\n.mc-export-confirm:disabled { background: rgba(255,255,255,0.18); cursor: not-allowed; }\n\n.mc-export-overlay {\n position: absolute; inset: 0;\n background: rgba(0,0,0,0.7);\n display: flex; justify-content: center; align-items: center;\n z-index: 2;\n}\n.mc-export-panel {\n background: rgba(20,20,20,0.96); color: #fff;\n border-radius: 8px; padding: 16px 20px;\n text-align: center; min-width: 200px; max-width: 80%;\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n}\n.mc-export-title { font-size: 13px; margin-bottom: 10px; opacity: 0.9; }\n.mc-export-track {\n width: 100%; height: 6px;\n background: rgba(255,255,255,0.15); border-radius: 3px;\n margin-bottom: 8px; overflow: hidden;\n}\n.mc-export-fill {\n height: 100%; width: 0%;\n background: linear-gradient(90deg, var(--mc-accent, #007AFF), var(--mc-accent, #0056CC));\n transition: width 0.3s ease; border-radius: 3px;\n}\n.mc-export-text {\n color: rgba(255,255,255,0.7); font-size: 11px;\n font-family: Menlo, Monaco, monospace;\n}\n";
8
8
  declare function formatTime(seconds: number): string;
9
9
  declare function frameToPercent(frame: number, totalFrames: number): number;
10
10
  declare function pxToFrame(clientX: number, rect: {
@@ -42,6 +42,10 @@ interface ControllerOptions {
42
42
  enableKeyboardShortcuts?: boolean;
43
43
  /** A click (or tap) on the picture plays / pauses, like a `<video>` element. Default true; `false` leaves the canvas alone. */
44
44
  clickToPlay?: boolean;
45
+ /** A movie with `stops` gets a Present button that starts a {@link Presenter} (keys, click and swipe move through the stops). Default true; `false` keeps only the marks on the seek bar. */
46
+ present?: boolean;
47
+ /** A movie with `stops`: the play button (and a click on the picture, and Space) plays to the next stop and pauses there, like a slide. Default true; `false` plays straight through to the end. */
48
+ pauseAtStops?: boolean;
45
49
  className?: string;
46
50
  }
47
51
  interface ResolvedOptions {
@@ -49,6 +53,8 @@ interface ResolvedOptions {
49
53
  showExportButton: boolean;
50
54
  enableKeyboardShortcuts: boolean;
51
55
  clickToPlay: boolean;
56
+ present: boolean;
57
+ pauseAtStops: boolean;
52
58
  className: string;
53
59
  }
54
60
  declare class Controller {
@@ -84,6 +90,7 @@ declare class Controller {
84
90
  private wasPlayingBeforeScrub;
85
91
  private activePointerId;
86
92
  private isExporting;
93
+ private exportPages;
87
94
  private exportOverlay;
88
95
  private exportFillEl;
89
96
  private exportTextEl;
@@ -91,6 +98,8 @@ declare class Controller {
91
98
  private settingsKeyHandler;
92
99
  private settingsOutsideHandler;
93
100
  private onReady;
101
+ private presenter;
102
+ private presentBtn;
94
103
  private onFrame;
95
104
  private onPlay;
96
105
  private onPause;
@@ -125,6 +134,17 @@ declare class Controller {
125
134
  private fitWrap;
126
135
  private bindFitWrap;
127
136
  private bindMovieEvents;
137
+ /** The stops of a presentation as marks on the seek bar, and the Present button. */
138
+ private refreshStops;
139
+ /** A deck can be downloaded as a PDF: the format list gets "PDF" choices while the movie has stops. */
140
+ private refreshPdfChoices;
141
+ /** Play, or pause: a movie with stops plays to the next stop (and from the end, again from the start). */
142
+ private togglePlay;
143
+ /** Start playing: to the next stop for a movie with stops, to the end for any other (or with `pauseAtStops: false`). */
144
+ private startPlaying;
145
+ /** Hand the page over to a Presenter (fullscreen, keys, click and swipe move through the stops); Escape gives it back. */
146
+ private startPresenting;
147
+ private stopPresenting;
128
148
  private bindPlayButton;
129
149
  /** Clicking the picture plays / pauses (a tap on a phone too). */
130
150
  private bindCanvasClick;
@@ -150,6 +170,8 @@ declare class Controller {
150
170
  private handleExport;
151
171
  private triggerDownload;
152
172
  private makeFilename;
173
+ /** One more PDF page is ready: move the bar. */
174
+ private pdfPageDone;
153
175
  private showExportOverlay;
154
176
  private hideExportOverlay;
155
177
  private bindVisibility;
@@ -8,6 +8,7 @@ var ICONS = {
8
8
  volumeOff: '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M2 6 H5 L9 2 V14 L5 10 H2 Z"/><path d="M11 5 L15 11 M15 5 L11 11" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>',
9
9
  download: '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2 V11 M4 7 L8 11 L12 7 M3 13 H13"/></svg>',
10
10
  fullscreenEnter: '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 6 V2 H6 M14 6 V2 H10 M2 10 V14 H6 M14 10 V14 H10"/></svg>',
11
+ present: '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 2.5 H14 V10.5 H2 Z M8 10.5 V13.5 M5 13.5 H11"/></svg>',
11
12
  fullscreenExit: '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 V6 H2 M10 2 V6 H14 M6 14 V10 H2 M10 14 V10 H14"/></svg>'
12
13
  };
13
14
  var STYLE_ATTR = "data-movie-controller";
@@ -45,6 +46,10 @@ var CONTROLLER_CSS = `
45
46
  transition: height 120ms ease;
46
47
  }
47
48
  .mc-progress:hover::before, .mc-progress.mc-scrubbing::before { height: calc(var(--mc-track-height, 3px) + 2px); }
49
+ .mc-stops { position: absolute; left: 12px; right: 12px; top: 0; bottom: 0; pointer-events: none; }
50
+ .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); }
51
+ .mc-stop-page { width: 4px; height: calc(var(--mc-track-height, 3px) + 20px); opacity: 1; }
52
+ .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); }
48
53
  .mc-progress-fill {
49
54
  position: absolute; left: 12px; top: 50%;
50
55
  height: var(--mc-track-height, 3px);
@@ -266,8 +271,10 @@ function pxToFraction(clientX, rect, inset = 0) {
266
271
  const ratio = (clientX - innerLeft) / innerWidth;
267
272
  return Math.min(Math.max(ratio, 0), 1);
268
273
  }
274
+ var PDF_QUALITY = { "low": 0.6, "medium": 0.75, "high": 0.88, "very-high": 0.95 };
269
275
  function extensionForMimeType(mime) {
270
276
  const t = (mime || "").toLowerCase();
277
+ if (t.includes("pdf")) return "pdf";
271
278
  if (t.includes("webm")) return "webm";
272
279
  if (t.includes("quicktime") || t.includes("mov")) return "mov";
273
280
  if (t.includes("matroska") || t.includes("mkv")) return "mkv";
@@ -314,6 +321,7 @@ var Controller = class _Controller {
314
321
  wasPlayingBeforeScrub = false;
315
322
  activePointerId = null;
316
323
  isExporting = false;
324
+ exportPages = null;
317
325
  exportOverlay = null;
318
326
  exportFillEl = null;
319
327
  exportTextEl = null;
@@ -321,6 +329,8 @@ var Controller = class _Controller {
321
329
  settingsKeyHandler = null;
322
330
  settingsOutsideHandler = null;
323
331
  onReady = null;
332
+ presenter = null;
333
+ presentBtn = null;
324
334
  onFrame = null;
325
335
  onPlay = null;
326
336
  onPause = null;
@@ -351,6 +361,8 @@ var Controller = class _Controller {
351
361
  showExportButton: options.showExportButton ?? true,
352
362
  enableKeyboardShortcuts: options.enableKeyboardShortcuts ?? true,
353
363
  clickToPlay: options.clickToPlay ?? true,
364
+ present: options.present ?? true,
365
+ pauseAtStops: options.pauseAtStops ?? true,
354
366
  className: options.className ?? "movie-controller"
355
367
  };
356
368
  installStyles();
@@ -369,6 +381,7 @@ var Controller = class _Controller {
369
381
  }
370
382
  this.root.setAttribute("data-state", "visible");
371
383
  this.bindMovieEvents();
384
+ this.refreshStops();
372
385
  this.bindPlayButton();
373
386
  if (this.options.clickToPlay) this.bindCanvasClick();
374
387
  this.bindScrubbing();
@@ -526,6 +539,7 @@ var Controller = class _Controller {
526
539
  this.refreshTime(0);
527
540
  this.refreshProgress(0);
528
541
  this.refreshVolumeUI();
542
+ this.refreshStops();
529
543
  };
530
544
  this.onFrame = ({ frame, totalFrames }) => {
531
545
  if (!this.isScrubbing) {
@@ -552,13 +566,92 @@ var Controller = class _Controller {
552
566
  this.movie.on("pause", this.onPause);
553
567
  this.movie.on("progress", this.onProgress);
554
568
  }
569
+ /** The stops of a presentation as marks on the seek bar, and the Present button. */
570
+ refreshStops() {
571
+ this.progressEl.querySelector(".mc-stops")?.remove();
572
+ const stops = this.movie.stops ?? [];
573
+ this.refreshPdfChoices(stops.length > 0);
574
+ if (stops.length === 0) {
575
+ this.presentBtn?.remove();
576
+ this.presentBtn = null;
577
+ return;
578
+ }
579
+ const total = this.movie.totalFrames || 1;
580
+ const holder = document.createElement("div");
581
+ holder.className = "mc-stops";
582
+ for (const s of stops) {
583
+ const tick = document.createElement("div");
584
+ tick.className = s.pageStart ? "mc-stop-tick mc-stop-page" : "mc-stop-tick";
585
+ tick.style.left = `${s.frame / total * 100}%`;
586
+ if (s.page) tick.title = s.page;
587
+ holder.appendChild(tick);
588
+ }
589
+ this.progressEl.appendChild(holder);
590
+ if (this.options.present && !this.presentBtn && this.fullscreenBtn) {
591
+ const btn = document.createElement("button");
592
+ btn.className = "mc-btn mc-present";
593
+ btn.setAttribute("aria-label", "Present");
594
+ btn.title = "Present";
595
+ btn.innerHTML = ICONS.present;
596
+ btn.addEventListener("click", () => {
597
+ void this.startPresenting();
598
+ });
599
+ this.fullscreenBtn.parentElement?.insertBefore(btn, this.fullscreenBtn);
600
+ this.presentBtn = btn;
601
+ }
602
+ }
603
+ /** A deck can be downloaded as a PDF: the format list gets "PDF" choices while the movie has stops. */
604
+ refreshPdfChoices(has) {
605
+ const select = this.settingsFormatSelect;
606
+ if (!select) return;
607
+ const present = !!select.querySelector('option[value="pdf"]');
608
+ if (has && !present) {
609
+ for (const [value, label] of [["pdf", "PDF (pages)"], ["pdf-steps", "PDF (every step)"]]) {
610
+ const o = document.createElement("option");
611
+ o.value = value;
612
+ o.textContent = label;
613
+ select.appendChild(o);
614
+ }
615
+ } else if (!has && present) {
616
+ select.querySelectorAll('option[value="pdf"], option[value="pdf-steps"]').forEach((o) => o.remove());
617
+ if (this.exportFormat === "pdf" || this.exportFormat === "pdf-steps") {
618
+ this.exportFormat = "mp4";
619
+ select.value = "mp4";
620
+ }
621
+ }
622
+ }
623
+ /** Play, or pause: a movie with stops plays to the next stop (and from the end, again from the start). */
624
+ async togglePlay() {
625
+ if (this.movie.isPlaying) {
626
+ this.movie.pause();
627
+ return;
628
+ }
629
+ await this.startPlaying();
630
+ }
631
+ /** Start playing: to the next stop for a movie with stops, to the end for any other (or with `pauseAtStops: false`). */
632
+ async startPlaying() {
633
+ if (this.options.pauseAtStops && (this.movie.stops?.length ?? 0) > 0) {
634
+ if (this.movie.currentFrame >= this.movie.totalFrames) await this.movie.gotoFrame(0, true);
635
+ await this.movie.next();
636
+ } else this.movie.play();
637
+ }
638
+ /** Hand the page over to a Presenter (fullscreen, keys, click and swipe move through the stops); Escape gives it back. */
639
+ async startPresenting() {
640
+ if (this.presenter || this.destroyed) return;
641
+ const { Presenter } = await import('./Presenter.js');
642
+ if (this.presenter || this.destroyed) return;
643
+ const p = new Presenter(this.movie, { canvas: this.options.canvas, onExit: () => this.stopPresenting() });
644
+ this.presenter = p;
645
+ await p.start();
646
+ }
647
+ stopPresenting() {
648
+ const p = this.presenter;
649
+ this.presenter = null;
650
+ p?.destroy();
651
+ }
555
652
  bindPlayButton() {
556
653
  this.playBtn.addEventListener("click", () => {
557
- if (this.movie.isPlaying) {
558
- this.movie.pause();
559
- } else {
560
- this.movie.play();
561
- }
654
+ void this.togglePlay();
562
655
  });
563
656
  }
564
657
  /** Clicking the picture plays / pauses (a tap on a phone too). */
@@ -570,12 +663,12 @@ var Controller = class _Controller {
570
663
  this.swallowCanvasClick = false;
571
664
  };
572
665
  this.canvasClickHandler = () => {
666
+ if (this.presenter) return;
573
667
  if (this.swallowCanvasClick) {
574
668
  this.swallowCanvasClick = false;
575
669
  return;
576
670
  }
577
- if (this.movie.isPlaying) this.movie.pause();
578
- else this.movie.play();
671
+ void this.togglePlay();
579
672
  };
580
673
  canvas.addEventListener("pointerdown", this.canvasPointerDownHandler);
581
674
  canvas.addEventListener("click", this.canvasClickHandler);
@@ -731,7 +824,7 @@ var Controller = class _Controller {
731
824
  } catch {
732
825
  }
733
826
  if (this.wasPlayingBeforeScrub) {
734
- this.movie.play();
827
+ void this.startPlaying();
735
828
  this.refreshPlayIcon();
736
829
  }
737
830
  };
@@ -780,7 +873,7 @@ var Controller = class _Controller {
780
873
  if (this.settingsFormatSelect) {
781
874
  this.settingsFormatSelect.addEventListener("change", (e) => {
782
875
  const v = e.target.value;
783
- if (v === "mp4" || v === "webm" || v === "mov") this.exportFormat = v;
876
+ if (v === "mp4" || v === "webm" || v === "mov" || v === "pdf" || v === "pdf-steps") this.exportFormat = v;
784
877
  });
785
878
  }
786
879
  if (this.settingsQualitySelect) {
@@ -830,9 +923,19 @@ var Controller = class _Controller {
830
923
  this.movie.pause();
831
924
  this.refreshPlayIcon();
832
925
  }
833
- this.showExportOverlay();
926
+ const pdf = this.exportFormat === "pdf" || this.exportFormat === "pdf-steps";
927
+ if (pdf) {
928
+ const stops = this.movie.stops ?? [];
929
+ const total = this.exportFormat === "pdf-steps" ? stops.filter((s) => s.pdf !== false).length : new Set(stops.map((s) => s.pageIndex)).size;
930
+ this.showExportOverlay("Exporting PDF...", `0% (0 / ${total} pages)`);
931
+ this.exportPages = { done: 0, total };
932
+ } else this.showExportOverlay();
834
933
  try {
835
- const blob = await this.movie.render({
934
+ const blob = pdf ? await this.movie.exportPDF({
935
+ which: this.exportFormat === "pdf-steps" ? "stops" : "pages",
936
+ quality: PDF_QUALITY[this.exportQuality],
937
+ onImage: () => this.pdfPageDone()
938
+ }) : await this.movie.render({
836
939
  format: this.exportFormat,
837
940
  video: { bitrate: this.exportQuality },
838
941
  audio: { bitrate: this.exportQuality }
@@ -848,7 +951,7 @@ var Controller = class _Controller {
848
951
  this.hideExportOverlay();
849
952
  this.isExporting = false;
850
953
  if (wasPlaying) {
851
- this.movie.play();
954
+ void this.startPlaying();
852
955
  this.refreshPlayIcon();
853
956
  }
854
957
  }
@@ -871,16 +974,27 @@ var Controller = class _Controller {
871
974
  const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
872
975
  return `movie-${ts}.${ext}`;
873
976
  }
874
- showExportOverlay() {
977
+ /** One more PDF page is ready: move the bar. */
978
+ pdfPageDone() {
979
+ const p = this.exportPages;
980
+ if (!p || !this.exportFillEl || !this.exportTextEl) return;
981
+ p.done++;
982
+ const percent = p.total ? Math.min(100, Math.round(p.done / p.total * 100)) : 100;
983
+ this.exportFillEl.style.width = `${percent}%`;
984
+ this.exportTextEl.textContent = `${percent}% (${p.done} / ${p.total} pages)`;
985
+ }
986
+ showExportOverlay(title = "Exporting video...", text = "0% (0 / 0 frames)") {
875
987
  const overlay = document.createElement("div");
876
988
  overlay.className = "mc-export-overlay";
877
989
  overlay.innerHTML = `
878
990
  <div class="mc-export-panel">
879
- <div class="mc-export-title">Exporting video...</div>
991
+ <div class="mc-export-title"></div>
880
992
  <div class="mc-export-track"><div class="mc-export-fill"></div></div>
881
- <div class="mc-export-text">0% (0 / 0 frames)</div>
993
+ <div class="mc-export-text"></div>
882
994
  </div>
883
995
  `;
996
+ overlay.querySelector(".mc-export-title").textContent = title;
997
+ overlay.querySelector(".mc-export-text").textContent = text;
884
998
  this.root.appendChild(overlay);
885
999
  this.exportOverlay = overlay;
886
1000
  this.exportFillEl = overlay.querySelector(".mc-export-fill");
@@ -893,6 +1007,7 @@ var Controller = class _Controller {
893
1007
  this.exportFillEl = null;
894
1008
  this.exportTextEl = null;
895
1009
  }
1010
+ this.exportPages = null;
896
1011
  }
897
1012
  bindVisibility() {
898
1013
  const wrap = this.wrapper;
@@ -935,6 +1050,7 @@ var Controller = class _Controller {
935
1050
  }
936
1051
  bindKeyboard() {
937
1052
  this.keyHandler = (e) => {
1053
+ if (this.presenter) return;
938
1054
  const target = e.target;
939
1055
  if (target) {
940
1056
  const tag = target.tagName;
@@ -996,6 +1112,7 @@ var Controller = class _Controller {
996
1112
  destroy() {
997
1113
  if (this.destroyed) return;
998
1114
  this.destroyed = true;
1115
+ this.stopPresenting();
999
1116
  if (this.onReady) {
1000
1117
  this.movie.off("ready", this.onReady);
1001
1118
  this.onReady = null;