pixi-effects 0.1.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.
@@ -0,0 +1,933 @@
1
+ // src/Controller.ts
2
+ var ICONS = {
3
+ play: '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3 2 L13 8 L3 14 Z"/></svg>',
4
+ pause: '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><rect x="3" y="2" width="3.5" height="12"/><rect x="9.5" y="2" width="3.5" height="12"/></svg>',
5
+ volumeOn: '<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 Q13 8 11 11" fill="none" stroke="currentColor" stroke-width="1.2"/><path d="M12.5 3.5 Q15.5 8 12.5 12.5" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>',
6
+ 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>',
7
+ 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>',
8
+ 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>',
9
+ 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>'
10
+ };
11
+ var STYLE_ATTR = "data-movie-controller";
12
+ var STYLE_CSS = `
13
+ .movie-controller-wrap { position: relative; display: inline-block; line-height: 0; }
14
+ .movie-controller {
15
+ position: absolute;
16
+ pointer-events: none;
17
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
18
+ display: flex;
19
+ flex-direction: column;
20
+ justify-content: flex-end;
21
+ opacity: 1;
22
+ transition: opacity 200ms ease;
23
+ }
24
+ .movie-controller[data-state="hidden"] { opacity: 0; }
25
+ .movie-controller > * { pointer-events: auto; }
26
+
27
+ .mc-progress {
28
+ position: relative;
29
+ width: 100%;
30
+ height: 16px;
31
+ cursor: pointer;
32
+ display: flex;
33
+ align-items: center;
34
+ outline: none;
35
+ }
36
+ .mc-progress:focus-visible {
37
+ outline: 2px solid #007AFF;
38
+ outline-offset: 2px;
39
+ }
40
+ .mc-progress::before {
41
+ content: ""; position: absolute; left: 12px; right: 12px;
42
+ height: 3px; background: rgba(255,255,255,0.25);
43
+ transition: height 120ms ease;
44
+ }
45
+ .mc-progress:hover::before, .mc-progress.mc-scrubbing::before { height: 5px; }
46
+ .mc-progress-fill {
47
+ position: absolute; left: 12px; top: 50%;
48
+ height: 3px;
49
+ width: calc((100% - 24px) * var(--mc-fill, 0));
50
+ background: #007AFF;
51
+ transform: translateY(-50%);
52
+ transition: height 120ms ease;
53
+ pointer-events: none;
54
+ }
55
+ .mc-progress:hover .mc-progress-fill,
56
+ .mc-progress.mc-scrubbing .mc-progress-fill { height: 5px; }
57
+ .mc-progress-thumb {
58
+ position: absolute; top: 50%;
59
+ left: calc(12px + (100% - 24px) * var(--mc-fill, 0));
60
+ width: 12px; height: 12px; border-radius: 50%;
61
+ background: #007AFF;
62
+ transform: translate(-50%, -50%) scale(0);
63
+ transition: transform 120ms ease;
64
+ pointer-events: none;
65
+ }
66
+ .mc-progress:hover .mc-progress-thumb,
67
+ .mc-progress.mc-scrubbing .mc-progress-thumb { transform: translate(-50%, -50%) scale(1); }
68
+
69
+ .mc-bar {
70
+ position: relative;
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 12px;
74
+ padding: 6px 12px 8px 12px;
75
+ background: linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0) 100%);
76
+ color: #fff;
77
+ line-height: 1;
78
+ }
79
+ .mc-btn {
80
+ background: none; border: 0; padding: 0; margin: 0;
81
+ width: 28px; height: 28px;
82
+ display: inline-flex; align-items: center; justify-content: center;
83
+ color: #fff; opacity: 0.85; cursor: pointer;
84
+ transition: opacity 120ms ease;
85
+ }
86
+ .mc-btn:hover { opacity: 1; }
87
+ .mc-btn:disabled { opacity: 0.4; cursor: not-allowed; }
88
+ .mc-time {
89
+ font-size: 12px; font-family: Menlo, Monaco, monospace;
90
+ color: #fff; opacity: 0.85;
91
+ min-width: 90px;
92
+ }
93
+ .mc-spacer { flex: 1; }
94
+
95
+ .mc-volume { display: flex; align-items: center; }
96
+ .mc-vol-slider {
97
+ position: relative;
98
+ width: 0;
99
+ height: 28px;
100
+ display: flex;
101
+ align-items: center;
102
+ cursor: pointer;
103
+ margin-left: 0;
104
+ outline: none;
105
+ transition: width 200ms ease, margin-left 200ms ease;
106
+ overflow: hidden;
107
+ }
108
+ .mc-volume:hover .mc-vol-slider,
109
+ .mc-volume:focus-within .mc-vol-slider,
110
+ .mc-vol-slider.mc-scrubbing {
111
+ width: 70px;
112
+ margin-left: 6px;
113
+ }
114
+ .mc-vol-slider:focus-visible { outline: 2px solid #007AFF; outline-offset: 2px; }
115
+ .mc-vol-slider::before {
116
+ content: ""; position: absolute; left: 4px; right: 4px;
117
+ height: 3px; background: rgba(255,255,255,0.25); border-radius: 2px;
118
+ }
119
+ .mc-vol-fill {
120
+ position: absolute; left: 4px; top: 50%;
121
+ height: 3px;
122
+ width: calc((100% - 8px) * var(--mc-volume, 1));
123
+ background: #fff;
124
+ transform: translateY(-50%);
125
+ border-radius: 2px;
126
+ pointer-events: none;
127
+ }
128
+ .mc-vol-thumb {
129
+ position: absolute; top: 50%;
130
+ left: calc(4px + (100% - 8px) * var(--mc-volume, 1));
131
+ width: 10px; height: 10px; border-radius: 50%;
132
+ background: #fff;
133
+ transform: translate(-50%, -50%);
134
+ pointer-events: none;
135
+ }
136
+
137
+ [data-mc-wrap]:fullscreen {
138
+ width: 100vw; height: 100vh;
139
+ display: flex; align-items: center; justify-content: center;
140
+ background: #000;
141
+ }
142
+ [data-mc-wrap]:fullscreen > canvas {
143
+ width: 100% !important;
144
+ height: 100% !important;
145
+ max-width: none !important;
146
+ max-height: none !important;
147
+ object-fit: contain;
148
+ border-radius: 0;
149
+ }
150
+
151
+ .mc-settings-popover {
152
+ position: absolute;
153
+ bottom: calc(100% + 8px);
154
+ right: 12px;
155
+ background: rgba(20, 20, 20, 0.96);
156
+ border-radius: 8px;
157
+ padding: 10px 12px;
158
+ min-width: 180px;
159
+ color: #fff;
160
+ font-size: 12px;
161
+ box-shadow: 0 4px 16px rgba(0,0,0,0.4);
162
+ display: flex;
163
+ flex-direction: column;
164
+ gap: 8px;
165
+ }
166
+ .mc-settings-popover[data-open="false"] { display: none; }
167
+ .mc-settings-row {
168
+ display: flex;
169
+ align-items: center;
170
+ justify-content: space-between;
171
+ gap: 12px;
172
+ }
173
+ .mc-settings-row > span { color: rgba(255,255,255,0.85); }
174
+ .mc-settings-row > select {
175
+ font: inherit;
176
+ background: rgba(255,255,255,0.1);
177
+ color: #fff;
178
+ border: 1px solid rgba(255,255,255,0.18);
179
+ border-radius: 4px;
180
+ padding: 2px 6px;
181
+ cursor: pointer;
182
+ }
183
+ .mc-export-confirm {
184
+ margin-top: 4px;
185
+ width: 100%;
186
+ background: #007AFF;
187
+ color: #fff;
188
+ border: 0;
189
+ border-radius: 4px;
190
+ padding: 6px 10px;
191
+ font: inherit;
192
+ font-weight: 600;
193
+ cursor: pointer;
194
+ transition: background 120ms ease;
195
+ }
196
+ .mc-export-confirm:hover { background: #0066d6; }
197
+ .mc-export-confirm:disabled { background: rgba(255,255,255,0.18); cursor: not-allowed; }
198
+
199
+ .mc-export-overlay {
200
+ position: absolute; inset: 0;
201
+ background: rgba(0,0,0,0.7);
202
+ display: flex; justify-content: center; align-items: center;
203
+ z-index: 2;
204
+ }
205
+ .mc-export-panel {
206
+ background: rgba(20,20,20,0.96); color: #fff;
207
+ border-radius: 8px; padding: 16px 20px;
208
+ text-align: center; min-width: 200px; max-width: 80%;
209
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
210
+ }
211
+ .mc-export-title { font-size: 13px; margin-bottom: 10px; opacity: 0.9; }
212
+ .mc-export-track {
213
+ width: 100%; height: 6px;
214
+ background: rgba(255,255,255,0.15); border-radius: 3px;
215
+ margin-bottom: 8px; overflow: hidden;
216
+ }
217
+ .mc-export-fill {
218
+ height: 100%; width: 0%;
219
+ background: linear-gradient(90deg, #007AFF, #0056CC);
220
+ transition: width 0.3s ease; border-radius: 3px;
221
+ }
222
+ .mc-export-text {
223
+ color: rgba(255,255,255,0.7); font-size: 11px;
224
+ font-family: Menlo, Monaco, monospace;
225
+ }
226
+ `;
227
+ var styleRefCount = 0;
228
+ function installStyles() {
229
+ if (styleRefCount === 0) {
230
+ const el = document.createElement("style");
231
+ el.setAttribute(STYLE_ATTR, "");
232
+ el.textContent = STYLE_CSS;
233
+ document.head.appendChild(el);
234
+ }
235
+ styleRefCount++;
236
+ }
237
+ function uninstallStyles() {
238
+ styleRefCount = Math.max(0, styleRefCount - 1);
239
+ if (styleRefCount === 0) {
240
+ document.head.querySelectorAll(`style[${STYLE_ATTR}]`).forEach((n) => n.remove());
241
+ }
242
+ }
243
+ function formatTime(seconds) {
244
+ const s = Math.max(0, Math.floor(seconds));
245
+ const m = Math.floor(s / 60);
246
+ const r = s % 60;
247
+ return `${m}:${r.toString().padStart(2, "0")}`;
248
+ }
249
+ function frameToPercent(frame, totalFrames) {
250
+ if (totalFrames <= 0) return 0;
251
+ const f = Math.min(Math.max(frame, 0), totalFrames);
252
+ return f / totalFrames * 100;
253
+ }
254
+ function pxToFrame(clientX, rect, totalFrames) {
255
+ if (rect.width <= 0 || totalFrames <= 0) return 0;
256
+ const ratio = (clientX - rect.left) / rect.width;
257
+ const clamped = Math.min(Math.max(ratio, 0), 1);
258
+ return Math.round(clamped * totalFrames);
259
+ }
260
+ function pxToFraction(clientX, rect, inset = 0) {
261
+ const innerLeft = rect.left + inset;
262
+ const innerWidth = Math.max(0, rect.width - 2 * inset);
263
+ if (innerWidth <= 0) return 0;
264
+ const ratio = (clientX - innerLeft) / innerWidth;
265
+ return Math.min(Math.max(ratio, 0), 1);
266
+ }
267
+ function extensionForMimeType(mime) {
268
+ const t = (mime || "").toLowerCase();
269
+ if (t.includes("webm")) return "webm";
270
+ if (t.includes("quicktime") || t.includes("mov")) return "mov";
271
+ if (t.includes("matroska") || t.includes("mkv")) return "mkv";
272
+ return "mp4";
273
+ }
274
+ var Controller = class _Controller {
275
+ movie;
276
+ options;
277
+ root;
278
+ wrapper;
279
+ wrappedHere = false;
280
+ destroyed = false;
281
+ progressEl;
282
+ progressFillEl;
283
+ progressThumbEl;
284
+ playBtn;
285
+ muteBtn;
286
+ timeEl;
287
+ exportBtn = null;
288
+ settingsPopoverEl = null;
289
+ settingsFormatSelect = null;
290
+ settingsQualitySelect = null;
291
+ exportConfirmBtn = null;
292
+ exportFormat = "mp4";
293
+ exportQuality = "high";
294
+ settingsOpen = false;
295
+ hideTimer = null;
296
+ static HIDE_DELAY_MS = 2500;
297
+ static PROGRESS_INSET_PX = 12;
298
+ isScrubbing = false;
299
+ wasPlayingBeforeScrub = false;
300
+ activePointerId = null;
301
+ isExporting = false;
302
+ exportOverlay = null;
303
+ exportFillEl = null;
304
+ exportTextEl = null;
305
+ keyHandler = null;
306
+ settingsKeyHandler = null;
307
+ settingsOutsideHandler = null;
308
+ onReady = null;
309
+ onFrame = null;
310
+ onPause = null;
311
+ onProgress = null;
312
+ onWrapPointerMove = null;
313
+ onWrapMouseLeave = null;
314
+ resizeObserver = null;
315
+ volumeSliderEl = null;
316
+ volumeContainerEl = null;
317
+ fullscreenBtn = null;
318
+ isVolumeScrubbing = false;
319
+ fullscreenChangeHandler = null;
320
+ wheelHandler = null;
321
+ constructor(movie, options) {
322
+ if (!options || !options.canvas) {
323
+ throw new Error("Controller requires options.canvas (HTMLCanvasElement).");
324
+ }
325
+ this.movie = movie;
326
+ this.options = {
327
+ canvas: options.canvas,
328
+ showExportButton: options.showExportButton ?? true,
329
+ enableKeyboardShortcuts: options.enableKeyboardShortcuts ?? true,
330
+ className: options.className ?? "movie-controller"
331
+ };
332
+ installStyles();
333
+ this.wrapper = this.ensurePositioningContext(this.options.canvas);
334
+ this.root = document.createElement("div");
335
+ this.root.className = this.options.className;
336
+ this.wrapper.appendChild(this.root);
337
+ this.buildBar();
338
+ this.syncRootToCanvas();
339
+ if (typeof ResizeObserver !== "undefined") {
340
+ this.resizeObserver = new ResizeObserver(() => this.syncRootToCanvas());
341
+ this.resizeObserver.observe(this.options.canvas);
342
+ this.resizeObserver.observe(this.wrapper);
343
+ }
344
+ this.root.setAttribute("data-state", "visible");
345
+ this.bindMovieEvents();
346
+ this.bindPlayButton();
347
+ this.bindScrubbing();
348
+ this.bindMuteButton();
349
+ this.bindVolumeSlider();
350
+ this.bindExportPopover();
351
+ this.bindFullscreen();
352
+ this.bindVisibility();
353
+ if (this.options.enableKeyboardShortcuts) this.bindKeyboard();
354
+ }
355
+ buildBar() {
356
+ const popoverHtml = this.options.showExportButton ? `
357
+ <div class="mc-settings-popover" role="dialog" aria-label="Export settings" data-open="false">
358
+ <label class="mc-settings-row">
359
+ <span>Format</span>
360
+ <select class="mc-settings-format">
361
+ <option value="mp4">MP4</option>
362
+ <option value="webm">WebM</option>
363
+ <option value="mov">MOV</option>
364
+ </select>
365
+ </label>
366
+ <label class="mc-settings-row">
367
+ <span>Quality</span>
368
+ <select class="mc-settings-quality">
369
+ <option value="low">Low</option>
370
+ <option value="medium">Medium</option>
371
+ <option value="high" selected>High</option>
372
+ <option value="very-high">Very High</option>
373
+ </select>
374
+ </label>
375
+ <button type="button" class="mc-export-confirm">Download</button>
376
+ </div>
377
+ ` : "";
378
+ this.root.innerHTML = `
379
+ <div class="mc-progress" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuemax="${this.movie.totalFrames}" aria-valuenow="0">
380
+ <div class="mc-progress-fill"></div>
381
+ <div class="mc-progress-thumb"></div>
382
+ </div>
383
+ <div class="mc-bar">
384
+ ${popoverHtml}
385
+ <button class="mc-btn mc-play" aria-label="Play">${ICONS.play}</button>
386
+ <div class="mc-volume">
387
+ <button class="mc-btn mc-mute" aria-label="Mute">${ICONS.volumeOn}</button>
388
+ <div class="mc-vol-slider" role="slider" tabindex="0" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="100">
389
+ <div class="mc-vol-fill"></div>
390
+ <div class="mc-vol-thumb"></div>
391
+ </div>
392
+ </div>
393
+ <span class="mc-time">0:00 / 0:00</span>
394
+ <div class="mc-spacer"></div>
395
+ ${this.options.showExportButton ? `<button class="mc-btn mc-export" aria-label="Export" aria-expanded="false">${ICONS.download}</button>` : ""}
396
+ <button class="mc-btn mc-fullscreen" aria-label="Enter fullscreen">${ICONS.fullscreenEnter}</button>
397
+ </div>
398
+ `;
399
+ this.progressEl = this.root.querySelector(".mc-progress");
400
+ this.progressFillEl = this.root.querySelector(".mc-progress-fill");
401
+ this.progressThumbEl = this.root.querySelector(".mc-progress-thumb");
402
+ this.playBtn = this.root.querySelector(".mc-play");
403
+ this.muteBtn = this.root.querySelector(".mc-mute");
404
+ this.timeEl = this.root.querySelector(".mc-time");
405
+ this.exportBtn = this.root.querySelector(".mc-export");
406
+ this.settingsPopoverEl = this.root.querySelector(".mc-settings-popover");
407
+ this.settingsFormatSelect = this.root.querySelector(".mc-settings-format");
408
+ this.settingsQualitySelect = this.root.querySelector(".mc-settings-quality");
409
+ this.exportConfirmBtn = this.root.querySelector(".mc-export-confirm");
410
+ this.volumeSliderEl = this.root.querySelector(".mc-vol-slider");
411
+ this.volumeContainerEl = this.root.querySelector(".mc-volume");
412
+ this.fullscreenBtn = this.root.querySelector(".mc-fullscreen");
413
+ if (this.settingsFormatSelect) this.settingsFormatSelect.value = this.exportFormat;
414
+ if (this.settingsQualitySelect) this.settingsQualitySelect.value = this.exportQuality;
415
+ }
416
+ ensurePositioningContext(canvas) {
417
+ const parent = canvas.parentElement;
418
+ if (!parent) {
419
+ throw new Error("Controller: canvas must be attached to the DOM before constructing.");
420
+ }
421
+ const pos = getComputedStyle(parent).position;
422
+ if (pos === "relative" || pos === "absolute" || pos === "fixed" || pos === "sticky") {
423
+ parent.setAttribute("data-mc-wrap", "");
424
+ return parent;
425
+ }
426
+ const wrap = document.createElement("div");
427
+ wrap.className = "movie-controller-wrap";
428
+ wrap.setAttribute("data-mc-wrap", "");
429
+ wrap.style.position = "relative";
430
+ wrap.style.display = "inline-block";
431
+ wrap.style.lineHeight = "0";
432
+ parent.insertBefore(wrap, canvas);
433
+ wrap.appendChild(canvas);
434
+ this.wrappedHere = true;
435
+ return wrap;
436
+ }
437
+ bindMovieEvents() {
438
+ this.onReady = () => {
439
+ this.progressEl.setAttribute("aria-valuemax", String(this.movie.totalFrames));
440
+ this.refreshTime(0);
441
+ this.refreshProgress(0);
442
+ this.refreshVolumeUI();
443
+ };
444
+ this.onFrame = ({ frame, totalFrames }) => {
445
+ if (!this.isScrubbing) {
446
+ this.refreshProgress(frame, totalFrames);
447
+ }
448
+ this.refreshTime(frame);
449
+ };
450
+ this.onPause = () => {
451
+ this.refreshPlayIcon();
452
+ this.kickIdleTimer();
453
+ };
454
+ this.onProgress = ({ progress, frame, totalFrames }) => {
455
+ if (!this.isExporting || !this.exportFillEl || !this.exportTextEl) return;
456
+ this.exportFillEl.style.width = `${progress}%`;
457
+ this.exportTextEl.textContent = `${progress}% (${frame} / ${totalFrames} frames)`;
458
+ };
459
+ this.movie.on("ready", this.onReady);
460
+ this.movie.on("frame", this.onFrame);
461
+ this.movie.on("pause", this.onPause);
462
+ this.movie.on("progress", this.onProgress);
463
+ }
464
+ bindPlayButton() {
465
+ this.playBtn.addEventListener("click", () => {
466
+ if (this.movie.isPlaying) {
467
+ this.movie.pause();
468
+ } else {
469
+ this.movie.play();
470
+ this.refreshPlayIcon();
471
+ this.kickIdleTimer();
472
+ }
473
+ });
474
+ }
475
+ refreshPlayIcon() {
476
+ this.playBtn.innerHTML = this.movie.isPlaying ? ICONS.pause : ICONS.play;
477
+ this.playBtn.setAttribute("aria-label", this.movie.isPlaying ? "Pause" : "Play");
478
+ }
479
+ bindMuteButton() {
480
+ this.muteBtn.addEventListener("click", () => {
481
+ this.movie.toggleMute();
482
+ this.refreshVolumeUI();
483
+ });
484
+ }
485
+ refreshMuteIcon() {
486
+ const silent = this.movie.muted || this.movie.volume <= 0;
487
+ this.muteBtn.innerHTML = silent ? ICONS.volumeOff : ICONS.volumeOn;
488
+ this.muteBtn.setAttribute("aria-label", silent ? "Unmute" : "Mute");
489
+ }
490
+ refreshVolumeUI() {
491
+ this.refreshMuteIcon();
492
+ if (!this.volumeSliderEl) return;
493
+ const effective = this.movie.muted ? 0 : this.movie.volume;
494
+ this.volumeSliderEl.style.setProperty("--mc-volume", String(effective));
495
+ this.volumeSliderEl.setAttribute("aria-valuenow", String(Math.round(effective * 100)));
496
+ }
497
+ bindVolumeSlider() {
498
+ if (!this.volumeSliderEl || !this.volumeContainerEl) return;
499
+ const slider = this.volumeSliderEl;
500
+ const container = this.volumeContainerEl;
501
+ const setFromPointer = (clientX) => {
502
+ const rect = slider.getBoundingClientRect();
503
+ const v = pxToFraction(clientX, { left: rect.left, width: rect.width }, 4);
504
+ this.movie.volume = v;
505
+ if (this.movie.muted && v > 0) this.movie.muted = false;
506
+ this.refreshVolumeUI();
507
+ };
508
+ const onDown = (e) => {
509
+ this.isVolumeScrubbing = true;
510
+ slider.classList.add("mc-scrubbing");
511
+ try {
512
+ slider.setPointerCapture(e.pointerId);
513
+ } catch {
514
+ }
515
+ setFromPointer(e.clientX);
516
+ };
517
+ const onMove = (e) => {
518
+ if (!this.isVolumeScrubbing) return;
519
+ setFromPointer(e.clientX);
520
+ };
521
+ const onUp = (e) => {
522
+ if (!this.isVolumeScrubbing) return;
523
+ this.isVolumeScrubbing = false;
524
+ slider.classList.remove("mc-scrubbing");
525
+ try {
526
+ slider.releasePointerCapture(e.pointerId);
527
+ } catch {
528
+ }
529
+ };
530
+ slider.addEventListener("pointerdown", onDown);
531
+ slider.addEventListener("pointermove", onMove);
532
+ slider.addEventListener("pointerup", onUp);
533
+ slider.addEventListener("pointercancel", onUp);
534
+ this.wheelHandler = (e) => {
535
+ e.preventDefault();
536
+ this.adjustVolume(e.deltaY < 0 ? 0.05 : -0.05);
537
+ };
538
+ container.addEventListener("wheel", this.wheelHandler, { passive: false });
539
+ }
540
+ bindFullscreen() {
541
+ if (!this.fullscreenBtn) return;
542
+ this.fullscreenBtn.addEventListener("click", () => {
543
+ void this.toggleFullscreen();
544
+ });
545
+ this.fullscreenChangeHandler = () => {
546
+ this.refreshFullscreenIcon();
547
+ this.syncRootToCanvas();
548
+ };
549
+ document.addEventListener("fullscreenchange", this.fullscreenChangeHandler);
550
+ }
551
+ async toggleFullscreen() {
552
+ try {
553
+ if (document.fullscreenElement === this.wrapper) {
554
+ await document.exitFullscreen();
555
+ } else {
556
+ await this.wrapper.requestFullscreen();
557
+ }
558
+ } catch (err) {
559
+ console.warn("pixi-effects: fullscreen denied:", err);
560
+ }
561
+ }
562
+ refreshFullscreenIcon() {
563
+ if (!this.fullscreenBtn) return;
564
+ const inFs = document.fullscreenElement === this.wrapper;
565
+ this.fullscreenBtn.innerHTML = inFs ? ICONS.fullscreenExit : ICONS.fullscreenEnter;
566
+ this.fullscreenBtn.setAttribute("aria-label", inFs ? "Exit fullscreen" : "Enter fullscreen");
567
+ }
568
+ refreshProgress(frame, totalFrames) {
569
+ const total = totalFrames ?? this.movie.totalFrames;
570
+ const pct = frameToPercent(frame, total);
571
+ this.progressEl.style.setProperty("--mc-fill", String(pct / 100));
572
+ this.progressEl.setAttribute("aria-valuenow", String(frame));
573
+ }
574
+ refreshTime(frame) {
575
+ const fr = this.movie.frameRate || 1;
576
+ const cur = frame / fr;
577
+ const total = this.movie.totalFrames / fr;
578
+ this.timeEl.textContent = `${formatTime(cur)} / ${formatTime(total)}`;
579
+ }
580
+ bindScrubbing() {
581
+ const onDown = (e) => {
582
+ this.isScrubbing = true;
583
+ this.activePointerId = e.pointerId;
584
+ this.wasPlayingBeforeScrub = this.movie.isPlaying;
585
+ if (this.movie.isPlaying) {
586
+ this.movie.pause();
587
+ this.refreshPlayIcon();
588
+ }
589
+ this.progressEl.classList.add("mc-scrubbing");
590
+ try {
591
+ this.progressEl.setPointerCapture(e.pointerId);
592
+ } catch {
593
+ }
594
+ this.seekFromPointer(e.clientX);
595
+ };
596
+ const onMove = (e) => {
597
+ if (!this.isScrubbing) return;
598
+ this.seekFromPointer(e.clientX);
599
+ };
600
+ const onUp = (e) => {
601
+ if (!this.isScrubbing) return;
602
+ this.seekFromPointer(e.clientX);
603
+ this.isScrubbing = false;
604
+ this.activePointerId = null;
605
+ this.progressEl.classList.remove("mc-scrubbing");
606
+ try {
607
+ this.progressEl.releasePointerCapture(e.pointerId);
608
+ } catch {
609
+ }
610
+ if (this.wasPlayingBeforeScrub) {
611
+ this.movie.play();
612
+ this.refreshPlayIcon();
613
+ }
614
+ };
615
+ this.progressEl.addEventListener("pointerdown", onDown);
616
+ this.progressEl.addEventListener("pointermove", onMove);
617
+ this.progressEl.addEventListener("pointerup", onUp);
618
+ this.progressEl.addEventListener("pointercancel", onUp);
619
+ }
620
+ seekFromPointer(clientX) {
621
+ const rect = this.progressEl.getBoundingClientRect();
622
+ const inset = _Controller.PROGRESS_INSET_PX;
623
+ const innerLeft = rect.left + inset;
624
+ const innerWidth = Math.max(0, rect.width - 2 * inset);
625
+ const frame = pxToFrame(clientX, { left: innerLeft, width: innerWidth }, this.movie.totalFrames);
626
+ this.refreshProgress(frame);
627
+ void this.movie.gotoFrame(frame);
628
+ }
629
+ bindExportPopover() {
630
+ if (!this.exportBtn || !this.settingsPopoverEl) return;
631
+ this.exportBtn.addEventListener("click", (e) => {
632
+ e.stopPropagation();
633
+ this.toggleSettings(!this.settingsOpen);
634
+ });
635
+ if (this.exportConfirmBtn) {
636
+ this.exportConfirmBtn.addEventListener("click", () => {
637
+ this.toggleSettings(false);
638
+ void this.handleExport();
639
+ });
640
+ }
641
+ if (this.settingsFormatSelect) {
642
+ this.settingsFormatSelect.addEventListener("change", (e) => {
643
+ const v = e.target.value;
644
+ if (v === "mp4" || v === "webm" || v === "mov") this.exportFormat = v;
645
+ });
646
+ }
647
+ if (this.settingsQualitySelect) {
648
+ this.settingsQualitySelect.addEventListener("change", (e) => {
649
+ const v = e.target.value;
650
+ if (v === "low" || v === "medium" || v === "high" || v === "very-high") this.exportQuality = v;
651
+ });
652
+ }
653
+ this.settingsKeyHandler = (e) => {
654
+ if (e.code === "Escape" && this.settingsOpen) {
655
+ e.preventDefault();
656
+ this.toggleSettings(false);
657
+ }
658
+ };
659
+ document.addEventListener("keydown", this.settingsKeyHandler);
660
+ this.settingsOutsideHandler = (e) => {
661
+ if (!this.settingsOpen) return;
662
+ const target = e.target;
663
+ if (!target) return;
664
+ if (this.settingsPopoverEl?.contains(target)) return;
665
+ if (this.exportBtn?.contains(target)) return;
666
+ this.toggleSettings(false);
667
+ };
668
+ document.addEventListener("pointerdown", this.settingsOutsideHandler);
669
+ }
670
+ toggleSettings(open) {
671
+ if (!this.exportBtn || !this.settingsPopoverEl) return;
672
+ this.settingsOpen = open;
673
+ this.settingsPopoverEl.setAttribute("data-open", open ? "true" : "false");
674
+ this.exportBtn.setAttribute("aria-expanded", open ? "true" : "false");
675
+ if (open) {
676
+ if (this.hideTimer) {
677
+ clearTimeout(this.hideTimer);
678
+ this.hideTimer = null;
679
+ }
680
+ this.setVisible(true);
681
+ } else {
682
+ this.kickIdleTimer();
683
+ }
684
+ }
685
+ async handleExport() {
686
+ if (this.isExporting) return;
687
+ this.isExporting = true;
688
+ const wasPlaying = this.movie.isPlaying;
689
+ if (wasPlaying) {
690
+ this.movie.pause();
691
+ this.refreshPlayIcon();
692
+ }
693
+ this.showExportOverlay();
694
+ try {
695
+ const blob = await this.movie.render({
696
+ format: this.exportFormat,
697
+ video: { bitrate: this.exportQuality },
698
+ audio: { bitrate: this.exportQuality }
699
+ });
700
+ if (this.exportFillEl) this.exportFillEl.style.width = "100%";
701
+ if (this.exportTextEl) this.exportTextEl.textContent = "Preparing download...";
702
+ this.triggerDownload(blob);
703
+ await new Promise((r) => setTimeout(r, 300));
704
+ } catch (err) {
705
+ console.error("Export failed:", err);
706
+ alert("Export failed.");
707
+ } finally {
708
+ this.hideExportOverlay();
709
+ this.isExporting = false;
710
+ if (wasPlaying) {
711
+ this.movie.play();
712
+ this.refreshPlayIcon();
713
+ }
714
+ }
715
+ }
716
+ triggerDownload(blob) {
717
+ const url = URL.createObjectURL(blob);
718
+ const a = document.createElement("a");
719
+ a.href = url;
720
+ a.download = this.makeFilename(blob);
721
+ a.style.display = "none";
722
+ document.body.appendChild(a);
723
+ a.click();
724
+ a.remove();
725
+ setTimeout(() => URL.revokeObjectURL(url), 100);
726
+ }
727
+ makeFilename(blob) {
728
+ const ext = extensionForMimeType(blob.type);
729
+ const d = /* @__PURE__ */ new Date();
730
+ const pad = (n) => n.toString().padStart(2, "0");
731
+ const ts = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
732
+ return `movie-${ts}.${ext}`;
733
+ }
734
+ showExportOverlay() {
735
+ const overlay = document.createElement("div");
736
+ overlay.className = "mc-export-overlay";
737
+ overlay.innerHTML = `
738
+ <div class="mc-export-panel">
739
+ <div class="mc-export-title">Exporting video...</div>
740
+ <div class="mc-export-track"><div class="mc-export-fill"></div></div>
741
+ <div class="mc-export-text">0% (0 / 0 frames)</div>
742
+ </div>
743
+ `;
744
+ this.root.appendChild(overlay);
745
+ this.exportOverlay = overlay;
746
+ this.exportFillEl = overlay.querySelector(".mc-export-fill");
747
+ this.exportTextEl = overlay.querySelector(".mc-export-text");
748
+ }
749
+ hideExportOverlay() {
750
+ if (this.exportOverlay) {
751
+ this.exportOverlay.remove();
752
+ this.exportOverlay = null;
753
+ this.exportFillEl = null;
754
+ this.exportTextEl = null;
755
+ }
756
+ }
757
+ bindVisibility() {
758
+ const wrap = this.wrapper;
759
+ this.onWrapPointerMove = () => this.kickIdleTimer();
760
+ this.onWrapMouseLeave = () => {
761
+ if (!this.settingsOpen && !this.isScrubbing && !this.isVolumeScrubbing) this.setVisible(false);
762
+ };
763
+ wrap.addEventListener("pointermove", this.onWrapPointerMove);
764
+ wrap.addEventListener("mouseleave", this.onWrapMouseLeave);
765
+ this.root.addEventListener("pointerenter", () => {
766
+ if (this.hideTimer) {
767
+ clearTimeout(this.hideTimer);
768
+ this.hideTimer = null;
769
+ }
770
+ this.setVisible(true);
771
+ });
772
+ }
773
+ kickIdleTimer() {
774
+ this.setVisible(true);
775
+ if (this.hideTimer) {
776
+ clearTimeout(this.hideTimer);
777
+ this.hideTimer = null;
778
+ }
779
+ if (this.settingsOpen) return;
780
+ this.hideTimer = setTimeout(() => {
781
+ if (!this.isScrubbing && !this.isVolumeScrubbing && !this.settingsOpen) this.setVisible(false);
782
+ }, _Controller.HIDE_DELAY_MS);
783
+ }
784
+ setVisible(v) {
785
+ this.root.setAttribute("data-state", v ? "visible" : "hidden");
786
+ }
787
+ syncRootToCanvas() {
788
+ const c = this.options.canvas;
789
+ const cr = c.getBoundingClientRect();
790
+ const wr = this.wrapper.getBoundingClientRect();
791
+ this.root.style.left = `${cr.left - wr.left}px`;
792
+ this.root.style.top = `${cr.top - wr.top}px`;
793
+ this.root.style.width = `${cr.width}px`;
794
+ this.root.style.height = `${cr.height}px`;
795
+ }
796
+ bindKeyboard() {
797
+ this.keyHandler = (e) => {
798
+ const target = e.target;
799
+ if (target) {
800
+ const tag = target.tagName;
801
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
802
+ if (target.isContentEditable || target.getAttribute?.("contenteditable") === "true") return;
803
+ }
804
+ if (this.isExporting) return;
805
+ switch (e.code) {
806
+ case "Space":
807
+ e.preventDefault();
808
+ this.playBtn.click();
809
+ break;
810
+ case "ArrowLeft":
811
+ e.preventDefault();
812
+ this.stepFrame(-1);
813
+ break;
814
+ case "ArrowRight":
815
+ e.preventDefault();
816
+ this.stepFrame(1);
817
+ break;
818
+ case "ArrowUp":
819
+ e.preventDefault();
820
+ this.adjustVolume(0.05);
821
+ break;
822
+ case "ArrowDown":
823
+ e.preventDefault();
824
+ this.adjustVolume(-0.05);
825
+ break;
826
+ case "KeyM":
827
+ e.preventDefault();
828
+ this.muteBtn.click();
829
+ break;
830
+ case "KeyE":
831
+ if (e.shiftKey && this.exportBtn) {
832
+ e.preventDefault();
833
+ void this.handleExport();
834
+ }
835
+ break;
836
+ case "KeyF":
837
+ if (this.fullscreenBtn) {
838
+ e.preventDefault();
839
+ void this.toggleFullscreen();
840
+ }
841
+ break;
842
+ }
843
+ };
844
+ document.addEventListener("keydown", this.keyHandler);
845
+ }
846
+ stepFrame(delta) {
847
+ const next = Math.max(0, Math.min(this.movie.totalFrames, this.movie.currentFrame + delta));
848
+ void this.movie.gotoFrame(next);
849
+ }
850
+ adjustVolume(delta) {
851
+ const next = Math.max(0, Math.min(1, this.movie.volume + delta));
852
+ this.movie.volume = next;
853
+ if (this.movie.muted && next > 0) this.movie.muted = false;
854
+ this.refreshVolumeUI();
855
+ }
856
+ destroy() {
857
+ if (this.destroyed) return;
858
+ this.destroyed = true;
859
+ if (this.onReady) {
860
+ this.movie.off("ready", this.onReady);
861
+ this.onReady = null;
862
+ }
863
+ if (this.onFrame) {
864
+ this.movie.off("frame", this.onFrame);
865
+ this.onFrame = null;
866
+ }
867
+ if (this.onPause) {
868
+ this.movie.off("pause", this.onPause);
869
+ this.onPause = null;
870
+ }
871
+ if (this.onProgress) {
872
+ this.movie.off("progress", this.onProgress);
873
+ this.onProgress = null;
874
+ }
875
+ if (this.onWrapPointerMove) {
876
+ this.wrapper.removeEventListener("pointermove", this.onWrapPointerMove);
877
+ this.onWrapPointerMove = null;
878
+ }
879
+ if (this.onWrapMouseLeave) {
880
+ this.wrapper.removeEventListener("mouseleave", this.onWrapMouseLeave);
881
+ this.onWrapMouseLeave = null;
882
+ }
883
+ if (this.resizeObserver) {
884
+ this.resizeObserver.disconnect();
885
+ this.resizeObserver = null;
886
+ }
887
+ this.root.remove();
888
+ if (this.wrappedHere) {
889
+ const parent = this.wrapper.parentElement;
890
+ const canvas = this.options.canvas;
891
+ if (parent) {
892
+ parent.insertBefore(canvas, this.wrapper);
893
+ this.wrapper.remove();
894
+ }
895
+ } else {
896
+ this.wrapper.removeAttribute("data-mc-wrap");
897
+ }
898
+ if (this.keyHandler) {
899
+ document.removeEventListener("keydown", this.keyHandler);
900
+ this.keyHandler = null;
901
+ }
902
+ if (this.settingsKeyHandler) {
903
+ document.removeEventListener("keydown", this.settingsKeyHandler);
904
+ this.settingsKeyHandler = null;
905
+ }
906
+ if (this.settingsOutsideHandler) {
907
+ document.removeEventListener("pointerdown", this.settingsOutsideHandler);
908
+ this.settingsOutsideHandler = null;
909
+ }
910
+ if (this.fullscreenChangeHandler) {
911
+ document.removeEventListener("fullscreenchange", this.fullscreenChangeHandler);
912
+ this.fullscreenChangeHandler = null;
913
+ }
914
+ if (this.wheelHandler && this.volumeContainerEl) {
915
+ this.volumeContainerEl.removeEventListener("wheel", this.wheelHandler);
916
+ this.wheelHandler = null;
917
+ }
918
+ if (document.fullscreenElement === this.wrapper) {
919
+ void document.exitFullscreen().catch(() => {
920
+ });
921
+ }
922
+ if (this.hideTimer) {
923
+ clearTimeout(this.hideTimer);
924
+ this.hideTimer = null;
925
+ }
926
+ this.hideExportOverlay();
927
+ uninstallStyles();
928
+ }
929
+ };
930
+
931
+ export { Controller, extensionForMimeType, formatTime, frameToPercent, pxToFraction, pxToFrame };
932
+ //# sourceMappingURL=Controller.js.map
933
+ //# sourceMappingURL=Controller.js.map