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