@aicut/core 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.
package/dist/index.js ADDED
@@ -0,0 +1,3331 @@
1
+ // src/events.ts
2
+ var EventBus = class {
3
+ listeners = /* @__PURE__ */ new Map();
4
+ on(event, handler) {
5
+ let set = this.listeners.get(event);
6
+ if (!set) {
7
+ set = /* @__PURE__ */ new Set();
8
+ this.listeners.set(event, set);
9
+ }
10
+ set.add(handler);
11
+ return () => this.off(event, handler);
12
+ }
13
+ off(event, handler) {
14
+ this.listeners.get(event)?.delete(handler);
15
+ }
16
+ emit(event, payload) {
17
+ const set = this.listeners.get(event);
18
+ if (!set) return;
19
+ for (const h of set) {
20
+ try {
21
+ h(payload);
22
+ } catch (err) {
23
+ console.error("[aicut] event handler threw", event, err);
24
+ }
25
+ }
26
+ }
27
+ clear() {
28
+ this.listeners.clear();
29
+ }
30
+ };
31
+
32
+ // src/history.ts
33
+ var HistoryStack = class {
34
+ undoStack = [];
35
+ redoStack = [];
36
+ limit;
37
+ constructor(limit = 50) {
38
+ this.limit = limit;
39
+ }
40
+ /** Call BEFORE applying a mutation. */
41
+ push(previous) {
42
+ this.undoStack.push(clone(previous));
43
+ if (this.undoStack.length > this.limit) this.undoStack.shift();
44
+ this.redoStack.length = 0;
45
+ }
46
+ canUndo() {
47
+ return this.undoStack.length > 0;
48
+ }
49
+ canRedo() {
50
+ return this.redoStack.length > 0;
51
+ }
52
+ /** Returns the project to restore, or null if nothing to undo. */
53
+ undo(current) {
54
+ const prev = this.undoStack.pop();
55
+ if (!prev) return null;
56
+ this.redoStack.push(clone(current));
57
+ return prev;
58
+ }
59
+ redo(current) {
60
+ const next = this.redoStack.pop();
61
+ if (!next) return null;
62
+ this.undoStack.push(clone(current));
63
+ return next;
64
+ }
65
+ clear() {
66
+ this.undoStack.length = 0;
67
+ this.redoStack.length = 0;
68
+ }
69
+ };
70
+ function clone(p) {
71
+ return JSON.parse(JSON.stringify(p));
72
+ }
73
+
74
+ // src/ids.ts
75
+ function createId(prefix = "id") {
76
+ const rand = Math.random().toString(36).slice(2, 8);
77
+ const t = Date.now().toString(36).slice(-4);
78
+ return `${prefix}_${t}${rand}`;
79
+ }
80
+
81
+ // src/model.ts
82
+ function createEmptyProject() {
83
+ return {
84
+ version: 1,
85
+ sources: [],
86
+ tracks: [{ id: createId("track"), kind: "video", clips: [] }]
87
+ };
88
+ }
89
+ function clipDuration(c) {
90
+ return c.out - c.in;
91
+ }
92
+ function clipEnd(c) {
93
+ return c.start + clipDuration(c);
94
+ }
95
+ function trackEnd(track) {
96
+ let max = 0;
97
+ for (const c of track.clips) {
98
+ const end = clipEnd(c);
99
+ if (end > max) max = end;
100
+ }
101
+ return max;
102
+ }
103
+ function findClipContaining(track, timeMs) {
104
+ for (const c of track.clips) {
105
+ if (timeMs >= c.start && timeMs < clipEnd(c)) return c;
106
+ }
107
+ return null;
108
+ }
109
+ function findTrackOfClip(project, clipId) {
110
+ for (const t of project.tracks) {
111
+ if (t.clips.some((c) => c.id === clipId)) return t;
112
+ }
113
+ return null;
114
+ }
115
+ function normalizeProject(project) {
116
+ const sources = project.sources.map((s) => ({ ...s }));
117
+ const tracks = project.tracks.map((t) => {
118
+ const clips = t.clips.filter((c) => c.out > c.in).map((c) => ({ ...c, id: c.id || createId("clip") })).sort((a, b) => a.start - b.start);
119
+ return { ...t, id: t.id || createId("track"), clips };
120
+ });
121
+ return { version: 1, sources, tracks };
122
+ }
123
+ function splitClipAt(clip, localOffset) {
124
+ const sourceLen = clip.out - clip.in;
125
+ if (localOffset <= 0 || localOffset >= sourceLen) return null;
126
+ const left = { ...clip, out: clip.in + localOffset };
127
+ const right = {
128
+ ...clip,
129
+ id: createId("clip"),
130
+ in: clip.in + localOffset,
131
+ start: clip.start + localOffset
132
+ };
133
+ return [left, right];
134
+ }
135
+ function projectDuration(project) {
136
+ let max = 0;
137
+ for (const t of project.tracks) {
138
+ const e = trackEnd(t);
139
+ if (e > max) max = e;
140
+ }
141
+ return max;
142
+ }
143
+
144
+ // src/timeline/layout.ts
145
+ var TRACK_HEIGHT = 56;
146
+ var RULER_HEIGHT = 24;
147
+ var HEADER_WIDTH = 96;
148
+ var HANDLE_PX = 8;
149
+ var CLIP_INSET = 6;
150
+ var SCALE_MIN = 10;
151
+ var SCALE_MAX = 400;
152
+ var SCROLLBAR_THICKNESS = 10;
153
+ var SCROLLBAR_MIN_THUMB = 24;
154
+ var SCROLLBAR_INSET = 2;
155
+ function contentHeight(tracks, isDragging) {
156
+ return tracks.length * TRACK_HEIGHT + (isDragging ? TRACK_HEIGHT : 0);
157
+ }
158
+ function contentWidth(project, pxPerSec) {
159
+ let max = 0;
160
+ for (const t of project.tracks) {
161
+ for (const c of t.clips) {
162
+ const end = c.start + (c.out - c.in);
163
+ if (end > max) max = end;
164
+ }
165
+ }
166
+ return max / 1e3 * pxPerSec;
167
+ }
168
+ function trackY(index) {
169
+ return RULER_HEIGHT + index * TRACK_HEIGHT;
170
+ }
171
+ function trackIndexAt(y, trackCount, scrollTop = 0) {
172
+ if (y < RULER_HEIGHT) return -1;
173
+ const contentY = y - RULER_HEIGHT + scrollTop;
174
+ const idx = Math.floor(contentY / TRACK_HEIGHT);
175
+ if (idx < 0 || idx >= trackCount) return -1;
176
+ return idx;
177
+ }
178
+ function xToMs(x, pxPerSec, scrollLeft, showHeader) {
179
+ const base = showHeader ? HEADER_WIDTH : 0;
180
+ return Math.max(0, (x - base + scrollLeft) / pxPerSec * 1e3);
181
+ }
182
+ function niceTickSeconds(targetSec) {
183
+ if (targetSec <= 0) return 1;
184
+ const exp = Math.floor(Math.log10(targetSec));
185
+ const base = targetSec / Math.pow(10, exp);
186
+ let nice;
187
+ if (base < 1.5) nice = 1;
188
+ else if (base < 3) nice = 2;
189
+ else if (base < 7) nice = 5;
190
+ else nice = 10;
191
+ return nice * Math.pow(10, exp);
192
+ }
193
+ function formatRulerLabel(sec) {
194
+ if (sec < 60) return `${Math.round(sec * 10) / 10}s`;
195
+ const m = Math.floor(sec / 60);
196
+ const s = Math.round(sec - m * 60);
197
+ return `${m}:${s.toString().padStart(2, "0")}`;
198
+ }
199
+ function clampScale(s) {
200
+ return Math.max(SCALE_MIN, Math.min(SCALE_MAX, s));
201
+ }
202
+ function snapTargets(project, playheadMs, ignoreClipId) {
203
+ const arr = [0, playheadMs];
204
+ for (const t of project.tracks) {
205
+ for (const c of t.clips) {
206
+ if (c.id === ignoreClipId) continue;
207
+ arr.push(c.start, c.start + (c.out - c.in));
208
+ }
209
+ }
210
+ return arr;
211
+ }
212
+ function wouldOverlap(track, clipId, start, end) {
213
+ for (const c of track.clips) {
214
+ if (c.id === clipId) continue;
215
+ const cEnd = c.start + (c.out - c.in);
216
+ if (start < cEnd && end > c.start) return true;
217
+ }
218
+ return false;
219
+ }
220
+ function findClip(project, clipId) {
221
+ for (let i = 0; i < project.tracks.length; i++) {
222
+ const t = project.tracks[i];
223
+ const c = t.clips.find((c2) => c2.id === clipId);
224
+ if (c) return { track: t, clip: c, trackIndex: i };
225
+ }
226
+ return null;
227
+ }
228
+ function uncoveredIntervals(project) {
229
+ const intervals = [];
230
+ for (const t of project.tracks) {
231
+ if (t.kind !== "video") continue;
232
+ for (const c of t.clips) {
233
+ const end = c.start + (c.out - c.in);
234
+ if (end > c.start) intervals.push([c.start, end]);
235
+ }
236
+ }
237
+ if (intervals.length === 0) return [];
238
+ intervals.sort((a, b) => a[0] - b[0]);
239
+ const merged = [];
240
+ for (const [s, e] of intervals) {
241
+ const last = merged[merged.length - 1];
242
+ if (last && s <= last[1]) last[1] = Math.max(last[1], e);
243
+ else merged.push([s, e]);
244
+ }
245
+ const gaps = [];
246
+ let cursor = 0;
247
+ for (const [s, e] of merged) {
248
+ if (s > cursor) gaps.push([cursor, s]);
249
+ cursor = e;
250
+ }
251
+ return gaps;
252
+ }
253
+
254
+ // src/playback.ts
255
+ var PlaybackEngine = class {
256
+ host;
257
+ mount;
258
+ videos = /* @__PURE__ */ new Map();
259
+ project;
260
+ currentClipId = null;
261
+ playing = false;
262
+ timeMs = 0;
263
+ rafHandle = null;
264
+ lastFrameTs = 0;
265
+ /** Public event hooks — set by Editor. */
266
+ onTimeUpdate;
267
+ onEnded;
268
+ onError;
269
+ onReady;
270
+ onSourceMetadata;
271
+ constructor(host, project) {
272
+ this.host = host;
273
+ this.project = project;
274
+ this.mount = document.createElement("div");
275
+ this.mount.className = "aicut-preview";
276
+ this.host.appendChild(this.mount);
277
+ this.syncSources();
278
+ }
279
+ setProject(next) {
280
+ this.project = next;
281
+ this.syncSources();
282
+ const clip = this.clipAtTime(this.timeMs);
283
+ if (!clip) {
284
+ this.timeMs = 0;
285
+ this.activate(null);
286
+ this.onTimeUpdate?.(0);
287
+ } else {
288
+ this.activate(clip);
289
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
290
+ }
291
+ }
292
+ play() {
293
+ if (this.playing) return;
294
+ if (this.totalDuration() <= 0) return;
295
+ let clip = this.clipAtTime(this.timeMs) ?? this.nextClipAfterTime(this.timeMs);
296
+ if (!clip) return;
297
+ if (this.timeMs < clip.start) this.timeMs = clip.start;
298
+ this.activate(clip);
299
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
300
+ const v = this.videos.get(clip.sourceId);
301
+ if (!v) return;
302
+ void v.play().catch((err) => this.onError?.(err));
303
+ this.playing = true;
304
+ this.startTickLoop();
305
+ }
306
+ pause() {
307
+ if (!this.playing) return;
308
+ this.playing = false;
309
+ this.stopTickLoop();
310
+ if (this.currentClipId) {
311
+ const clip = this.clipById(this.currentClipId);
312
+ if (clip) {
313
+ const v = this.videos.get(clip.sourceId);
314
+ v?.pause();
315
+ }
316
+ }
317
+ }
318
+ isPlaying() {
319
+ return this.playing;
320
+ }
321
+ getTime() {
322
+ return this.timeMs;
323
+ }
324
+ seek(timeMs) {
325
+ const total = this.totalDuration();
326
+ if (total <= 0) {
327
+ this.timeMs = 0;
328
+ return;
329
+ }
330
+ const clamped = Math.max(0, Math.min(timeMs, total));
331
+ this.timeMs = clamped;
332
+ const clip = this.clipAtTime(clamped);
333
+ if (clip) {
334
+ this.activate(clip);
335
+ this.seekVideoToClipOffset(clip, clamped - clip.start);
336
+ } else {
337
+ this.activate(null);
338
+ }
339
+ this.onTimeUpdate?.(clamped);
340
+ }
341
+ destroy() {
342
+ this.stopTickLoop();
343
+ for (const v of this.videos.values()) {
344
+ v.pause();
345
+ v.removeAttribute("src");
346
+ v.load();
347
+ v.remove();
348
+ }
349
+ this.videos.clear();
350
+ this.mount.remove();
351
+ }
352
+ // --- internals -------------------------------------------------------
353
+ syncSources() {
354
+ const wanted = new Set(this.project.sources.map((s) => s.id));
355
+ for (const [id, v] of this.videos) {
356
+ if (!wanted.has(id)) {
357
+ v.pause();
358
+ v.remove();
359
+ this.videos.delete(id);
360
+ }
361
+ }
362
+ for (const src of this.project.sources) {
363
+ if (src.kind !== "video") continue;
364
+ if (this.videos.has(src.id)) continue;
365
+ const v = document.createElement("video");
366
+ v.preload = "auto";
367
+ v.playsInline = true;
368
+ v.muted = false;
369
+ v.src = src.url;
370
+ v.style.position = "absolute";
371
+ v.style.inset = "0";
372
+ v.style.width = "100%";
373
+ v.style.height = "100%";
374
+ v.style.objectFit = "contain";
375
+ v.style.visibility = "hidden";
376
+ const sourceId = src.id;
377
+ v.addEventListener(
378
+ "error",
379
+ () => this.onError?.(new Error(`Failed to load ${src.url}`))
380
+ );
381
+ v.addEventListener("loadedmetadata", () => {
382
+ this.onReady?.();
383
+ const durMs = Math.round(v.duration * 1e3);
384
+ if (Number.isFinite(durMs) && durMs > 0) {
385
+ this.onSourceMetadata?.(sourceId, durMs);
386
+ }
387
+ });
388
+ this.mount.appendChild(v);
389
+ this.videos.set(src.id, v);
390
+ }
391
+ }
392
+ activate(clip) {
393
+ if (clip?.id === this.currentClipId) return;
394
+ if (this.currentClipId) {
395
+ const prev = this.clipById(this.currentClipId);
396
+ if (prev) {
397
+ const v = this.videos.get(prev.sourceId);
398
+ if (v) {
399
+ v.pause();
400
+ v.style.visibility = "hidden";
401
+ }
402
+ }
403
+ }
404
+ this.currentClipId = clip ? clip.id : null;
405
+ if (clip) {
406
+ const v = this.videos.get(clip.sourceId);
407
+ if (v) v.style.visibility = "visible";
408
+ }
409
+ }
410
+ seekVideoToClipOffset(clip, offsetMs) {
411
+ const v = this.videos.get(clip.sourceId);
412
+ if (!v) return;
413
+ const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
414
+ if (Math.abs(v.currentTime - target) > 0.05) {
415
+ v.currentTime = target;
416
+ }
417
+ }
418
+ clipById(id) {
419
+ for (const t of this.project.tracks) {
420
+ for (const c of t.clips) if (c.id === id) return c;
421
+ }
422
+ return null;
423
+ }
424
+ /**
425
+ * Find the clip whose timeline range contains `timeMs`, searching
426
+ * across ALL video tracks. If multiple tracks have a clip at this
427
+ * moment, the lowest-index track wins (matches the "Track 1 is
428
+ * background" convention used in the auto-split UX — overlapping
429
+ * placements would have created a new track on top, but here we
430
+ * fall back to the underlying clip).
431
+ */
432
+ clipAtTime(timeMs) {
433
+ for (const t of this.project.tracks) {
434
+ if (t.kind !== "video") continue;
435
+ for (const c of t.clips) {
436
+ if (timeMs >= c.start && timeMs < c.start + (c.out - c.in)) return c;
437
+ }
438
+ }
439
+ return null;
440
+ }
441
+ /** Earliest clip starting at-or-after `timeMs` across all video tracks. */
442
+ nextClipAfterTime(timeMs) {
443
+ let best = null;
444
+ for (const t of this.project.tracks) {
445
+ if (t.kind !== "video") continue;
446
+ for (const c of t.clips) {
447
+ if (c.start >= timeMs && (!best || c.start < best.start)) best = c;
448
+ }
449
+ }
450
+ return best;
451
+ }
452
+ /** Max clip end across all video tracks. */
453
+ totalDuration() {
454
+ let max = 0;
455
+ for (const t of this.project.tracks) {
456
+ if (t.kind !== "video") continue;
457
+ for (const c of t.clips) {
458
+ const e = c.start + (c.out - c.in);
459
+ if (e > max) max = e;
460
+ }
461
+ }
462
+ return max;
463
+ }
464
+ startTickLoop() {
465
+ this.lastFrameTs = performance.now();
466
+ const tick = (now) => {
467
+ if (!this.playing) return;
468
+ const dtMs = now - this.lastFrameTs;
469
+ this.lastFrameTs = now;
470
+ this.advance(dtMs);
471
+ this.rafHandle = requestAnimationFrame(tick);
472
+ };
473
+ this.rafHandle = requestAnimationFrame(tick);
474
+ }
475
+ stopTickLoop() {
476
+ if (this.rafHandle != null) {
477
+ cancelAnimationFrame(this.rafHandle);
478
+ this.rafHandle = null;
479
+ }
480
+ }
481
+ advance(dtMs) {
482
+ if (this.project.tracks.length === 0) return;
483
+ this.timeMs += dtMs;
484
+ const totalDur = this.totalDuration();
485
+ if (this.timeMs >= totalDur) {
486
+ this.timeMs = totalDur;
487
+ this.onTimeUpdate?.(this.timeMs);
488
+ this.pause();
489
+ this.onEnded?.();
490
+ return;
491
+ }
492
+ const clip = this.clipAtTime(this.timeMs);
493
+ if (!clip) {
494
+ const next = this.nextClipAfterTime(this.timeMs);
495
+ if (next) {
496
+ this.timeMs = next.start;
497
+ this.activate(next);
498
+ this.seekVideoToClipOffset(next, 0);
499
+ const v = this.videos.get(next.sourceId);
500
+ if (v) void v.play().catch((err) => this.onError?.(err));
501
+ } else {
502
+ this.pause();
503
+ this.onEnded?.();
504
+ return;
505
+ }
506
+ } else if (clip.id !== this.currentClipId) {
507
+ this.activate(clip);
508
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
509
+ const v = this.videos.get(clip.sourceId);
510
+ if (v) void v.play().catch((err) => this.onError?.(err));
511
+ }
512
+ this.onTimeUpdate?.(this.timeMs);
513
+ }
514
+ };
515
+
516
+ // src/theme.ts
517
+ var THEME_VARS = {
518
+ brand: "--color-brand",
519
+ secondary: "--color-secondary",
520
+ surface: "--color-surface",
521
+ dark: "--color-dark",
522
+ muted: "--color-muted",
523
+ card: "--color-card",
524
+ success: "--color-success",
525
+ warning: "--color-warning",
526
+ info: "--color-info",
527
+ error: "--color-error",
528
+ controlsBg: "--aicut-controls-bg",
529
+ controlsBorder: "--aicut-controls-border",
530
+ controlsText: "--aicut-controls-text",
531
+ controlsHover: "--aicut-controls-hover",
532
+ controlsActive: "--aicut-controls-active",
533
+ previewBg: "--aicut-preview-bg",
534
+ radiusSm: "--aicut-radius-sm",
535
+ radiusMd: "--aicut-radius-md",
536
+ radiusLg: "--aicut-radius-lg"
537
+ };
538
+ function applyTheme(root, theme) {
539
+ if (!theme) return;
540
+ for (const key of Object.keys(theme)) {
541
+ const cssVar = THEME_VARS[key];
542
+ const value = theme[key];
543
+ if (cssVar && value) root.style.setProperty(cssVar, value);
544
+ }
545
+ }
546
+
547
+ // src/i18n.ts
548
+ var localeEn = {
549
+ undo: "Undo",
550
+ redo: "Redo",
551
+ split: "Split",
552
+ trimLeft: "Trim left edge",
553
+ trimRight: "Trim right edge",
554
+ speedComingSoon: "Speed (coming soon)",
555
+ playPause: "Play / Pause (Space)",
556
+ fullscreen: "Fullscreen preview",
557
+ snap: "Snap",
558
+ snapOnTitle: "Turn off snap",
559
+ snapOffTitle: "Turn on snap",
560
+ zoomOut: "Zoom out",
561
+ zoomIn: "Zoom in",
562
+ reset: "Reset edits (keep sources)",
563
+ exitFullscreen: "Exit fullscreen",
564
+ exitFullscreenTitle: "Exit fullscreen (Esc)",
565
+ newTrack: "+ New track",
566
+ videoTrackLabel: "Video {n}",
567
+ audioTrackLabel: "Audio {n}"
568
+ };
569
+ var localeZh = {
570
+ undo: "\u64A4\u9500",
571
+ redo: "\u91CD\u505A",
572
+ split: "\u5206\u5272",
573
+ trimLeft: "\u5411\u5DE6\u88C1\u526A",
574
+ trimRight: "\u5411\u53F3\u88C1\u526A",
575
+ speedComingSoon: "\u53D8\u901F\uFF08\u5373\u5C06\u5230\u6765\uFF09",
576
+ playPause: "\u64AD\u653E / \u6682\u505C (Space)",
577
+ fullscreen: "\u5168\u5C4F\u9884\u89C8",
578
+ snap: "\u5438\u9644",
579
+ snapOnTitle: "\u5173\u95ED\u5438\u9644",
580
+ snapOffTitle: "\u5F00\u542F\u5438\u9644",
581
+ zoomOut: "\u7F29\u5C0F",
582
+ zoomIn: "\u653E\u5927",
583
+ reset: "\u91CD\u7F6E\u7F16\u8F91\uFF08\u4FDD\u7559\u89C6\u9891\u6E90\uFF09",
584
+ exitFullscreen: "\u9000\u51FA\u5168\u5C4F",
585
+ exitFullscreenTitle: "\u9000\u51FA\u5168\u5C4F (Esc)",
586
+ newTrack: "+ \u65B0\u8F68\u9053",
587
+ videoTrackLabel: "\u89C6\u9891 {n}",
588
+ audioTrackLabel: "\u97F3\u9891 {n}"
589
+ };
590
+ function mergeLocale(partial) {
591
+ return partial ? { ...localeEn, ...partial } : localeEn;
592
+ }
593
+ function formatLabel(template, vars) {
594
+ return template.replace(
595
+ /\{(\w+)\}/g,
596
+ (_, k) => k in vars ? String(vars[k]) : `{${k}}`
597
+ );
598
+ }
599
+
600
+ // src/ui/thumbnails.ts
601
+ var THUMB_HEIGHT = 44;
602
+ var THUMB_WIDTH = Math.round(THUMB_HEIGHT * (16 / 9));
603
+ var BUCKET_MS = 250;
604
+ var ThumbnailRibbon = class {
605
+ host;
606
+ sources = /* @__PURE__ */ new Map();
607
+ onUpdate;
608
+ static get THUMB_HEIGHT() {
609
+ return THUMB_HEIGHT;
610
+ }
611
+ static get THUMB_WIDTH() {
612
+ return THUMB_WIDTH;
613
+ }
614
+ constructor(host, onUpdate) {
615
+ this.host = host;
616
+ this.onUpdate = onUpdate;
617
+ }
618
+ syncSources(sources) {
619
+ const wanted = new Set(sources.map((s) => s.id));
620
+ for (const [id, st] of this.sources) {
621
+ if (!wanted.has(id)) {
622
+ st.video.remove();
623
+ for (const bmp of st.cache.values()) bmp.close();
624
+ this.sources.delete(id);
625
+ }
626
+ }
627
+ for (const src of sources) {
628
+ if (src.kind !== "video") continue;
629
+ if (this.sources.has(src.id)) continue;
630
+ const v = document.createElement("video");
631
+ v.src = src.url;
632
+ v.preload = "auto";
633
+ v.muted = true;
634
+ v.playsInline = true;
635
+ v.style.position = "absolute";
636
+ v.style.left = "-9999px";
637
+ v.style.top = "-9999px";
638
+ v.style.width = "160px";
639
+ v.style.height = "90px";
640
+ this.host.appendChild(v);
641
+ const ready = new Promise((resolve) => {
642
+ if (v.readyState >= 1) resolve();
643
+ else v.addEventListener("loadedmetadata", () => resolve(), { once: true });
644
+ });
645
+ this.sources.set(src.id, {
646
+ video: v,
647
+ cache: /* @__PURE__ */ new Map(),
648
+ inflight: /* @__PURE__ */ new Set(),
649
+ ready,
650
+ queue: [],
651
+ busy: false
652
+ });
653
+ }
654
+ }
655
+ /**
656
+ * Paint thumbnails for the clip's visible window onto `ctx`. The
657
+ * canvas is the per-clip strip — width = clip's px width, height =
658
+ * THUMB_HEIGHT. Source-time range derives from the clip's `in/out`
659
+ * and the px range we're drawing into.
660
+ */
661
+ paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth) {
662
+ ctx.clearRect(0, 0, pxWidth, THUMB_HEIGHT);
663
+ const st = this.sources.get(sourceId);
664
+ if (!st) return;
665
+ if (sourceOutMs <= sourceInMs || pxWidth <= 0) return;
666
+ const count = Math.max(1, Math.ceil(pxWidth / THUMB_WIDTH));
667
+ const spanMs = sourceOutMs - sourceInMs;
668
+ for (let i = 0; i < count; i++) {
669
+ const tMs = sourceInMs + spanMs * i / count;
670
+ const bucket = Math.round(tMs / BUCKET_MS) * BUCKET_MS;
671
+ const bmp = st.cache.get(bucket);
672
+ const x = Math.round(i * pxWidth / count);
673
+ const w = Math.round((i + 1) * pxWidth / count) - x;
674
+ if (bmp) {
675
+ ctx.drawImage(bmp, x, 0, w, THUMB_HEIGHT);
676
+ } else {
677
+ ctx.fillStyle = "rgba(255,255,255,0.04)";
678
+ ctx.fillRect(x, 0, w, THUMB_HEIGHT);
679
+ this.enqueue(st, bucket);
680
+ }
681
+ }
682
+ }
683
+ destroy() {
684
+ for (const st of this.sources.values()) {
685
+ st.video.remove();
686
+ for (const bmp of st.cache.values()) bmp.close();
687
+ }
688
+ this.sources.clear();
689
+ }
690
+ // ---- internals ------------------------------------------------------
691
+ enqueue(st, bucketMs) {
692
+ if (st.cache.has(bucketMs) || st.inflight.has(bucketMs)) return;
693
+ st.inflight.add(bucketMs);
694
+ st.queue.push(bucketMs);
695
+ void this.drain(st);
696
+ }
697
+ async drain(st) {
698
+ if (st.busy) return;
699
+ st.busy = true;
700
+ try {
701
+ await st.ready;
702
+ while (st.queue.length > 0) {
703
+ const t = st.queue.shift();
704
+ try {
705
+ const bmp = await this.extractFrame(st.video, t);
706
+ st.cache.set(t, bmp);
707
+ } catch {
708
+ } finally {
709
+ st.inflight.delete(t);
710
+ }
711
+ this.onUpdate();
712
+ }
713
+ } finally {
714
+ st.busy = false;
715
+ }
716
+ }
717
+ extractFrame(video, timeMs) {
718
+ const targetSec = Math.max(
719
+ 0,
720
+ Math.min((video.duration || Infinity) - 0.05, timeMs / 1e3)
721
+ );
722
+ return new Promise((resolve, reject) => {
723
+ const onSeeked = () => {
724
+ try {
725
+ const cnv = document.createElement("canvas");
726
+ cnv.width = THUMB_WIDTH;
727
+ cnv.height = THUMB_HEIGHT;
728
+ const cx = cnv.getContext("2d");
729
+ if (!cx) return reject(new Error("no 2d ctx"));
730
+ cx.drawImage(video, 0, 0, THUMB_WIDTH, THUMB_HEIGHT);
731
+ createImageBitmap(cnv).then(resolve, reject);
732
+ } catch (e) {
733
+ reject(e);
734
+ }
735
+ };
736
+ video.addEventListener("seeked", onSeeked, { once: true });
737
+ try {
738
+ video.currentTime = targetSec;
739
+ } catch (e) {
740
+ video.removeEventListener("seeked", onSeeked);
741
+ reject(e);
742
+ }
743
+ });
744
+ }
745
+ };
746
+
747
+ // src/ui/format.ts
748
+ function fmtClock(ms) {
749
+ const total = Math.max(0, Math.round(ms / 1e3));
750
+ const m = Math.floor(total / 60);
751
+ const s = total % 60;
752
+ return `${pad2(m)}:${pad2(s)}`;
753
+ }
754
+ function fmtClockMs(ms) {
755
+ const total = Math.max(0, Math.round(ms));
756
+ const m = Math.floor(total / 6e4);
757
+ const s = Math.floor(total % 6e4 / 1e3);
758
+ const r = total % 1e3;
759
+ return `${pad2(m)}:${pad2(s)}.${pad3(r)}`;
760
+ }
761
+ var pad2 = (n) => n < 10 ? `0${n}` : `${n}`;
762
+ var pad3 = (n) => n.toString().padStart(3, "0");
763
+
764
+ // src/timeline/draw.ts
765
+ function drawAll(ctx, state, style, thumbs) {
766
+ const { viewportWidth: W, viewportHeight: H } = state;
767
+ ctx.fillStyle = style.bg;
768
+ ctx.fillRect(0, 0, W, H);
769
+ const baseX = state.showHeader ? HEADER_WIDTH : 0;
770
+ const trackAreaW = W - baseX - SCROLLBAR_THICKNESS;
771
+ const trackAreaH = H - RULER_HEIGHT - SCROLLBAR_THICKNESS;
772
+ ctx.save();
773
+ ctx.beginPath();
774
+ ctx.rect(baseX, RULER_HEIGHT, trackAreaW, trackAreaH);
775
+ ctx.clip();
776
+ ctx.translate(0, -state.scrollTop);
777
+ drawTracks(ctx, state, style, thumbs);
778
+ if (state.isDragging) {
779
+ drawPhantomRow(ctx, state.project.tracks.length, baseX, state, style);
780
+ }
781
+ if (state.dragGhost) drawDragGhost(ctx, state, style, thumbs);
782
+ ctx.restore();
783
+ if (state.showHeader) {
784
+ ctx.save();
785
+ ctx.beginPath();
786
+ ctx.rect(0, RULER_HEIGHT, HEADER_WIDTH, trackAreaH);
787
+ ctx.clip();
788
+ ctx.translate(0, -state.scrollTop);
789
+ drawHeaders(ctx, state, style);
790
+ ctx.restore();
791
+ }
792
+ ctx.save();
793
+ ctx.beginPath();
794
+ ctx.rect(baseX, 0, trackAreaW, RULER_HEIGHT);
795
+ ctx.clip();
796
+ drawRuler(ctx, state, style);
797
+ ctx.restore();
798
+ ctx.save();
799
+ ctx.beginPath();
800
+ ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
801
+ ctx.clip();
802
+ drawCoverageGaps(ctx, state);
803
+ ctx.restore();
804
+ ctx.save();
805
+ ctx.beginPath();
806
+ ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
807
+ ctx.clip();
808
+ drawSnapGuide(ctx, state, style);
809
+ drawPlayhead(ctx, state, style);
810
+ ctx.restore();
811
+ drawScrollbarV(ctx, state, style);
812
+ drawScrollbarH(ctx, state, style);
813
+ }
814
+ function drawCoverageGaps(ctx, state, style) {
815
+ const gaps = uncoveredIntervals(state.project);
816
+ if (gaps.length === 0) return;
817
+ const baseX = state.showHeader ? HEADER_WIDTH : 0;
818
+ const trackStackH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
819
+ for (const [s, e] of gaps) {
820
+ const x1 = Math.max(
821
+ baseX,
822
+ baseX + s / 1e3 * state.pxPerSec - state.scrollLeft
823
+ );
824
+ const x2 = Math.min(
825
+ state.viewportWidth,
826
+ baseX + e / 1e3 * state.pxPerSec - state.scrollLeft
827
+ );
828
+ if (x2 <= x1) continue;
829
+ ctx.fillStyle = "rgba(250, 167, 0, 0.35)";
830
+ ctx.fillRect(x1, 0, x2 - x1, RULER_HEIGHT);
831
+ ctx.fillStyle = "rgba(250, 167, 0, 0.12)";
832
+ ctx.fillRect(x1, RULER_HEIGHT, x2 - x1, trackStackH);
833
+ ctx.save();
834
+ ctx.strokeStyle = "rgba(250, 167, 0, 0.6)";
835
+ ctx.lineWidth = 1;
836
+ ctx.beginPath();
837
+ for (let hx = Math.floor(x1); hx < x2; hx += 6) {
838
+ ctx.moveTo(hx, RULER_HEIGHT - 1);
839
+ ctx.lineTo(hx + 6, RULER_HEIGHT - 7);
840
+ }
841
+ ctx.stroke();
842
+ ctx.restore();
843
+ }
844
+ }
845
+ function drawDragGhost(ctx, state, style, thumbs) {
846
+ const ghost = state.dragGhost;
847
+ let real = null;
848
+ for (const t of state.project.tracks) {
849
+ const c = t.clips.find((c2) => c2.id === ghost.clipId);
850
+ if (c) {
851
+ real = c;
852
+ break;
853
+ }
854
+ }
855
+ if (!real) return;
856
+ const baseX = state.showHeader ? HEADER_WIDTH : 0;
857
+ const widthPx = Math.max(2, (real.out - real.in) / 1e3 * state.pxPerSec);
858
+ const startX = baseX + ghost.ghostStart / 1e3 * state.pxPerSec - state.scrollLeft;
859
+ const overlap = ghost.wouldOverlap;
860
+ drawDropOutline(
861
+ ctx,
862
+ startX,
863
+ overlap ? state.project.tracks.length : ghost.ghostTrackIndex,
864
+ widthPx,
865
+ style.info,
866
+ overlap
867
+ );
868
+ ctx.save();
869
+ ctx.globalAlpha = 0.85;
870
+ drawClipAt(
871
+ ctx,
872
+ real,
873
+ overlap ? state.project.tracks.length : ghost.ghostTrackIndex,
874
+ ghost.ghostStart,
875
+ state.project.sources,
876
+ state,
877
+ style,
878
+ thumbs,
879
+ /* dim = */
880
+ false,
881
+ /* warn = */
882
+ overlap
883
+ );
884
+ ctx.restore();
885
+ }
886
+ function drawDropOutline(ctx, startX, trackIndex, widthPx, color, emphasized) {
887
+ const y = trackY(trackIndex) + CLIP_INSET - 1;
888
+ const h = TRACK_HEIGHT - CLIP_INSET * 2 + 2;
889
+ ctx.save();
890
+ if (emphasized) {
891
+ ctx.shadowColor = withAlpha(color, 0.45);
892
+ ctx.shadowBlur = 6;
893
+ }
894
+ ctx.strokeStyle = withAlpha(color, emphasized ? 0.9 : 0.7);
895
+ ctx.lineWidth = 1;
896
+ roundRect(ctx, startX - 0.5, y, widthPx + 1, h, 6);
897
+ ctx.stroke();
898
+ ctx.restore();
899
+ }
900
+ function drawPhantomRow(ctx, trackIndex, baseX, state, style) {
901
+ const y = trackY(trackIndex);
902
+ const w = state.viewportWidth - baseX;
903
+ ctx.save();
904
+ ctx.fillStyle = withAlpha(style.info, 0.04);
905
+ ctx.fillRect(baseX, y, w, TRACK_HEIGHT);
906
+ ctx.strokeStyle = withAlpha(style.info, 0.35);
907
+ ctx.lineWidth = 1;
908
+ ctx.setLineDash([3, 4]);
909
+ ctx.beginPath();
910
+ ctx.moveTo(baseX, y + 0.5);
911
+ ctx.lineTo(baseX + w, y + 0.5);
912
+ ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
913
+ ctx.lineTo(baseX + w, y + TRACK_HEIGHT - 0.5);
914
+ ctx.stroke();
915
+ ctx.setLineDash([]);
916
+ if (state.showHeader) {
917
+ ctx.fillStyle = withAlpha(style.info, 0.7);
918
+ ctx.font = "10px system-ui, -apple-system, sans-serif";
919
+ ctx.textBaseline = "middle";
920
+ ctx.fillText(state.locale.newTrack, 12, y + TRACK_HEIGHT / 2);
921
+ }
922
+ ctx.restore();
923
+ }
924
+ function drawRuler(ctx, state, style) {
925
+ const { pxPerSec, scrollLeft, viewportWidth: W } = state;
926
+ const baseX = state.showHeader ? HEADER_WIDTH : 0;
927
+ const rulerW = W - baseX;
928
+ ctx.fillStyle = style.bg;
929
+ ctx.fillRect(baseX, 0, rulerW, RULER_HEIGHT);
930
+ ctx.strokeStyle = style.border;
931
+ ctx.lineWidth = 1;
932
+ ctx.beginPath();
933
+ ctx.moveTo(baseX, RULER_HEIGHT - 0.5);
934
+ ctx.lineTo(W, RULER_HEIGHT - 0.5);
935
+ ctx.stroke();
936
+ const minPx = 80;
937
+ const tickSec = niceTickSeconds(minPx / pxPerSec);
938
+ const subSec = tickSec / 5;
939
+ const firstVisibleSec = Math.max(0, scrollLeft / pxPerSec - subSec);
940
+ const lastVisibleSec = (scrollLeft + rulerW) / pxPerSec + subSec;
941
+ ctx.textBaseline = "bottom";
942
+ ctx.font = "10px system-ui, -apple-system, sans-serif";
943
+ const startStep = Math.floor(firstVisibleSec / subSec);
944
+ const endStep = Math.ceil(lastVisibleSec / subSec);
945
+ for (let i = startStep; i <= endStep; i++) {
946
+ const s = i * subSec;
947
+ if (s < 0) continue;
948
+ const x = baseX + s * pxPerSec - scrollLeft;
949
+ if (x < baseX || x > W) continue;
950
+ const isMajor = Math.abs(s / tickSec % 1) < 1e-3;
951
+ ctx.strokeStyle = isMajor ? withAlpha(style.text, 0.5) : withAlpha(style.text, 0.25);
952
+ ctx.lineWidth = 1;
953
+ const h = isMajor ? 10 : 6;
954
+ ctx.beginPath();
955
+ ctx.moveTo(x + 0.5, RULER_HEIGHT - h);
956
+ ctx.lineTo(x + 0.5, RULER_HEIGHT - 1);
957
+ ctx.stroke();
958
+ if (isMajor) {
959
+ ctx.fillStyle = withAlpha(style.textMuted, 0.85);
960
+ ctx.fillText(formatRulerLabel(s), x + 3, RULER_HEIGHT - 12);
961
+ }
962
+ }
963
+ }
964
+ function drawTracks(ctx, state, style, thumbs) {
965
+ const { project } = state;
966
+ for (let ti = 0; ti < project.tracks.length; ti++) {
967
+ drawTrackRow(ctx, ti, project.tracks[ti], project.sources, state, style, thumbs);
968
+ }
969
+ }
970
+ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
971
+ const { viewportWidth: W } = state;
972
+ const baseX = state.showHeader ? HEADER_WIDTH : 0;
973
+ const y = trackY(trackIndex);
974
+ ctx.fillStyle = style.trackBg;
975
+ ctx.fillRect(baseX, y, W - baseX, TRACK_HEIGHT);
976
+ ctx.strokeStyle = style.border;
977
+ ctx.lineWidth = 1;
978
+ ctx.beginPath();
979
+ ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
980
+ ctx.lineTo(W, y + TRACK_HEIGHT - 0.5);
981
+ ctx.stroke();
982
+ if (state.dropTargetTrackIndex === trackIndex) {
983
+ ctx.strokeStyle = withAlpha(style.info, 0.45);
984
+ ctx.lineWidth = 1;
985
+ ctx.beginPath();
986
+ ctx.moveTo(baseX, y + 0.5);
987
+ ctx.lineTo(W, y + 0.5);
988
+ ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
989
+ ctx.lineTo(W, y + TRACK_HEIGHT - 0.5);
990
+ ctx.stroke();
991
+ }
992
+ for (const clip of track.clips) {
993
+ const dim = state.dragGhost?.clipId === clip.id;
994
+ drawClipAt(
995
+ ctx,
996
+ clip,
997
+ trackIndex,
998
+ clip.start,
999
+ sources,
1000
+ state,
1001
+ style,
1002
+ thumbs,
1003
+ dim,
1004
+ false
1005
+ );
1006
+ }
1007
+ }
1008
+ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumbs, dim, warn) {
1009
+ const { pxPerSec, scrollLeft } = state;
1010
+ const baseX = state.showHeader ? HEADER_WIDTH : 0;
1011
+ const startX = baseX + startMs / 1e3 * pxPerSec - scrollLeft;
1012
+ const widthPx = Math.max(2, (clip.out - clip.in) / 1e3 * pxPerSec);
1013
+ const y = trackY(trackIndex) + CLIP_INSET;
1014
+ const h = TRACK_HEIGHT - CLIP_INSET * 2;
1015
+ if (startX + widthPx < baseX || startX > state.viewportWidth) return;
1016
+ ctx.save();
1017
+ if (dim) ctx.globalAlpha = 0.3;
1018
+ const grad = ctx.createLinearGradient(0, y, 0, y + h);
1019
+ if (warn) {
1020
+ grad.addColorStop(0, "rgba(250, 175, 70, 0.7)");
1021
+ grad.addColorStop(1, "rgba(240, 145, 50, 0.62)");
1022
+ } else {
1023
+ grad.addColorStop(0, withAlpha(style.brand, 0.8));
1024
+ grad.addColorStop(1, withAlpha(style.brandTo, 0.7));
1025
+ }
1026
+ ctx.fillStyle = grad;
1027
+ roundRect(ctx, startX, y, widthPx, h, 6);
1028
+ ctx.fill();
1029
+ ctx.save();
1030
+ roundRect(ctx, startX, y, widthPx, h, 6);
1031
+ ctx.clip();
1032
+ ctx.translate(startX, y);
1033
+ thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx);
1034
+ ctx.restore();
1035
+ ctx.strokeStyle = "rgba(255,255,255,0.2)";
1036
+ ctx.lineWidth = 1;
1037
+ roundRect(ctx, startX + 0.5, y + 0.5, widthPx - 1, h - 1, 6);
1038
+ ctx.stroke();
1039
+ const src = sources.find((s) => s.id === clip.sourceId);
1040
+ const label = src?.name ?? src?.url.split("/").pop() ?? clip.id;
1041
+ ctx.font = "11px system-ui, -apple-system, sans-serif";
1042
+ ctx.textBaseline = "top";
1043
+ ctx.fillStyle = "rgba(0,0,0,0.6)";
1044
+ ctx.fillText(label, startX + 9, y + 5);
1045
+ ctx.fillStyle = "#fff";
1046
+ ctx.fillText(label, startX + 8, y + 4);
1047
+ if (!dim && state.selectedClipId === clip.id) {
1048
+ ctx.strokeStyle = style.selectedRing;
1049
+ ctx.lineWidth = 2;
1050
+ roundRect(ctx, startX - 1, y - 1, widthPx + 2, h + 2, 7);
1051
+ ctx.stroke();
1052
+ }
1053
+ const showHandles = !dim && (state.selectedClipId === clip.id || state.hoveredClipId === clip.id);
1054
+ if (showHandles) {
1055
+ ctx.fillStyle = "rgba(255,255,255,0.75)";
1056
+ ctx.fillRect(startX + 2, y + 12, 2, h - 24);
1057
+ ctx.fillRect(startX + widthPx - 4, y + 12, 2, h - 24);
1058
+ }
1059
+ ctx.restore();
1060
+ }
1061
+ function drawHeaders(ctx, state, style) {
1062
+ ctx.fillStyle = style.bg;
1063
+ ctx.fillRect(0, 0, HEADER_WIDTH, state.viewportHeight);
1064
+ ctx.strokeStyle = style.border;
1065
+ ctx.lineWidth = 1;
1066
+ ctx.beginPath();
1067
+ ctx.moveTo(HEADER_WIDTH - 0.5, 0);
1068
+ ctx.lineTo(HEADER_WIDTH - 0.5, state.viewportHeight);
1069
+ ctx.stroke();
1070
+ ctx.textBaseline = "middle";
1071
+ ctx.font = "11px system-ui, -apple-system, sans-serif";
1072
+ for (let i = 0; i < state.project.tracks.length; i++) {
1073
+ const t = state.project.tracks[i];
1074
+ const y = trackY(i);
1075
+ ctx.strokeStyle = style.border;
1076
+ ctx.beginPath();
1077
+ ctx.moveTo(0, y + TRACK_HEIGHT - 0.5);
1078
+ ctx.lineTo(HEADER_WIDTH, y + TRACK_HEIGHT - 0.5);
1079
+ ctx.stroke();
1080
+ ctx.fillStyle = withAlpha(style.text, 0.7);
1081
+ const template = t.kind === "video" ? state.locale.videoTrackLabel : state.locale.audioTrackLabel;
1082
+ const label = formatLabel(template, { n: i + 1 });
1083
+ ctx.fillText(label, 12, y + TRACK_HEIGHT / 2);
1084
+ if (t.clips.length === 0) {
1085
+ const hovered = state.hoveredTrackIndex === i;
1086
+ const btnSize = 18;
1087
+ const btnLeft = HEADER_WIDTH - btnSize - 6;
1088
+ const btnTop = y + (TRACK_HEIGHT - btnSize) / 2;
1089
+ ctx.save();
1090
+ if (hovered) {
1091
+ ctx.fillStyle = withAlpha(style.text, 0.1);
1092
+ roundRect(ctx, btnLeft, btnTop, btnSize, btnSize, 5);
1093
+ ctx.fill();
1094
+ }
1095
+ ctx.strokeStyle = withAlpha(style.text, hovered ? 0.85 : 0.4);
1096
+ ctx.lineWidth = 1.4;
1097
+ const pad = 5;
1098
+ ctx.beginPath();
1099
+ ctx.moveTo(btnLeft + pad, btnTop + pad);
1100
+ ctx.lineTo(btnLeft + btnSize - pad, btnTop + btnSize - pad);
1101
+ ctx.moveTo(btnLeft + btnSize - pad, btnTop + pad);
1102
+ ctx.lineTo(btnLeft + pad, btnTop + btnSize - pad);
1103
+ ctx.stroke();
1104
+ ctx.restore();
1105
+ }
1106
+ }
1107
+ }
1108
+ function drawPlayhead(ctx, state, style) {
1109
+ const baseX = state.showHeader ? HEADER_WIDTH : 0;
1110
+ const x = baseX + state.timeMs / 1e3 * state.pxPerSec - state.scrollLeft;
1111
+ if (x < baseX - 2 || x > state.viewportWidth + 2) return;
1112
+ ctx.strokeStyle = style.playhead;
1113
+ ctx.lineWidth = 2;
1114
+ ctx.beginPath();
1115
+ ctx.moveTo(x, 0);
1116
+ ctx.lineTo(x, state.viewportHeight);
1117
+ ctx.stroke();
1118
+ const label = fmtClockMs(state.timeMs);
1119
+ ctx.font = "10px system-ui, -apple-system, sans-serif";
1120
+ const padX = 6;
1121
+ const w = ctx.measureText(label).width + padX * 2;
1122
+ const h = 14;
1123
+ const bx = x - w / 2;
1124
+ const by = 2;
1125
+ ctx.fillStyle = style.playhead;
1126
+ roundRect(ctx, bx, by, w, h, 4);
1127
+ ctx.fill();
1128
+ ctx.beginPath();
1129
+ ctx.moveTo(x - 4, by + h);
1130
+ ctx.lineTo(x + 4, by + h);
1131
+ ctx.lineTo(x, by + h + 4);
1132
+ ctx.closePath();
1133
+ ctx.fill();
1134
+ ctx.fillStyle = "#fff";
1135
+ ctx.textBaseline = "middle";
1136
+ ctx.fillText(label, bx + padX, by + h / 2);
1137
+ }
1138
+ function drawSnapGuide(ctx, state, style) {
1139
+ if (state.snapX == null) return;
1140
+ ctx.strokeStyle = style.info;
1141
+ ctx.lineWidth = 1;
1142
+ ctx.setLineDash([3, 3]);
1143
+ ctx.beginPath();
1144
+ ctx.moveTo(state.snapX + 0.5, 0);
1145
+ ctx.lineTo(state.snapX + 0.5, state.viewportHeight);
1146
+ ctx.stroke();
1147
+ ctx.setLineDash([]);
1148
+ }
1149
+ function drawScrollbarV(ctx, state, style) {
1150
+ if (state.scrollbarOpacityY <= 0.01) return;
1151
+ const visibleH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1152
+ const contentH = contentHeight(state.project.tracks, state.isDragging);
1153
+ if (contentH <= visibleH) return;
1154
+ const trackX = state.viewportWidth - SCROLLBAR_THICKNESS + SCROLLBAR_INSET;
1155
+ const trackY0 = RULER_HEIGHT + SCROLLBAR_INSET;
1156
+ const trackLen = visibleH - SCROLLBAR_INSET * 2;
1157
+ const thumbLen = Math.max(
1158
+ SCROLLBAR_MIN_THUMB,
1159
+ trackLen * (visibleH / contentH)
1160
+ );
1161
+ const maxScroll = contentH - visibleH;
1162
+ const thumbY = trackY0 + (maxScroll > 0 ? state.scrollTop / maxScroll * (trackLen - thumbLen) : 0);
1163
+ paintScrollbar(
1164
+ ctx,
1165
+ style,
1166
+ trackX,
1167
+ trackY0,
1168
+ SCROLLBAR_THICKNESS - SCROLLBAR_INSET * 2,
1169
+ trackLen,
1170
+ thumbY - trackY0,
1171
+ thumbLen,
1172
+ state.scrollbarOpacityY,
1173
+ state.scrollbarActiveY,
1174
+ "v"
1175
+ );
1176
+ }
1177
+ function drawScrollbarH(ctx, state, style) {
1178
+ if (state.scrollbarOpacityX <= 0.01) return;
1179
+ const baseX = state.showHeader ? HEADER_WIDTH : 0;
1180
+ const visibleW = state.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1181
+ const contentW = contentWidth(state.project, state.pxPerSec);
1182
+ if (contentW <= visibleW) return;
1183
+ const trackY0 = state.viewportHeight - SCROLLBAR_THICKNESS + SCROLLBAR_INSET;
1184
+ const trackX0 = baseX + SCROLLBAR_INSET;
1185
+ const trackLen = visibleW - SCROLLBAR_INSET * 2;
1186
+ const thumbLen = Math.max(
1187
+ SCROLLBAR_MIN_THUMB,
1188
+ trackLen * (visibleW / contentW)
1189
+ );
1190
+ const maxScroll = contentW - visibleW;
1191
+ const thumbX = trackX0 + (maxScroll > 0 ? state.scrollLeft / maxScroll * (trackLen - thumbLen) : 0);
1192
+ paintScrollbar(
1193
+ ctx,
1194
+ style,
1195
+ trackX0,
1196
+ trackY0,
1197
+ trackLen,
1198
+ SCROLLBAR_THICKNESS - SCROLLBAR_INSET * 2,
1199
+ thumbX - trackX0,
1200
+ thumbLen,
1201
+ state.scrollbarOpacityX,
1202
+ state.scrollbarActiveX,
1203
+ "h"
1204
+ );
1205
+ }
1206
+ function paintScrollbar(ctx, style, trackX, trackY0, trackW, trackH, thumbOffset, thumbLen, opacity, active, axis) {
1207
+ ctx.save();
1208
+ ctx.globalAlpha = opacity;
1209
+ ctx.fillStyle = withAlpha(style.text, 0.06);
1210
+ roundRect(ctx, trackX, trackY0, trackW, trackH, Math.min(trackW, trackH) / 2);
1211
+ ctx.fill();
1212
+ ctx.fillStyle = active ? withAlpha(style.info, 0.85) : withAlpha(style.text, 0.4);
1213
+ if (axis === "v") {
1214
+ roundRect(ctx, trackX, trackY0 + thumbOffset, trackW, thumbLen, trackW / 2);
1215
+ } else {
1216
+ roundRect(ctx, trackX + thumbOffset, trackY0, thumbLen, trackH, trackH / 2);
1217
+ }
1218
+ ctx.fill();
1219
+ ctx.restore();
1220
+ }
1221
+ function roundRect(ctx, x, y, w, h, r) {
1222
+ const rr = Math.min(r, w / 2, h / 2);
1223
+ ctx.beginPath();
1224
+ ctx.moveTo(x + rr, y);
1225
+ ctx.lineTo(x + w - rr, y);
1226
+ ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
1227
+ ctx.lineTo(x + w, y + h - rr);
1228
+ ctx.quadraticCurveTo(x + w, y + h, x + w - rr, y + h);
1229
+ ctx.lineTo(x + rr, y + h);
1230
+ ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
1231
+ ctx.lineTo(x, y + rr);
1232
+ ctx.quadraticCurveTo(x, y, x + rr, y);
1233
+ ctx.closePath();
1234
+ }
1235
+ function withAlpha(color, alpha) {
1236
+ const c = parseColor(color);
1237
+ if (!c) return color;
1238
+ return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha})`;
1239
+ }
1240
+ function parseColor(s) {
1241
+ if (s.startsWith("#")) {
1242
+ const hex = s.slice(1);
1243
+ if (hex.length === 3) {
1244
+ return [
1245
+ parseInt(hex[0] + hex[0], 16),
1246
+ parseInt(hex[1] + hex[1], 16),
1247
+ parseInt(hex[2] + hex[2], 16)
1248
+ ];
1249
+ }
1250
+ if (hex.length === 6) {
1251
+ return [
1252
+ parseInt(hex.slice(0, 2), 16),
1253
+ parseInt(hex.slice(2, 4), 16),
1254
+ parseInt(hex.slice(4, 6), 16)
1255
+ ];
1256
+ }
1257
+ }
1258
+ const m = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
1259
+ if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
1260
+ return null;
1261
+ }
1262
+
1263
+ // src/timeline/hit.ts
1264
+ function hitTest(x, y, ctx) {
1265
+ if (y < 0 || x < 0) return { kind: "outside" };
1266
+ const baseX = ctx.showHeader ? HEADER_WIDTH : 0;
1267
+ const visibleH = ctx.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1268
+ const contentH = contentHeight(ctx.project.tracks, ctx.isDragging);
1269
+ if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
1270
+ const trackLen = visibleH - SCROLLBAR_INSET * 2;
1271
+ const thumbLen = Math.max(
1272
+ SCROLLBAR_MIN_THUMB,
1273
+ trackLen * (visibleH / contentH)
1274
+ );
1275
+ const maxScroll = contentH - visibleH;
1276
+ const thumbY = RULER_HEIGHT + SCROLLBAR_INSET + (maxScroll > 0 ? ctx.scrollTop / maxScroll * (trackLen - thumbLen) : 0);
1277
+ if (y >= thumbY && y <= thumbY + thumbLen) {
1278
+ return { kind: "scrollbar-thumb-v", thumbY, thumbLen };
1279
+ }
1280
+ return { kind: "scrollbar-track-v", before: y < thumbY };
1281
+ }
1282
+ const visibleW = ctx.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1283
+ const contentW = contentWidth(ctx.project, ctx.pxPerSec);
1284
+ if (contentW > visibleW && y >= ctx.viewportHeight - SCROLLBAR_THICKNESS && y < ctx.viewportHeight && x >= baseX && x < ctx.viewportWidth - SCROLLBAR_THICKNESS) {
1285
+ const trackLen = visibleW - SCROLLBAR_INSET * 2;
1286
+ const thumbLen = Math.max(
1287
+ SCROLLBAR_MIN_THUMB,
1288
+ trackLen * (visibleW / contentW)
1289
+ );
1290
+ const maxScroll = contentW - visibleW;
1291
+ const thumbX = baseX + SCROLLBAR_INSET + (maxScroll > 0 ? ctx.scrollLeft / maxScroll * (trackLen - thumbLen) : 0);
1292
+ if (x >= thumbX && x <= thumbX + thumbLen) {
1293
+ return { kind: "scrollbar-thumb-h", thumbX, thumbLen };
1294
+ }
1295
+ return { kind: "scrollbar-track-h", before: x < thumbX };
1296
+ }
1297
+ if (ctx.showHeader && x < HEADER_WIDTH && y >= RULER_HEIGHT) {
1298
+ const ti2 = trackIndexAt(y, ctx.project.tracks.length, ctx.scrollTop);
1299
+ if (ti2 >= 0) {
1300
+ const track2 = ctx.project.tracks[ti2];
1301
+ if (track2.clips.length === 0) {
1302
+ const btnSize = 18;
1303
+ const btnLeft = HEADER_WIDTH - btnSize - 6;
1304
+ const btnTop = RULER_HEIGHT + ti2 * 56 + (56 - btnSize) / 2 - ctx.scrollTop;
1305
+ if (x >= btnLeft && x <= btnLeft + btnSize && y >= btnTop && y <= btnTop + btnSize) {
1306
+ return { kind: "header-delete", trackIndex: ti2 };
1307
+ }
1308
+ }
1309
+ return { kind: "header", trackIndex: ti2 };
1310
+ }
1311
+ return { kind: "outside" };
1312
+ }
1313
+ if (y < RULER_HEIGHT) return { kind: "ruler" };
1314
+ const ti = trackIndexAt(y, ctx.project.tracks.length, ctx.scrollTop);
1315
+ if (ti < 0) return { kind: "outside" };
1316
+ const track = ctx.project.tracks[ti];
1317
+ const ms = xToMs(x, ctx.pxPerSec, ctx.scrollLeft, ctx.showHeader);
1318
+ for (const clip of track.clips) {
1319
+ const start = clip.start;
1320
+ const end = clip.start + (clip.out - clip.in);
1321
+ const startX = msToXLocal(start, ctx);
1322
+ const endX = msToXLocal(end, ctx);
1323
+ if (x >= startX - HANDLE_PX && x <= startX + HANDLE_PX) {
1324
+ return { kind: "clip-handle-left", trackIndex: ti, clipId: clip.id };
1325
+ }
1326
+ if (x >= endX - HANDLE_PX && x <= endX + HANDLE_PX) {
1327
+ return { kind: "clip-handle-right", trackIndex: ti, clipId: clip.id };
1328
+ }
1329
+ if (ms >= start && ms < end) {
1330
+ return { kind: "clip", trackIndex: ti, clipId: clip.id };
1331
+ }
1332
+ }
1333
+ return { kind: "track-empty", trackIndex: ti };
1334
+ }
1335
+ function msToXLocal(ms, ctx) {
1336
+ const base = ctx.showHeader ? HEADER_WIDTH : 0;
1337
+ return base + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
1338
+ }
1339
+
1340
+ // src/timeline/index.ts
1341
+ var SNAP_PX = 8;
1342
+ var DEFAULT_SCALE = 80;
1343
+ var WHEEL_ZOOM_RATE = 0.012;
1344
+ var SCROLLBAR_FADE_HOLD_MS = 800;
1345
+ var SCROLLBAR_FADE_OUT_MS = 400;
1346
+ var Timeline = class _Timeline {
1347
+ root;
1348
+ opts;
1349
+ canvas;
1350
+ ctx;
1351
+ thumbs;
1352
+ hiddenHost;
1353
+ toolbarEl = null;
1354
+ /**
1355
+ * Public flex slot at the left of the top toolbar. `null` when
1356
+ * `toolbar` is disabled. Hosts append their own elements (e.g. a
1357
+ * size/aspect dropdown). React/Vue wrappers portal children here.
1358
+ */
1359
+ toolbarLeft = null;
1360
+ /** Right-side counterpart — conventionally used for export/save. */
1361
+ toolbarRight = null;
1362
+ project;
1363
+ pxPerSec;
1364
+ timeMs;
1365
+ selectedClipId;
1366
+ snapEnabled;
1367
+ showHeader;
1368
+ readOnly;
1369
+ autoFitEnabled;
1370
+ locale;
1371
+ scrollLeft = 0;
1372
+ scrollTop = 0;
1373
+ viewportWidth = 0;
1374
+ viewportHeight = 0;
1375
+ /**
1376
+ * `Date.now()` of the last interaction with each scrollbar (scroll
1377
+ * change OR hover OR drag). Drives the macOS-style fade — bars are
1378
+ * fully opaque for SCROLLBAR_FADE_HOLD_MS after activity, then ease
1379
+ * out over the next SCROLLBAR_FADE_OUT_MS.
1380
+ */
1381
+ lastScrollInteractY = 0;
1382
+ lastScrollInteractX = 0;
1383
+ hoverScrollbarY = false;
1384
+ hoverScrollbarX = false;
1385
+ /** When set, pointer is dragging a scrollbar thumb. */
1386
+ scrollbarDrag = null;
1387
+ hoveredClipId = null;
1388
+ hoveredTrackIndex = null;
1389
+ hoverCursor = "default";
1390
+ dropTargetTrackIndex = null;
1391
+ snapX = null;
1392
+ drag = null;
1393
+ /**
1394
+ * In-flight ghost of the clip being dragged. Decoupled from the
1395
+ * project data so the data stays clean and undo-able only commits
1396
+ * on release. Has both the proposed `start` (X) and `trackIndex`
1397
+ * (Y), so the rendered ghost follows the cursor across tracks.
1398
+ */
1399
+ dragGhost = null;
1400
+ /**
1401
+ * Most recent local pointer coords during a move drag — used by the
1402
+ * edge-autoscroll loop to re-run drop-target resolution between
1403
+ * pointermove events while scrollTop ticks under a stationary cursor.
1404
+ */
1405
+ lastDragPointerX = 0;
1406
+ lastDragPointerY = 0;
1407
+ dragScrollRafPending = false;
1408
+ rafPending = false;
1409
+ hasAutoFitted = false;
1410
+ resizeObs = null;
1411
+ destroyed = false;
1412
+ static create(opts) {
1413
+ return new _Timeline(opts);
1414
+ }
1415
+ constructor(opts) {
1416
+ this.opts = opts;
1417
+ this.root = opts.container;
1418
+ this.project = normalizeProject(opts.project);
1419
+ this.pxPerSec = clampScale(opts.pxPerSec ?? DEFAULT_SCALE);
1420
+ this.timeMs = opts.time ?? 0;
1421
+ this.selectedClipId = opts.selectedClipId ?? null;
1422
+ this.snapEnabled = opts.snap !== false;
1423
+ this.showHeader = opts.showHeader !== false;
1424
+ this.readOnly = opts.readOnly === true;
1425
+ this.autoFitEnabled = opts.autoFit !== false;
1426
+ this.locale = mergeLocale(opts.locale);
1427
+ this.root.classList.add("aicut-timeline-canvas");
1428
+ this.root.innerHTML = "";
1429
+ this.root.style.position = this.root.style.position || "relative";
1430
+ if (opts.toolbar) {
1431
+ this.root.style.display = "flex";
1432
+ this.root.style.flexDirection = "column";
1433
+ const bar = document.createElement("div");
1434
+ bar.className = "aicut-timeline-toolbar";
1435
+ const left = document.createElement("div");
1436
+ left.className = "aicut-timeline-toolbar-left";
1437
+ const right = document.createElement("div");
1438
+ right.className = "aicut-timeline-toolbar-right";
1439
+ bar.appendChild(left);
1440
+ bar.appendChild(right);
1441
+ this.root.appendChild(bar);
1442
+ this.toolbarEl = bar;
1443
+ this.toolbarLeft = left;
1444
+ this.toolbarRight = right;
1445
+ }
1446
+ this.canvas = document.createElement("canvas");
1447
+ this.canvas.style.display = "block";
1448
+ this.canvas.style.width = "100%";
1449
+ if (opts.toolbar) {
1450
+ this.canvas.style.flex = "1 1 0";
1451
+ this.canvas.style.minHeight = "0";
1452
+ } else {
1453
+ this.canvas.style.height = "100%";
1454
+ }
1455
+ this.canvas.style.touchAction = "none";
1456
+ this.root.appendChild(this.canvas);
1457
+ const ctx = this.canvas.getContext("2d");
1458
+ if (!ctx) throw new Error("2d context unavailable");
1459
+ this.ctx = ctx;
1460
+ this.hiddenHost = document.createElement("div");
1461
+ this.hiddenHost.style.position = "absolute";
1462
+ this.hiddenHost.style.overflow = "hidden";
1463
+ this.hiddenHost.style.width = "0";
1464
+ this.hiddenHost.style.height = "0";
1465
+ this.hiddenHost.style.pointerEvents = "none";
1466
+ this.root.appendChild(this.hiddenHost);
1467
+ this.thumbs = new ThumbnailRibbon(
1468
+ this.hiddenHost,
1469
+ () => this.scheduleRender()
1470
+ );
1471
+ this.thumbs.syncSources(this.project.sources);
1472
+ this.attachPointer();
1473
+ this.attachWheel();
1474
+ this.attachResize();
1475
+ this.resizeCanvas();
1476
+ this.scheduleRender();
1477
+ }
1478
+ // ---- public API -----------------------------------------------------
1479
+ /**
1480
+ * Sync the project data. Does NOT reset the auto-fit latch — that's
1481
+ * what caused the editor-side zoom feedback loop: every Editor
1482
+ * mutation called `ui.render() → timeline.setProject()` which used
1483
+ * to reset auto-fit, refit on the next frame, emit a new scale,
1484
+ * which re-rendered… and round we went. Callers that genuinely
1485
+ * want a re-fit (e.g. when the host swaps to a brand-new project)
1486
+ * should call `refit()` explicitly.
1487
+ */
1488
+ setProject(p) {
1489
+ this.project = normalizeProject(p);
1490
+ this.thumbs.syncSources(this.project.sources);
1491
+ this.scheduleRender();
1492
+ }
1493
+ /** Force a re-fit on the next render. */
1494
+ refit() {
1495
+ if (!this.autoFitEnabled) return;
1496
+ this.hasAutoFitted = false;
1497
+ this.scheduleRender();
1498
+ }
1499
+ getProject() {
1500
+ return JSON.parse(JSON.stringify(this.project));
1501
+ }
1502
+ setTime(timeMs) {
1503
+ this.timeMs = Math.max(0, timeMs);
1504
+ this.scheduleRender();
1505
+ }
1506
+ getTime() {
1507
+ return this.timeMs;
1508
+ }
1509
+ setScale(pxPerSec) {
1510
+ const next = clampScale(pxPerSec);
1511
+ if (next === this.pxPerSec) return;
1512
+ this.pxPerSec = next;
1513
+ this.hasAutoFitted = true;
1514
+ this.scheduleRender();
1515
+ }
1516
+ getScale() {
1517
+ return this.pxPerSec;
1518
+ }
1519
+ setSelection(id) {
1520
+ if (id === this.selectedClipId) return;
1521
+ this.selectedClipId = id;
1522
+ this.scheduleRender();
1523
+ }
1524
+ getSelection() {
1525
+ return this.selectedClipId;
1526
+ }
1527
+ setSnap(snap) {
1528
+ this.snapEnabled = snap;
1529
+ }
1530
+ getSnap() {
1531
+ return this.snapEnabled;
1532
+ }
1533
+ setLocale(locale) {
1534
+ this.locale = mergeLocale(locale);
1535
+ this.scheduleRender();
1536
+ }
1537
+ /** Fit the project's full duration into the current viewport width. */
1538
+ fitToWindow() {
1539
+ const fit = this.computeFitScale();
1540
+ if (fit == null) return;
1541
+ this.pxPerSec = fit;
1542
+ this.hasAutoFitted = true;
1543
+ this.scrollLeft = 0;
1544
+ this.opts.onScaleChange?.(this.pxPerSec);
1545
+ this.scheduleRender();
1546
+ }
1547
+ /**
1548
+ * Test/debug introspection — pixel coordinates of every visible clip,
1549
+ * the playhead, and the headers. Because clips are canvas-painted
1550
+ * there are no DOM nodes to query in e2e; tests use this instead.
1551
+ * Exposed publicly so React/Vue wrappers can forward it to a ref.
1552
+ */
1553
+ getDebugInfo() {
1554
+ const baseX = this.showHeader ? HEADER_WIDTH : 0;
1555
+ const clips = [];
1556
+ for (let ti = 0; ti < this.project.tracks.length; ti++) {
1557
+ const t = this.project.tracks[ti];
1558
+ for (const c of t.clips) {
1559
+ const x = baseX + c.start / 1e3 * this.pxPerSec - this.scrollLeft;
1560
+ const width = (c.out - c.in) / 1e3 * this.pxPerSec;
1561
+ const y = RULER_HEIGHT + ti * TRACK_HEIGHT + 6;
1562
+ clips.push({
1563
+ id: c.id,
1564
+ trackIndex: ti,
1565
+ x,
1566
+ width,
1567
+ y,
1568
+ height: TRACK_HEIGHT - 12
1569
+ });
1570
+ }
1571
+ }
1572
+ return {
1573
+ pxPerSec: this.pxPerSec,
1574
+ scrollLeft: this.scrollLeft,
1575
+ viewportWidth: this.viewportWidth,
1576
+ viewportHeight: this.viewportHeight,
1577
+ playheadX: baseX + this.timeMs / 1e3 * this.pxPerSec - this.scrollLeft,
1578
+ clips
1579
+ };
1580
+ }
1581
+ destroy() {
1582
+ if (this.destroyed) return;
1583
+ this.destroyed = true;
1584
+ this.resizeObs?.disconnect();
1585
+ this.thumbs.destroy();
1586
+ this.root.innerHTML = "";
1587
+ this.root.classList.remove("aicut-timeline-canvas");
1588
+ if (this.opts.toolbar) {
1589
+ this.root.style.display = "";
1590
+ this.root.style.flexDirection = "";
1591
+ }
1592
+ }
1593
+ // ---- size / layout --------------------------------------------------
1594
+ resizeCanvas() {
1595
+ const rect = this.canvas.getBoundingClientRect();
1596
+ this.viewportWidth = Math.max(1, Math.floor(rect.width));
1597
+ this.viewportHeight = Math.max(
1598
+ Math.floor(rect.height) || RULER_HEIGHT + TRACK_HEIGHT + SCROLLBAR_THICKNESS,
1599
+ RULER_HEIGHT + TRACK_HEIGHT + SCROLLBAR_THICKNESS
1600
+ );
1601
+ const dpr = window.devicePixelRatio || 1;
1602
+ this.canvas.width = Math.floor(this.viewportWidth * dpr);
1603
+ this.canvas.height = Math.floor(this.viewportHeight * dpr);
1604
+ this.canvas.style.height = `${this.viewportHeight}px`;
1605
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1606
+ }
1607
+ computeFitScale() {
1608
+ const baseX = this.showHeader ? HEADER_WIDTH : 0;
1609
+ const w = this.viewportWidth - baseX - 24;
1610
+ const dur = projectDuration(this.project);
1611
+ if (w <= 0 || dur <= 0) return null;
1612
+ return clampScale(w / (dur / 1e3));
1613
+ }
1614
+ maxScrollLeft() {
1615
+ const baseX = this.showHeader ? HEADER_WIDTH : 0;
1616
+ const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1617
+ const cw = contentWidth(this.project, this.pxPerSec);
1618
+ return Math.max(0, cw - visibleW + 24);
1619
+ }
1620
+ maxScrollTop() {
1621
+ const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1622
+ const ch = contentHeight(this.project.tracks, this.drag?.kind === "move");
1623
+ return Math.max(0, ch - visibleH);
1624
+ }
1625
+ clampScroll() {
1626
+ this.scrollLeft = Math.max(0, Math.min(this.scrollLeft, this.maxScrollLeft()));
1627
+ this.scrollTop = Math.max(0, Math.min(this.scrollTop, this.maxScrollTop()));
1628
+ }
1629
+ /**
1630
+ * Scrollbar opacity = full for SCROLLBAR_FADE_HOLD_MS after last
1631
+ * interaction, then linearly fades to 0 over SCROLLBAR_FADE_OUT_MS.
1632
+ * Hovering or actively dragging the bar pins opacity at 1. Returns
1633
+ * 0 if the bar isn't needed (content fits).
1634
+ */
1635
+ scrollbarOpacity(axis) {
1636
+ if (axis === "v" && this.maxScrollTop() <= 0) return 0;
1637
+ if (axis === "h" && this.maxScrollLeft() <= 0) return 0;
1638
+ if (axis === "v" && (this.hoverScrollbarY || this.scrollbarDrag?.axis === "v") || axis === "h" && (this.hoverScrollbarX || this.scrollbarDrag?.axis === "h")) {
1639
+ return 1;
1640
+ }
1641
+ const last = axis === "v" ? this.lastScrollInteractY : this.lastScrollInteractX;
1642
+ const elapsed = Date.now() - last;
1643
+ if (elapsed < SCROLLBAR_FADE_HOLD_MS) return 1;
1644
+ const fade = elapsed - SCROLLBAR_FADE_HOLD_MS;
1645
+ if (fade >= SCROLLBAR_FADE_OUT_MS) return 0;
1646
+ return 1 - fade / SCROLLBAR_FADE_OUT_MS;
1647
+ }
1648
+ /** Mark a scrollbar axis as just-touched so its fade timer restarts. */
1649
+ touchScrollbar(axis) {
1650
+ if (axis === "v") this.lastScrollInteractY = Date.now();
1651
+ else this.lastScrollInteractX = Date.now();
1652
+ this.scheduleRender();
1653
+ }
1654
+ // ---- rendering ------------------------------------------------------
1655
+ scheduleRender() {
1656
+ if (this.rafPending) return;
1657
+ this.rafPending = true;
1658
+ requestAnimationFrame(() => {
1659
+ this.rafPending = false;
1660
+ if (this.destroyed) return;
1661
+ this.maybeAutoFit();
1662
+ this.resizeCanvas();
1663
+ this.clampScroll();
1664
+ drawAll(
1665
+ this.ctx,
1666
+ this.buildDrawState(),
1667
+ this.readStyle(),
1668
+ this.thumbs
1669
+ );
1670
+ this.canvas.style.cursor = this.hoverCursor;
1671
+ this.maybeContinueFade();
1672
+ });
1673
+ }
1674
+ /**
1675
+ * Keep the raf loop alive while a scrollbar is still in its HOLD or
1676
+ * fade-out window. Without this, opacity is sampled once and the
1677
+ * bar would freeze at whatever value it had at the last input event
1678
+ * instead of smoothly fading out. Skipped when a bar is pinned
1679
+ * (hover or active drag) since opacity is constant there.
1680
+ */
1681
+ maybeContinueFade() {
1682
+ const total = SCROLLBAR_FADE_HOLD_MS + SCROLLBAR_FADE_OUT_MS;
1683
+ const now = Date.now();
1684
+ const needV = this.maxScrollTop() > 0 && !this.hoverScrollbarY && this.scrollbarDrag?.axis !== "v" && now - this.lastScrollInteractY < total;
1685
+ const needH = this.maxScrollLeft() > 0 && !this.hoverScrollbarX && this.scrollbarDrag?.axis !== "h" && now - this.lastScrollInteractX < total;
1686
+ if (needV || needH) this.scheduleRender();
1687
+ }
1688
+ maybeAutoFit() {
1689
+ if (!this.autoFitEnabled || this.hasAutoFitted) return;
1690
+ if (projectDuration(this.project) <= 0) return;
1691
+ const fit = this.computeFitScale();
1692
+ if (fit == null) return;
1693
+ this.hasAutoFitted = true;
1694
+ if (Math.abs(fit - this.pxPerSec) > 0.5) {
1695
+ this.pxPerSec = fit;
1696
+ this.opts.onScaleChange?.(fit);
1697
+ }
1698
+ }
1699
+ buildDrawState() {
1700
+ return {
1701
+ project: this.project,
1702
+ pxPerSec: this.pxPerSec,
1703
+ scrollLeft: this.scrollLeft,
1704
+ scrollTop: this.scrollTop,
1705
+ timeMs: this.timeMs,
1706
+ selectedClipId: this.selectedClipId,
1707
+ hoveredClipId: this.hoveredClipId,
1708
+ hoveredTrackIndex: this.hoveredTrackIndex,
1709
+ dropTargetTrackIndex: this.dropTargetTrackIndex,
1710
+ isDragging: this.drag?.kind === "move",
1711
+ snapX: this.snapX,
1712
+ showHeader: this.showHeader,
1713
+ viewportWidth: this.viewportWidth,
1714
+ viewportHeight: this.viewportHeight,
1715
+ dragGhost: this.dragGhost,
1716
+ scrollbarOpacityY: this.scrollbarOpacity("v"),
1717
+ scrollbarOpacityX: this.scrollbarOpacity("h"),
1718
+ scrollbarActiveY: this.scrollbarDrag?.axis === "v",
1719
+ scrollbarActiveX: this.scrollbarDrag?.axis === "h",
1720
+ locale: this.locale
1721
+ };
1722
+ }
1723
+ readStyle() {
1724
+ const cs = getComputedStyle(this.root);
1725
+ const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback;
1726
+ return {
1727
+ bg: v("--aicut-controls-bg", "#1f1f22"),
1728
+ border: v("--aicut-controls-border", "rgba(255,255,255,0.08)"),
1729
+ // Pass the resolved text color straight through — draw.ts'
1730
+ // withAlpha can adjust it for tick / muted variants.
1731
+ text: v("--aicut-controls-text", "rgba(255,255,255,0.85)"),
1732
+ textMuted: v("--color-muted", "#999999"),
1733
+ trackBg: "rgba(255,255,255,0.06)",
1734
+ brand: v("--color-brand", "#ff3386"),
1735
+ brandTo: v("--color-secondary", "#9a31f4"),
1736
+ info: v("--color-info", "#1077ff"),
1737
+ clipText: "#fff",
1738
+ selectedRing: v("--color-info", "#1077ff"),
1739
+ playhead: v("--color-brand", "#ff3386")
1740
+ };
1741
+ }
1742
+ // ---- pointer / wheel ------------------------------------------------
1743
+ attachPointer() {
1744
+ this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
1745
+ this.canvas.addEventListener("pointermove", (e) => this.onPointerMove(e));
1746
+ this.canvas.addEventListener("pointerup", (e) => this.onPointerUp(e));
1747
+ this.canvas.addEventListener("pointercancel", (e) => this.onPointerUp(e));
1748
+ this.canvas.addEventListener("pointerleave", () => {
1749
+ if (!this.drag && !this.scrollbarDrag) {
1750
+ this.hoveredClipId = null;
1751
+ this.hoverCursor = "default";
1752
+ this.hoverScrollbarY = false;
1753
+ this.hoverScrollbarX = false;
1754
+ this.scheduleRender();
1755
+ }
1756
+ });
1757
+ }
1758
+ onPointerDown(e) {
1759
+ const { x, y } = this.localCoords(e);
1760
+ const target = this.hitTarget(x, y);
1761
+ this.canvas.setPointerCapture(e.pointerId);
1762
+ if (target.kind === "scrollbar-thumb-v") {
1763
+ this.scrollbarDrag = {
1764
+ axis: "v",
1765
+ pointerStart: y,
1766
+ scrollStart: this.scrollTop
1767
+ };
1768
+ this.touchScrollbar("v");
1769
+ return;
1770
+ }
1771
+ if (target.kind === "scrollbar-thumb-h") {
1772
+ this.scrollbarDrag = {
1773
+ axis: "h",
1774
+ pointerStart: x,
1775
+ scrollStart: this.scrollLeft
1776
+ };
1777
+ this.touchScrollbar("h");
1778
+ return;
1779
+ }
1780
+ if (target.kind === "scrollbar-track-v") {
1781
+ const page = Math.max(
1782
+ TRACK_HEIGHT,
1783
+ this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS
1784
+ );
1785
+ this.scrollTop += target.before ? -page : page;
1786
+ this.clampScroll();
1787
+ this.touchScrollbar("v");
1788
+ return;
1789
+ }
1790
+ if (target.kind === "scrollbar-track-h") {
1791
+ const baseX = this.showHeader ? HEADER_WIDTH : 0;
1792
+ const page = Math.max(
1793
+ 80,
1794
+ this.viewportWidth - baseX - SCROLLBAR_THICKNESS
1795
+ );
1796
+ this.scrollLeft += target.before ? -page : page;
1797
+ this.clampScroll();
1798
+ this.touchScrollbar("h");
1799
+ return;
1800
+ }
1801
+ if (this.readOnly) {
1802
+ if (target.kind === "ruler" || target.kind === "clip" || target.kind === "clip-handle-left" || target.kind === "clip-handle-right" || target.kind === "track-empty") {
1803
+ this.drag = { kind: "scrub" };
1804
+ const ms = this.applySnap(
1805
+ xToMs(x, this.pxPerSec, this.scrollLeft, this.showHeader),
1806
+ null
1807
+ );
1808
+ this.timeMs = ms;
1809
+ this.opts.onSeek?.(ms);
1810
+ this.scheduleRender();
1811
+ }
1812
+ return;
1813
+ }
1814
+ if (target.kind === "header-delete") {
1815
+ const t = this.project.tracks[target.trackIndex];
1816
+ if (t) this.opts.onDeleteTrack?.(t.id);
1817
+ return;
1818
+ }
1819
+ if (target.kind === "header") return;
1820
+ if (target.kind === "ruler") {
1821
+ this.drag = { kind: "scrub" };
1822
+ const ms = this.applySnap(
1823
+ xToMs(x, this.pxPerSec, this.scrollLeft, this.showHeader),
1824
+ null
1825
+ );
1826
+ this.timeMs = ms;
1827
+ this.opts.onSeek?.(ms);
1828
+ this.scheduleRender();
1829
+ return;
1830
+ }
1831
+ if (target.kind === "clip") {
1832
+ const found = findClip(this.project, target.clipId);
1833
+ if (!found) return;
1834
+ this.selectedClipId = target.clipId;
1835
+ this.opts.onSelectClip?.(target.clipId);
1836
+ this.drag = {
1837
+ kind: "move",
1838
+ clipId: target.clipId,
1839
+ trackIndex: target.trackIndex,
1840
+ pointerStartX: x,
1841
+ pointerStartY: y,
1842
+ originalStart: found.clip.start
1843
+ };
1844
+ this.scheduleRender();
1845
+ return;
1846
+ }
1847
+ if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
1848
+ const found = findClip(this.project, target.clipId);
1849
+ if (!found) return;
1850
+ this.selectedClipId = target.clipId;
1851
+ this.opts.onSelectClip?.(target.clipId);
1852
+ this.drag = {
1853
+ kind: target.kind === "clip-handle-left" ? "trim-left" : "trim-right",
1854
+ clipId: target.clipId,
1855
+ trackIndex: target.trackIndex,
1856
+ pointerStartX: x,
1857
+ originalStart: found.clip.start,
1858
+ originalIn: found.clip.in,
1859
+ originalOut: found.clip.out
1860
+ };
1861
+ this.scheduleRender();
1862
+ return;
1863
+ }
1864
+ if (target.kind === "track-empty") {
1865
+ this.selectedClipId = null;
1866
+ this.opts.onSelectClip?.(null);
1867
+ const ms = this.applySnap(
1868
+ xToMs(x, this.pxPerSec, this.scrollLeft, this.showHeader),
1869
+ null
1870
+ );
1871
+ this.timeMs = ms;
1872
+ this.opts.onSeek?.(ms);
1873
+ this.drag = { kind: "scrub" };
1874
+ this.scheduleRender();
1875
+ return;
1876
+ }
1877
+ }
1878
+ onPointerMove(e) {
1879
+ const { x, y } = this.localCoords(e);
1880
+ if (this.scrollbarDrag) {
1881
+ if (this.scrollbarDrag.axis === "v") {
1882
+ const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1883
+ const contentH = contentHeight(
1884
+ this.project.tracks,
1885
+ this.drag?.kind === "move"
1886
+ );
1887
+ const trackLen = visibleH - SCROLLBAR_INSET * 2;
1888
+ const thumbLen = Math.max(
1889
+ SCROLLBAR_MIN_THUMB,
1890
+ trackLen * (visibleH / contentH)
1891
+ );
1892
+ const maxScroll = Math.max(0, contentH - visibleH);
1893
+ const free = Math.max(1, trackLen - thumbLen);
1894
+ const ratio = maxScroll / free;
1895
+ this.scrollTop = this.scrollbarDrag.scrollStart + (y - this.scrollbarDrag.pointerStart) * ratio;
1896
+ } else {
1897
+ const baseX = this.showHeader ? HEADER_WIDTH : 0;
1898
+ const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1899
+ const contentW = contentWidth(this.project, this.pxPerSec);
1900
+ const trackLen = visibleW - SCROLLBAR_INSET * 2;
1901
+ const thumbLen = Math.max(
1902
+ SCROLLBAR_MIN_THUMB,
1903
+ trackLen * (visibleW / contentW)
1904
+ );
1905
+ const maxScroll = Math.max(0, contentW - visibleW);
1906
+ const free = Math.max(1, trackLen - thumbLen);
1907
+ const ratio = maxScroll / free;
1908
+ this.scrollLeft = this.scrollbarDrag.scrollStart + (x - this.scrollbarDrag.pointerStart) * ratio;
1909
+ }
1910
+ this.clampScroll();
1911
+ this.touchScrollbar(this.scrollbarDrag.axis);
1912
+ return;
1913
+ }
1914
+ if (!this.drag) {
1915
+ const target = this.hitTarget(x, y);
1916
+ let nextHover = null;
1917
+ let nextHoverTrack = null;
1918
+ let cursor = "default";
1919
+ let onScrollbarV = false;
1920
+ let onScrollbarH = false;
1921
+ if (target.kind === "clip") {
1922
+ nextHover = target.clipId;
1923
+ nextHoverTrack = target.trackIndex;
1924
+ cursor = this.readOnly ? "pointer" : "grab";
1925
+ } else if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
1926
+ nextHover = target.clipId;
1927
+ nextHoverTrack = target.trackIndex;
1928
+ cursor = "ew-resize";
1929
+ } else if (target.kind === "ruler") {
1930
+ cursor = "ew-resize";
1931
+ } else if (target.kind === "track-empty") {
1932
+ nextHoverTrack = target.trackIndex;
1933
+ cursor = "crosshair";
1934
+ } else if (target.kind === "header") {
1935
+ nextHoverTrack = target.trackIndex;
1936
+ cursor = "default";
1937
+ } else if (target.kind === "header-delete") {
1938
+ nextHoverTrack = target.trackIndex;
1939
+ cursor = "pointer";
1940
+ } else if (target.kind === "scrollbar-thumb-v" || target.kind === "scrollbar-track-v") {
1941
+ onScrollbarV = true;
1942
+ cursor = "default";
1943
+ } else if (target.kind === "scrollbar-thumb-h" || target.kind === "scrollbar-track-h") {
1944
+ onScrollbarH = true;
1945
+ cursor = "default";
1946
+ }
1947
+ const hoverChanged = onScrollbarV !== this.hoverScrollbarY || onScrollbarH !== this.hoverScrollbarX;
1948
+ if (nextHover !== this.hoveredClipId || nextHoverTrack !== this.hoveredTrackIndex || cursor !== this.hoverCursor || hoverChanged) {
1949
+ this.hoveredClipId = nextHover;
1950
+ this.hoveredTrackIndex = nextHoverTrack;
1951
+ this.hoverCursor = cursor;
1952
+ this.hoverScrollbarY = onScrollbarV;
1953
+ this.hoverScrollbarX = onScrollbarH;
1954
+ this.scheduleRender();
1955
+ }
1956
+ return;
1957
+ }
1958
+ if (this.drag.kind === "scrub") {
1959
+ const ms = this.applySnap(
1960
+ xToMs(x, this.pxPerSec, this.scrollLeft, this.showHeader),
1961
+ null
1962
+ );
1963
+ this.timeMs = ms;
1964
+ this.opts.onSeek?.(ms);
1965
+ this.scheduleRender();
1966
+ return;
1967
+ }
1968
+ if (this.drag.kind === "move") {
1969
+ this.lastDragPointerX = x;
1970
+ this.lastDragPointerY = y;
1971
+ this.processMoveDrag(x, y);
1972
+ this.scheduleRender();
1973
+ this.maybeStartDragAutoScroll();
1974
+ return;
1975
+ }
1976
+ if (this.drag.kind === "trim-left" || this.drag.kind === "trim-right") {
1977
+ const dxPx = x - this.drag.pointerStartX;
1978
+ const dxMs = dxPx / this.pxPerSec * 1e3;
1979
+ const found = findClip(this.project, this.drag.clipId);
1980
+ if (!found) return;
1981
+ const c = found.clip;
1982
+ if (this.drag.kind === "trim-left") {
1983
+ let nextStart = Math.max(0, this.drag.originalStart + dxMs);
1984
+ nextStart = this.applySnap(nextStart, this.drag.clipId);
1985
+ const delta = nextStart - this.drag.originalStart;
1986
+ const nextIn = Math.max(
1987
+ 0,
1988
+ Math.min(this.drag.originalIn + delta, this.drag.originalOut - 50)
1989
+ );
1990
+ const adjStart = this.drag.originalStart + (nextIn - this.drag.originalIn);
1991
+ c.in = nextIn;
1992
+ c.start = adjStart;
1993
+ } else {
1994
+ const nextOut = Math.max(
1995
+ this.drag.originalIn + 50,
1996
+ this.drag.originalOut + dxMs
1997
+ );
1998
+ c.out = nextOut;
1999
+ }
2000
+ this.scheduleRender();
2001
+ return;
2002
+ }
2003
+ }
2004
+ /**
2005
+ * Update dragGhost + dropTargetTrackIndex for the in-flight move
2006
+ * drag, given the current viewport pointer position. Pulled out of
2007
+ * onPointerMove so the edge-autoscroll loop can re-run it on each
2008
+ * tick — autoscroll moves scrollTop under a stationary cursor, and
2009
+ * the ghost must follow the new row under that cursor.
2010
+ */
2011
+ processMoveDrag(x, y) {
2012
+ if (!this.drag || this.drag.kind !== "move") return;
2013
+ const drag = this.drag;
2014
+ const dxPx = x - drag.pointerStartX;
2015
+ const dxMs = dxPx / this.pxPerSec * 1e3;
2016
+ let nextStart = Math.max(0, drag.originalStart + dxMs);
2017
+ nextStart = this.applySnap(nextStart, drag.clipId);
2018
+ const tiRaw = this.trackIndexAtY(y);
2019
+ const phantomIdx = this.project.tracks.length;
2020
+ const phantomScreenY = RULER_HEIGHT + phantomIdx * TRACK_HEIGHT - this.scrollTop;
2021
+ const onPhantom = y >= phantomScreenY && y < phantomScreenY + TRACK_HEIGHT;
2022
+ const intendedTrackIndex = onPhantom ? phantomIdx : tiRaw >= 0 ? tiRaw : drag.trackIndex;
2023
+ let ghostTrackIndex = intendedTrackIndex;
2024
+ let overlap = false;
2025
+ if (onPhantom) {
2026
+ ghostTrackIndex = phantomIdx;
2027
+ overlap = true;
2028
+ } else if (this.opts.resolveDrop) {
2029
+ const r = this.opts.resolveDrop(drag.clipId, {
2030
+ start: nextStart,
2031
+ intendedTrackIndex
2032
+ });
2033
+ ghostTrackIndex = r.trackIndex;
2034
+ overlap = r.wouldCreateNew;
2035
+ } else {
2036
+ const found = findClip(this.project, drag.clipId);
2037
+ const dur = found ? found.clip.out - found.clip.in : 0;
2038
+ const targetTrack = this.project.tracks[intendedTrackIndex];
2039
+ overlap = targetTrack ? wouldOverlap(targetTrack, drag.clipId, nextStart, nextStart + dur) : false;
2040
+ }
2041
+ this.dragGhost = {
2042
+ clipId: drag.clipId,
2043
+ ghostStart: nextStart,
2044
+ ghostTrackIndex,
2045
+ wouldOverlap: overlap
2046
+ };
2047
+ this.dropTargetTrackIndex = ghostTrackIndex !== drag.trackIndex ? ghostTrackIndex : null;
2048
+ }
2049
+ /**
2050
+ * Px-per-frame scroll speed when the pointer is in a vertical edge
2051
+ * zone of the track region. Returns 0 outside the zone. Speed ramps
2052
+ * linearly from 0 at the zone's inner edge to ~16 px/frame at the
2053
+ * outer edge, so brushing the edge gives a gentle nudge and parking
2054
+ * deep at it gives a brisk auto-scroll.
2055
+ */
2056
+ dragScrollSpeedY() {
2057
+ if (!this.drag || this.drag.kind !== "move") return 0;
2058
+ const y = this.lastDragPointerY;
2059
+ const top = RULER_HEIGHT;
2060
+ const bottom = this.viewportHeight - SCROLLBAR_THICKNESS;
2061
+ const zone = 36;
2062
+ const maxSpeed = 16;
2063
+ if (y >= top && y < top + zone) {
2064
+ if (this.scrollTop <= 0) return 0;
2065
+ const depth = (top + zone - y) / zone;
2066
+ return -Math.max(2, maxSpeed * depth);
2067
+ }
2068
+ if (y <= bottom && y > bottom - zone) {
2069
+ if (this.scrollTop >= this.maxScrollTop()) return 0;
2070
+ const depth = (y - (bottom - zone)) / zone;
2071
+ return Math.max(2, maxSpeed * depth);
2072
+ }
2073
+ return 0;
2074
+ }
2075
+ /**
2076
+ * Drive vertical auto-scroll while the user holds a clip near the
2077
+ * top/bottom edge of the track area. Self-stopping — exits the loop
2078
+ * once the pointer leaves the zone, the drag ends, or scroll bottoms
2079
+ * out at the clamp.
2080
+ */
2081
+ maybeStartDragAutoScroll() {
2082
+ if (this.dragScrollRafPending) return;
2083
+ if (this.dragScrollSpeedY() === 0) return;
2084
+ this.dragScrollRafPending = true;
2085
+ requestAnimationFrame(() => {
2086
+ this.dragScrollRafPending = false;
2087
+ if (this.destroyed) return;
2088
+ const speed = this.dragScrollSpeedY();
2089
+ if (speed === 0 || !this.drag || this.drag.kind !== "move") return;
2090
+ this.scrollTop += speed;
2091
+ this.clampScroll();
2092
+ this.touchScrollbar("v");
2093
+ this.processMoveDrag(this.lastDragPointerX, this.lastDragPointerY);
2094
+ this.scheduleRender();
2095
+ this.maybeStartDragAutoScroll();
2096
+ });
2097
+ }
2098
+ onPointerUp(_e) {
2099
+ if (this.scrollbarDrag) {
2100
+ const axis = this.scrollbarDrag.axis;
2101
+ this.scrollbarDrag = null;
2102
+ this.touchScrollbar(axis);
2103
+ return;
2104
+ }
2105
+ if (!this.drag) return;
2106
+ const drag = this.drag;
2107
+ const ghost = this.dragGhost;
2108
+ this.drag = null;
2109
+ this.dragGhost = null;
2110
+ this.dropTargetTrackIndex = null;
2111
+ this.snapX = null;
2112
+ if (drag.kind === "move") {
2113
+ if (ghost) {
2114
+ const isPhantom = ghost.ghostTrackIndex >= this.project.tracks.length;
2115
+ const finalTrackId = isPhantom ? void 0 : ghost.ghostTrackIndex !== drag.trackIndex ? this.project.tracks[ghost.ghostTrackIndex]?.id : void 0;
2116
+ this.opts.onMoveClip?.(drag.clipId, {
2117
+ start: ghost.ghostStart,
2118
+ trackId: finalTrackId,
2119
+ // Phantom row drop is the user explicitly asking for a new
2120
+ // track — bypass the editor's smart routing in that case
2121
+ // (which would otherwise route back to the source track and
2122
+ // make the gesture a no-op).
2123
+ newTrack: isPhantom
2124
+ });
2125
+ this.opts.onChange?.(this.getProject());
2126
+ }
2127
+ } else if (drag.kind === "trim-left" || drag.kind === "trim-right") {
2128
+ const found = findClip(this.project, drag.clipId);
2129
+ if (found) {
2130
+ this.opts.onResizeClip?.(drag.clipId, {
2131
+ in: found.clip.in,
2132
+ out: found.clip.out,
2133
+ start: found.clip.start
2134
+ });
2135
+ this.opts.onChange?.(this.getProject());
2136
+ }
2137
+ }
2138
+ this.scheduleRender();
2139
+ }
2140
+ attachWheel() {
2141
+ this.canvas.addEventListener(
2142
+ "wheel",
2143
+ (e) => {
2144
+ if (e.ctrlKey || e.metaKey) {
2145
+ e.preventDefault();
2146
+ const rect = this.canvas.getBoundingClientRect();
2147
+ const cursorX = e.clientX - rect.left;
2148
+ const anchorMs = xToMs(
2149
+ cursorX,
2150
+ this.pxPerSec,
2151
+ this.scrollLeft,
2152
+ this.showHeader
2153
+ );
2154
+ const dy = Math.max(-50, Math.min(50, e.deltaY));
2155
+ const factor = Math.exp(-dy * WHEEL_ZOOM_RATE);
2156
+ const next = clampScale(this.pxPerSec * factor);
2157
+ if (Math.abs(next - this.pxPerSec) < 0.01) return;
2158
+ this.pxPerSec = next;
2159
+ this.hasAutoFitted = true;
2160
+ const baseX = this.showHeader ? HEADER_WIDTH : 0;
2161
+ this.scrollLeft = anchorMs / 1e3 * this.pxPerSec - (cursorX - baseX);
2162
+ this.clampScroll();
2163
+ this.touchScrollbar("h");
2164
+ this.opts.onScaleChange?.(this.pxPerSec);
2165
+ this.scheduleRender();
2166
+ return;
2167
+ }
2168
+ const horizDominant = Math.abs(e.deltaX) > Math.abs(e.deltaY);
2169
+ if (horizDominant) {
2170
+ if (e.deltaX === 0) return;
2171
+ e.preventDefault();
2172
+ this.scrollLeft += e.deltaX;
2173
+ this.clampScroll();
2174
+ this.touchScrollbar("h");
2175
+ this.scheduleRender();
2176
+ return;
2177
+ }
2178
+ if (e.deltaY === 0) return;
2179
+ e.preventDefault();
2180
+ if (this.maxScrollTop() > 0) {
2181
+ this.scrollTop += e.deltaY;
2182
+ this.clampScroll();
2183
+ this.touchScrollbar("v");
2184
+ } else {
2185
+ this.scrollLeft += e.deltaY;
2186
+ this.clampScroll();
2187
+ this.touchScrollbar("h");
2188
+ }
2189
+ this.scheduleRender();
2190
+ },
2191
+ { passive: false }
2192
+ );
2193
+ }
2194
+ attachResize() {
2195
+ if (typeof ResizeObserver === "undefined") return;
2196
+ this.resizeObs = new ResizeObserver(() => {
2197
+ this.resizeCanvas();
2198
+ if (!this.hasAutoFitted && this.autoFitEnabled) {
2199
+ const fit = this.computeFitScale();
2200
+ if (fit != null) {
2201
+ this.pxPerSec = fit;
2202
+ this.opts.onScaleChange?.(fit);
2203
+ }
2204
+ }
2205
+ this.scheduleRender();
2206
+ });
2207
+ this.resizeObs.observe(this.root);
2208
+ }
2209
+ // ---- helpers --------------------------------------------------------
2210
+ localCoords(e) {
2211
+ const rect = this.canvas.getBoundingClientRect();
2212
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
2213
+ }
2214
+ hitTarget(x, y) {
2215
+ return hitTest(x, y, {
2216
+ project: this.project,
2217
+ pxPerSec: this.pxPerSec,
2218
+ scrollLeft: this.scrollLeft,
2219
+ scrollTop: this.scrollTop,
2220
+ showHeader: this.showHeader,
2221
+ viewportWidth: this.viewportWidth,
2222
+ viewportHeight: this.viewportHeight,
2223
+ isDragging: this.drag?.kind === "move"
2224
+ });
2225
+ }
2226
+ trackIndexAtY(y) {
2227
+ return trackIndexAt(y, this.project.tracks.length, this.scrollTop);
2228
+ }
2229
+ applySnap(ms, ignoreClipId) {
2230
+ if (!this.snapEnabled) {
2231
+ this.snapX = null;
2232
+ return ms;
2233
+ }
2234
+ const tolMs = Math.max(20, SNAP_PX / this.pxPerSec * 1e3);
2235
+ const targets = snapTargets(this.project, this.timeMs, ignoreClipId);
2236
+ let best = ms;
2237
+ let bestDist = tolMs;
2238
+ for (const t of targets) {
2239
+ const d = Math.abs(t - ms);
2240
+ if (d < bestDist) {
2241
+ bestDist = d;
2242
+ best = t;
2243
+ }
2244
+ }
2245
+ if (best !== ms) {
2246
+ const baseX = this.showHeader ? HEADER_WIDTH : 0;
2247
+ this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
2248
+ } else {
2249
+ this.snapX = null;
2250
+ }
2251
+ return best;
2252
+ }
2253
+ };
2254
+
2255
+ // src/ui/icons.ts
2256
+ var wrap = (path) => `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">${path}</svg>`;
2257
+ var ICONS = {
2258
+ undo: wrap(
2259
+ `<g transform="translate(1 1)"><path d="M5.66577 1.38721L2.85034 4.20264H9.64624C11.8471 4.20264 13.6313 5.98724 13.6316 8.18799C13.6315 10.3889 11.8472 12.1733 9.64624 12.1733H7.19116V11.1235H9.64624C11.2673 11.1235 12.5817 9.80904 12.5818 8.18799C12.5815 6.56714 11.2672 5.25342 9.64624 5.25342H2.85034L5.66577 8.06885L4.92358 8.81201L0.8396 4.72803L4.92358 0.644043L5.66577 1.38721Z" fill="currentColor"/></g>`
2260
+ ),
2261
+ redo: wrap(
2262
+ `<g transform="translate(1 1)"><path d="M8.55005 2.04655L11.3659 4.86239L4.56982 4.86239C2.36897 4.86251 0.584578 6.64687 0.584473 8.84774C0.584646 11.0485 2.36901 12.833 4.56982 12.8331L7.0245 12.8331L7.0245 11.7832H4.56982C2.94891 11.7831 1.63453 10.4686 1.63436 8.84774C1.63446 7.22677 2.94886 5.91297 4.56982 5.91284H11.3659L8.55005 8.72811L9.29289 9.47095L13.3768 5.38761L9.29289 1.30371L8.55005 2.04655Z" fill="currentColor"/></g>`
2263
+ ),
2264
+ split: wrap(
2265
+ `<g transform="translate(1 1)"><path d="M5.7168 12.7754H1.75V11.7246H4.68164V2.27539H1.75V1.22461H5.7168V12.7754ZM12.25 2.27539H9.31836V11.7246H12.25V12.7754H8.2832V1.22461H12.25V2.27539Z" fill="currentColor"/></g>`
2266
+ ),
2267
+ trimLeft: wrap(
2268
+ `<g transform="translate(1 1)"><path d="M2.7998 12.7754H1.75V11.7246H2.7998V12.7754ZM4.25781 12.7754H3.20801V11.7246H4.25781V12.7754ZM5.7168 12.7754H4.66699V11.7246H5.7168V12.7754ZM12.25 2.27539H9.31836V11.7246H12.25V12.7754H8.2832V1.22461H12.25V2.27539ZM5.7168 11.0254H4.66699V9.97461H5.7168V11.0254ZM5.7168 9.27539H4.66699V8.22461H5.7168V9.27539ZM5.7168 7.52539H4.66699V6.47461H5.7168V7.52539ZM5.7168 5.77539H4.66699V4.72461H5.7168V5.77539ZM5.7168 4.02539H4.66699V2.97461H5.7168V4.02539ZM2.7998 2.27539H1.75V1.22461H2.7998V2.27539ZM4.25781 2.27539H3.20801V1.22461H4.25781V2.27539ZM5.7168 2.27539H4.66699V1.22461H5.7168V2.27539Z" fill="currentColor"/></g>`
2269
+ ),
2270
+ trimRight: wrap(
2271
+ `<g transform="translate(1 1)"><path d="M5.7168 12.7754H1.75V11.7246H4.68164V2.27539H1.75V1.22461H5.7168V12.7754ZM9.33301 12.7754H8.2832V11.7246H9.33301V12.7754ZM10.792 12.7754H9.74219V11.7246H10.792V12.7754ZM12.25 12.7754H11.2002V11.7246H12.25V12.7754ZM9.33301 11.0254H8.2832V9.97461H9.33301V11.0254ZM9.33301 9.27539H8.2832V8.22461H9.33301V9.27539ZM9.33301 7.52539H8.2832V6.47461H9.33301V7.52539ZM9.33301 5.77539H8.2832V4.72461H9.33301V5.77539ZM9.33301 4.02539H8.2832V2.97461H9.33301V4.02539ZM9.33301 2.27539H8.2832V1.22461H9.33301V2.27539ZM10.792 2.27539H9.74219V1.22461H10.792V2.27539ZM12.25 2.27539H11.2002V1.22461H12.25V2.27539Z" fill="currentColor"/></g>`
2272
+ ),
2273
+ speed: wrap(
2274
+ `<g transform="translate(1 1)"><path d="M7.00175 0.595229C7.32353 0.596316 7.58439 0.858333 7.58378 1.18019C7.583 1.50212 7.32071 1.76265 6.99882 1.76222C6.45361 1.76108 5.90722 1.8449 5.38065 2.01417C4.23393 2.38294 3.24874 3.13519 2.59061 4.14406C1.93249 5.15302 1.64169 6.35801 1.76639 7.55617C1.89121 8.75413 2.4235 9.87343 3.27518 10.7251C4.12694 11.5767 5.24613 12.1092 6.44413 12.2339C7.6422 12.3585 8.84735 12.0678 9.85624 11.4097C10.8649 10.7516 11.6173 9.76618 11.9861 8.61964C12.5967 6.72047 12.1219 5.04361 11.0633 3.76124L8.57792 6.24757C8.68682 6.4756 8.74974 6.72999 8.74979 6.99953C8.74979 7.9659 7.96612 8.74933 6.99979 8.74953C6.03345 8.74934 5.24979 7.96591 5.24979 6.99953C5.24997 6.03329 6.03356 5.24971 6.99979 5.24953C7.26941 5.24958 7.52464 5.31244 7.75272 5.4214L10.6707 2.50441L10.7137 2.46535C10.8174 2.38043 10.9485 2.33351 11.0838 2.33351C11.2382 2.33369 11.3867 2.39519 11.4959 2.50441C13.0855 4.09426 13.9307 6.38102 13.0965 8.97609C12.6458 10.3776 11.726 11.5819 10.493 12.3862C9.25989 13.1905 7.7873 13.5464 6.32303 13.3941C4.8588 13.2416 3.49099 12.5903 2.44999 11.5493C1.40904 10.5083 0.757676 9.14058 0.605261 7.67628C0.452904 6.21196 0.808759 4.73947 1.61307 3.50636C2.41742 2.27338 3.62178 1.35455 5.02323 0.903823C5.66695 0.696861 6.33524 0.593807 7.00175 0.595229Z" fill="currentColor"/></g>`
2275
+ ),
2276
+ play: wrap(
2277
+ // Triangle bbox center at viewBox x=9.4 vs viewBox center 8 — the
2278
+ // raw path leans right. Optical centering: translate by half the
2279
+ // bbox offset (-0.7) so the centroid stays near 7.5 (slight left
2280
+ // of center, which the eye accepts as balanced for a right-pointing
2281
+ // triangle) while the bbox is no longer obviously off to one side.
2282
+ `<g transform="translate(-0.7 0)"><path d="M4.66699 2.64248C4.66717 1.82358 5.59736 1.35167 6.25781 1.83584L13.5674 7.19619C14.1117 7.59579 14.1118 8.40897 13.5674 8.8085L6.25781 14.1688C5.59734 14.6528 4.6671 14.1811 4.66699 13.3622V2.64248Z" fill="currentColor"/></g>`
2283
+ ),
2284
+ pause: wrap(
2285
+ `<path d="M4 3h2.5v10H4V3zm5.5 0H12v10H9.5V3z" fill="currentColor"/>`
2286
+ ),
2287
+ fullscreen: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3H5a2 2 0 00-2 2v3"/><path d="M21 8V5a2 2 0 00-2-2h-3"/><path d="M3 16v3a2 2 0 002 2h3"/><path d="M16 21h3a2 2 0 002-2v-3"/></svg>`,
2288
+ snap: `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.275.783a5.07 5.07 0 0 1 4.929 4.927 5.07 5.07 0 0 1-1.311 3.542l-3.937 3.95a.526.526 0 0 1-.744 0l-2.334-2.334a.525.525 0 0 1 .001-.743l2.55-2.538a.5.5 0 0 1 .171-.17l.976-.97a.723.723 0 0 0-1.01-1.008L3.87 9.12a.526.526 0 0 1-.743 0L.795 6.784a.526.526 0 0 1 0-.742l3.937-3.95.022-.02a5.07 5.07 0 0 1 3.52-1.29m-2.281 9.715 1.588 1.589 1.884-1.889-1.583-1.583zm2.253-8.666a4.02 4.02 0 0 0-2.785 1.017l-.936.939 1.602 1.603.729-.726.052-.045a1.776 1.776 0 0 1 2.33.154 1.775 1.775 0 0 1 .155 2.332 1 1 0 0 1-.046.053l-.72.716 1.58 1.58.924-.929a4.019 4.019 0 0 0-2.885-6.694M1.909 6.413 3.5 8.005 5.384 6.13l-1.6-1.6z"/></svg>`,
2289
+ zoomOut: `<svg width="14" height="14" viewBox="0 0 20.2618 20.2564" fill="none" aria-hidden="true"><path d="M9 0C13.9706 0 18 4.02944 18 9C18 11.1612 17.2374 13.1438 15.9678 14.6953L19.998 18.7197C20.3494 19.071 20.3498 19.6404 19.999 19.9922C19.6476 20.3441 19.0775 20.3446 18.7256 19.9932L14.6953 15.9678C13.1438 17.2374 11.1612 18 9 18C4.02944 18 0 13.9706 0 9C0 4.02944 4.02944 0 9 0ZM9 1.7998C5.02355 1.7998 1.7998 5.02355 1.7998 9C1.7998 12.9765 5.02355 16.2002 9 16.2002C12.9765 16.2002 16.2002 12.9765 16.2002 9C16.2002 5.02355 12.9765 1.7998 9 1.7998ZM12.5996 8.09961C13.0969 8.09961 13.5 8.50273 13.5 9C13.5 9.49727 13.0969 9.90039 12.5996 9.90039H5.40039C4.90312 9.90039 4.5 9.49727 4.5 9C4.5 8.50273 4.90312 8.09961 5.40039 8.09961H12.5996Z" fill="currentColor"/></svg>`,
2290
+ zoomIn: `<svg width="14" height="14" viewBox="0 0 20.2613 20.2565" fill="none" aria-hidden="true"><path d="M9 0C13.9705 6.59711e-05 18 4.02948 18 9C18 11.1612 17.2374 13.1438 15.9678 14.6953L19.9971 18.7197C20.3489 19.0711 20.3494 19.6403 19.998 19.9922C19.6466 20.3441 19.0765 20.3446 18.7246 19.9932L14.6953 15.9678C13.1438 17.2374 11.1611 18 9 18C4.02944 18 0 13.9706 0 9C0 4.02944 4.02944 0 9 0ZM9 1.7998C5.02355 1.7998 1.7998 5.02355 1.7998 9C1.7998 12.9765 5.02355 16.2002 9 16.2002C12.9764 16.2001 16.2002 12.9764 16.2002 9C16.2002 5.02359 12.9764 1.79987 9 1.7998ZM8.99512 4.50488C9.49233 4.50495 9.89551 4.90804 9.89551 5.40527V8.09961H12.5996C13.0968 8.09968 13.5 8.50277 13.5 9C13.5 9.49723 13.0968 9.90032 12.5996 9.90039H9.89551V12.6045C9.89551 13.1017 9.49233 13.5048 8.99512 13.5049C8.49785 13.5049 8.09473 13.1018 8.09473 12.6045V9.90039H5.40039C4.90312 9.90039 4.5 9.49727 4.5 9C4.5 8.50273 4.90312 8.09961 5.40039 8.09961H8.09473V5.40527C8.09473 4.908 8.49785 4.50488 8.99512 4.50488Z" fill="currentColor"/></svg>`,
2291
+ export: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>`,
2292
+ addTrack: wrap(
2293
+ `<g transform="translate(2 2)"><rect x="0" y="1.5" width="12" height="2.5" rx="0.5" fill="currentColor"/><rect x="0" y="8" width="12" height="2.5" rx="0.5" fill="currentColor" opacity="0.55"/><circle cx="10" cy="10" r="3.4" fill="currentColor"/><rect x="9.4" y="8.5" width="1.2" height="3" rx="0.4" fill="#fff"/><rect x="8.5" y="9.4" width="3" height="1.2" rx="0.4" fill="#fff"/></g>`
2294
+ ),
2295
+ trash: wrap(
2296
+ `<g transform="translate(1 1)" fill="currentColor"><path d="M5 1.25h4v.9H13v1.05H1V2.15h4v-.9zM2.4 4.1h9.2l-.65 8.9c-.04.55-.5.96-1.05.96H4.1c-.55 0-1.01-.41-1.05-.96L2.4 4.1zm2.3 1.7l.35 7.1h1l-.35-7.1h-1zm2.65 0v7.1h1V5.8h-1zm2.3 0l-.35 7.1h1l.35-7.1h-1z"/></g>`
2297
+ ),
2298
+ /** Counter-clockwise circular arrow — "reset to initial layout". */
2299
+ reset: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`
2300
+ };
2301
+
2302
+ // src/ui/toolbar.ts
2303
+ var SCALE_MIN2 = 10;
2304
+ var SCALE_MAX2 = 400;
2305
+ var Toolbar = class {
2306
+ root;
2307
+ cb;
2308
+ locale;
2309
+ /**
2310
+ * Bookend slots reserved for host-supplied controls. The library
2311
+ * paints nothing into either — hosts (React/Vue wrappers or plain
2312
+ * JS) append their own buttons / dropdowns. `extrasLeft` sits at
2313
+ * the very start of the toolbar; `extrasRight` at the very end.
2314
+ * Empty by default and visually hidden (no separator) until they
2315
+ * actually contain children.
2316
+ */
2317
+ extrasLeft;
2318
+ extrasRight;
2319
+ undoBtn;
2320
+ redoBtn;
2321
+ splitBtn;
2322
+ trimLeftBtn;
2323
+ trimRightBtn;
2324
+ playBtn;
2325
+ playIcon;
2326
+ timeLabel;
2327
+ durationLabel;
2328
+ fullscreenBtn;
2329
+ snapBtn;
2330
+ zoomOutBtn;
2331
+ zoomSlider;
2332
+ zoomInBtn;
2333
+ resetBtn;
2334
+ lastState = null;
2335
+ constructor(host, cb, locale) {
2336
+ this.cb = cb;
2337
+ this.locale = locale;
2338
+ this.root = document.createElement("div");
2339
+ this.root.className = "aicut-toolbar";
2340
+ this.root.setAttribute("data-testid", "aicut-toolbar");
2341
+ this.extrasLeft = mkGroup("aicut-toolbar-extras aicut-toolbar-extras-left");
2342
+ this.extrasRight = mkGroup("aicut-toolbar-extras aicut-toolbar-extras-right");
2343
+ const left = mkGroup("aicut-toolbar-left");
2344
+ this.undoBtn = mkIconButton("undo", locale.undo, () => cb.onUndo(), "aicut-undo");
2345
+ this.redoBtn = mkIconButton("redo", locale.redo, () => cb.onRedo(), "aicut-redo");
2346
+ this.splitBtn = mkIconButton("split", locale.split, () => cb.onSplit(), "aicut-split");
2347
+ this.trimLeftBtn = mkIconButton("trimLeft", locale.trimLeft, () => cb.onTrimLeft(), "aicut-trim-left");
2348
+ this.trimRightBtn = mkIconButton("trimRight", locale.trimRight, () => cb.onTrimRight(), "aicut-trim-right");
2349
+ const speedBtn = mkIconButton("speed", locale.speedComingSoon, () => void 0, "aicut-speed");
2350
+ speedBtn.disabled = true;
2351
+ left.append(this.undoBtn, this.redoBtn, this.splitBtn, this.trimLeftBtn, this.trimRightBtn, speedBtn);
2352
+ const center = mkGroup("aicut-toolbar-center");
2353
+ this.timeLabel = mkSpan("aicut-time-current", "00:00", "aicut-time-current");
2354
+ this.playBtn = document.createElement("button");
2355
+ this.playBtn.type = "button";
2356
+ this.playBtn.className = "aicut-play-btn";
2357
+ this.playBtn.title = locale.playPause;
2358
+ this.playBtn.setAttribute("data-testid", "aicut-play");
2359
+ this.playIcon = document.createElement("span");
2360
+ this.playIcon.innerHTML = ICONS.play;
2361
+ this.playBtn.appendChild(this.playIcon);
2362
+ this.playBtn.addEventListener("click", () => cb.onPlayToggle());
2363
+ this.durationLabel = mkSpan("aicut-time-total", "00:00", "aicut-time-total");
2364
+ this.fullscreenBtn = mkIconButton("fullscreen", locale.fullscreen, () => cb.onFullscreen(), "aicut-fullscreen");
2365
+ center.append(this.timeLabel, this.playBtn, this.durationLabel, this.fullscreenBtn);
2366
+ const right = mkGroup("aicut-toolbar-right");
2367
+ this.snapBtn = mkIconButton("snap", locale.snap, () => cb.onSnapToggle(), "aicut-snap");
2368
+ this.zoomOutBtn = mkIconButton("zoomOut", locale.zoomOut, () => this.nudgeZoom(-1), "aicut-zoom-out");
2369
+ this.zoomSlider = document.createElement("input");
2370
+ this.zoomSlider.type = "range";
2371
+ this.zoomSlider.min = "0";
2372
+ this.zoomSlider.max = "100";
2373
+ this.zoomSlider.className = "aicut-zoom-slider";
2374
+ this.zoomSlider.setAttribute("data-testid", "aicut-zoom-slider");
2375
+ this.zoomSlider.addEventListener("input", () => {
2376
+ const ratio = Number(this.zoomSlider.value) / 100;
2377
+ cb.onScaleChange(sliderToScale(ratio));
2378
+ });
2379
+ this.zoomInBtn = mkIconButton("zoomIn", locale.zoomIn, () => this.nudgeZoom(1), "aicut-zoom-in");
2380
+ this.resetBtn = mkIconButton("reset", locale.reset, () => cb.onReset(), "aicut-reset");
2381
+ right.append(this.snapBtn, this.zoomOutBtn, this.zoomSlider, this.zoomInBtn, this.resetBtn);
2382
+ this.root.append(this.extrasLeft, left, center, right, this.extrasRight);
2383
+ host.appendChild(this.root);
2384
+ }
2385
+ get element() {
2386
+ return this.root;
2387
+ }
2388
+ /**
2389
+ * Idempotent render. Critically: only mutates DOM when a state
2390
+ * field actually changed. Without this, `playIcon.innerHTML = ICONS`
2391
+ * re-parsed the SVG every playback tick (~60Hz) — and replacing the
2392
+ * element between a user's mousedown and mouseup meant the browser
2393
+ * never fired `click` (the two events landed on different element
2394
+ * identities). That manifested as "needs 3 clicks to pause".
2395
+ *
2396
+ * Rule of thumb here: any `innerHTML =` / element rebuild MUST be
2397
+ * behind a "did the input change" guard. Plain `.disabled` /
2398
+ * `.textContent` writes are idempotent in browsers and safe to set
2399
+ * unconditionally, but we still diff them for cheap CPU.
2400
+ */
2401
+ render(state) {
2402
+ if (!this.lastState || this.lastState.time !== state.time) {
2403
+ this.timeLabel.textContent = fmtClock(state.time);
2404
+ }
2405
+ if (!this.lastState || this.lastState.duration !== state.duration) {
2406
+ this.durationLabel.textContent = fmtClock(state.duration);
2407
+ }
2408
+ if (!this.lastState || this.lastState.playing !== state.playing) {
2409
+ this.playIcon.innerHTML = state.playing ? ICONS.pause : ICONS.play;
2410
+ this.playBtn.setAttribute(
2411
+ "data-state",
2412
+ state.playing ? "playing" : "paused"
2413
+ );
2414
+ }
2415
+ if (!this.lastState || this.lastState.canUndo !== state.canUndo) {
2416
+ this.undoBtn.disabled = !state.canUndo;
2417
+ }
2418
+ if (!this.lastState || this.lastState.canRedo !== state.canRedo) {
2419
+ this.redoBtn.disabled = !state.canRedo;
2420
+ }
2421
+ if (!this.lastState || this.lastState.canSplit !== state.canSplit) {
2422
+ this.splitBtn.disabled = !state.canSplit;
2423
+ }
2424
+ if (!this.lastState || this.lastState.canTrim !== state.canTrim) {
2425
+ this.trimLeftBtn.disabled = !state.canTrim;
2426
+ this.trimRightBtn.disabled = !state.canTrim;
2427
+ }
2428
+ if (!this.lastState || this.lastState.snap !== state.snap) {
2429
+ this.snapBtn.setAttribute("aria-pressed", state.snap ? "true" : "false");
2430
+ this.snapBtn.classList.toggle("aicut-toggle-on", state.snap);
2431
+ this.snapBtn.title = state.snap ? this.locale.snapOnTitle : this.locale.snapOffTitle;
2432
+ }
2433
+ if (!this.lastState || this.lastState.pxPerSec !== state.pxPerSec) {
2434
+ const ratio = scaleToSlider(state.pxPerSec);
2435
+ const nextVal = String(Math.round(ratio * 100));
2436
+ if (this.zoomSlider.value !== nextVal) this.zoomSlider.value = nextVal;
2437
+ this.zoomSlider.style.setProperty(
2438
+ "--aicut-zoom-fill",
2439
+ `${Math.round(ratio * 100)}%`
2440
+ );
2441
+ }
2442
+ this.lastState = { ...state };
2443
+ }
2444
+ destroy() {
2445
+ this.root.remove();
2446
+ }
2447
+ /**
2448
+ * Apply a new locale to all already-mounted controls. Re-uses the
2449
+ * same DOM elements (so event listeners and pointer-capture state
2450
+ * stay intact) — only writes `title` / `aria-label`. Snap toggle
2451
+ * title is then refreshed via the next render pass.
2452
+ */
2453
+ setLocale(locale) {
2454
+ this.locale = locale;
2455
+ const applyTitle = (el, title) => {
2456
+ if (!el) return;
2457
+ el.title = title;
2458
+ el.setAttribute("aria-label", title);
2459
+ };
2460
+ applyTitle(this.undoBtn, locale.undo);
2461
+ applyTitle(this.redoBtn, locale.redo);
2462
+ applyTitle(this.splitBtn, locale.split);
2463
+ applyTitle(this.trimLeftBtn, locale.trimLeft);
2464
+ applyTitle(this.trimRightBtn, locale.trimRight);
2465
+ applyTitle(this.playBtn, locale.playPause);
2466
+ applyTitle(this.fullscreenBtn, locale.fullscreen);
2467
+ applyTitle(this.snapBtn, locale.snap);
2468
+ applyTitle(this.zoomOutBtn, locale.zoomOut);
2469
+ applyTitle(this.zoomInBtn, locale.zoomIn);
2470
+ applyTitle(this.resetBtn, locale.reset);
2471
+ if (this.lastState) {
2472
+ this.snapBtn.title = this.lastState.snap ? locale.snapOnTitle : locale.snapOffTitle;
2473
+ }
2474
+ }
2475
+ nudgeZoom(dir) {
2476
+ const cur = Number(this.zoomSlider.value);
2477
+ const next = Math.max(0, Math.min(100, cur + dir * 8));
2478
+ this.zoomSlider.value = String(next);
2479
+ this.cb.onScaleChange(sliderToScale(next / 100));
2480
+ }
2481
+ };
2482
+ function mkGroup(cls) {
2483
+ const d = document.createElement("div");
2484
+ d.className = cls;
2485
+ return d;
2486
+ }
2487
+ function mkSpan(cls, text, testId) {
2488
+ const s = document.createElement("span");
2489
+ s.className = cls;
2490
+ s.textContent = text;
2491
+ if (testId) s.setAttribute("data-testid", testId);
2492
+ return s;
2493
+ }
2494
+ function mkIconButton(icon, title, onClick, testId) {
2495
+ const b = document.createElement("button");
2496
+ b.type = "button";
2497
+ b.className = "aicut-icon-btn";
2498
+ b.title = title;
2499
+ b.setAttribute("aria-label", title);
2500
+ b.innerHTML = ICONS[icon];
2501
+ if (testId) b.setAttribute("data-testid", testId);
2502
+ b.addEventListener("click", onClick);
2503
+ return b;
2504
+ }
2505
+ function sliderToScale(ratio) {
2506
+ const lo = Math.log(SCALE_MIN2);
2507
+ const hi = Math.log(SCALE_MAX2);
2508
+ return Math.exp(lo + (hi - lo) * ratio);
2509
+ }
2510
+ function scaleToSlider(scale) {
2511
+ const lo = Math.log(SCALE_MIN2);
2512
+ const hi = Math.log(SCALE_MAX2);
2513
+ return (Math.log(Math.max(SCALE_MIN2, Math.min(SCALE_MAX2, scale))) - lo) / (hi - lo);
2514
+ }
2515
+
2516
+ // src/ui/index.ts
2517
+ var EditorUI = class {
2518
+ root;
2519
+ editor;
2520
+ preview;
2521
+ fullscreenExitBtn;
2522
+ toolbar;
2523
+ timelineHost;
2524
+ timeline;
2525
+ fullscreen = false;
2526
+ onDocKeydown = null;
2527
+ constructor(root, editor, cb) {
2528
+ this.root = root;
2529
+ this.editor = editor;
2530
+ const locale = editor.getLocale();
2531
+ root.classList.add("aicut-root");
2532
+ root.innerHTML = "";
2533
+ this.preview = document.createElement("div");
2534
+ this.preview.className = "aicut-preview-host";
2535
+ this.preview.setAttribute("data-testid", "aicut-preview");
2536
+ root.appendChild(this.preview);
2537
+ this.fullscreenExitBtn = document.createElement("button");
2538
+ this.fullscreenExitBtn.type = "button";
2539
+ this.fullscreenExitBtn.className = "aicut-fullscreen-exit";
2540
+ this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
2541
+ this.fullscreenExitBtn.setAttribute("data-testid", "aicut-fullscreen-exit");
2542
+ this.fullscreenExitBtn.textContent = locale.exitFullscreen;
2543
+ this.fullscreenExitBtn.addEventListener(
2544
+ "click",
2545
+ () => this.setFullscreen(false)
2546
+ );
2547
+ this.preview.appendChild(this.fullscreenExitBtn);
2548
+ this.toolbar = new Toolbar(root, cb, locale);
2549
+ this.timelineHost = document.createElement("div");
2550
+ this.timelineHost.className = "aicut-timeline";
2551
+ this.timelineHost.setAttribute("data-testid", "aicut-timeline");
2552
+ root.appendChild(this.timelineHost);
2553
+ this.timeline = Timeline.create({
2554
+ container: this.timelineHost,
2555
+ project: editor.getProject(),
2556
+ pxPerSec: editor.getScale(),
2557
+ time: editor.getTime(),
2558
+ selectedClipId: editor.getSelection(),
2559
+ snap: editor.getSnap(),
2560
+ autoFit: true,
2561
+ locale,
2562
+ onSeek: cb.onSeek,
2563
+ onSelectClip: cb.onSelectClip,
2564
+ onMoveClip: cb.onMoveClip,
2565
+ onResizeClip: cb.onResizeClip,
2566
+ onScaleChange: cb.onScaleChange,
2567
+ onDeleteTrack: (trackId) => editor.removeTrack(trackId),
2568
+ // Mirror the editor's smart routing into the drag preview so
2569
+ // the ghost lands on the same row the commit will pick.
2570
+ resolveDrop: (clipId, intent) => {
2571
+ const proj = editor.getProject();
2572
+ const intendedTrack = proj.tracks[intent.intendedTrackIndex];
2573
+ const pred = editor.previewMoveTarget(
2574
+ clipId,
2575
+ intent.start,
2576
+ intendedTrack?.id
2577
+ );
2578
+ if (!pred) {
2579
+ return {
2580
+ trackIndex: intent.intendedTrackIndex,
2581
+ wouldCreateNew: false
2582
+ };
2583
+ }
2584
+ return {
2585
+ trackIndex: pred.trackIndex,
2586
+ wouldCreateNew: pred.wouldCreateNew
2587
+ };
2588
+ }
2589
+ });
2590
+ this.attachKeyboard(cb);
2591
+ }
2592
+ // ---- fullscreen -----------------------------------------------------
2593
+ isFullscreen() {
2594
+ return this.fullscreen;
2595
+ }
2596
+ toggleFullscreen() {
2597
+ this.setFullscreen(!this.fullscreen);
2598
+ }
2599
+ setFullscreen(on) {
2600
+ if (on === this.fullscreen) return;
2601
+ this.fullscreen = on;
2602
+ this.root.classList.toggle("aicut-fullscreen", on);
2603
+ if (on) {
2604
+ this.onDocKeydown = (e) => {
2605
+ if (e.key === "Escape") {
2606
+ e.preventDefault();
2607
+ this.setFullscreen(false);
2608
+ }
2609
+ };
2610
+ document.addEventListener("keydown", this.onDocKeydown);
2611
+ } else if (this.onDocKeydown) {
2612
+ document.removeEventListener("keydown", this.onDocKeydown);
2613
+ this.onDocKeydown = null;
2614
+ }
2615
+ }
2616
+ get previewHost() {
2617
+ return this.preview;
2618
+ }
2619
+ /** Host-extensible slot at the very left of the top toolbar. */
2620
+ get toolbarLeft() {
2621
+ return this.toolbar.extrasLeft;
2622
+ }
2623
+ /** Host-extensible slot at the very right of the top toolbar. */
2624
+ get toolbarRight() {
2625
+ return this.toolbar.extrasRight;
2626
+ }
2627
+ /** Public for e2e — read-back of timeline canvas state (no DOM clips). */
2628
+ getTimelineDebug() {
2629
+ return this.timeline.getDebugInfo();
2630
+ }
2631
+ /** Full sync from editor state. Idempotent. */
2632
+ render() {
2633
+ const project = this.editor.getProject();
2634
+ const time = this.editor.getTime();
2635
+ const duration = this.editor.getDuration();
2636
+ const selectedClipId = this.editor.getSelection();
2637
+ const pxPerSec = this.editor.getScale();
2638
+ const snap = this.editor.getSnap();
2639
+ this.toolbar.render({
2640
+ playing: this.editor.isPlaying(),
2641
+ time,
2642
+ duration,
2643
+ canUndo: this.editor.canUndo(),
2644
+ canRedo: this.editor.canRedo(),
2645
+ canSplit: this.canSplitAt(time),
2646
+ canTrim: this.canTrimAt(time, selectedClipId),
2647
+ snap,
2648
+ pxPerSec
2649
+ });
2650
+ this.timeline.setProject(project);
2651
+ this.timeline.setTime(time);
2652
+ this.timeline.setScale(pxPerSec);
2653
+ this.timeline.setSelection(selectedClipId);
2654
+ this.timeline.setSnap(snap);
2655
+ }
2656
+ /** Playback-fast path: nudge playhead + toolbar time label only. */
2657
+ onTimeTick(timeMs) {
2658
+ this.timeline.setTime(timeMs);
2659
+ this.toolbar.render({
2660
+ playing: this.editor.isPlaying(),
2661
+ time: timeMs,
2662
+ duration: this.editor.getDuration(),
2663
+ canUndo: this.editor.canUndo(),
2664
+ canRedo: this.editor.canRedo(),
2665
+ canSplit: this.canSplitAt(timeMs),
2666
+ canTrim: this.canTrimAt(timeMs, this.editor.getSelection()),
2667
+ snap: this.editor.getSnap(),
2668
+ pxPerSec: this.editor.getScale()
2669
+ });
2670
+ }
2671
+ /** Explicit re-fit — Editor calls this when a brand-new project replaces the current one. */
2672
+ resetAutoFit() {
2673
+ this.timeline.refit();
2674
+ }
2675
+ setLocale(locale) {
2676
+ this.toolbar.setLocale(locale);
2677
+ this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
2678
+ this.fullscreenExitBtn.textContent = locale.exitFullscreen;
2679
+ this.timeline.setLocale(locale);
2680
+ this.render();
2681
+ }
2682
+ destroy() {
2683
+ if (this.onDocKeydown) {
2684
+ document.removeEventListener("keydown", this.onDocKeydown);
2685
+ this.onDocKeydown = null;
2686
+ }
2687
+ this.toolbar.destroy();
2688
+ this.timeline.destroy();
2689
+ this.root.innerHTML = "";
2690
+ this.root.classList.remove("aicut-root", "aicut-fullscreen");
2691
+ }
2692
+ // ---- helpers --------------------------------------------------------
2693
+ canSplitAt(timeMs) {
2694
+ const project = this.editor.getProject();
2695
+ for (const t of project.tracks) {
2696
+ for (const c of t.clips) {
2697
+ if (timeMs > c.start && timeMs < c.start + clipDuration(c)) return true;
2698
+ }
2699
+ }
2700
+ return false;
2701
+ }
2702
+ canTrimAt(timeMs, selectedClipId) {
2703
+ const project = this.editor.getProject();
2704
+ if (selectedClipId) {
2705
+ const trk = findTrackOfClip(project, selectedClipId);
2706
+ const cl = trk?.clips.find((c) => c.id === selectedClipId);
2707
+ if (cl && timeMs > cl.start && timeMs < cl.start + clipDuration(cl)) {
2708
+ return true;
2709
+ }
2710
+ }
2711
+ for (const t of project.tracks) {
2712
+ const cl = findClipContaining(t, timeMs);
2713
+ if (cl) return true;
2714
+ }
2715
+ return false;
2716
+ }
2717
+ attachKeyboard(cb) {
2718
+ this.root.tabIndex = 0;
2719
+ this.root.addEventListener("keydown", (e) => {
2720
+ const target = e.target;
2721
+ if (target && ["INPUT", "TEXTAREA"].includes(target.tagName)) return;
2722
+ if (e.code === "Space") {
2723
+ e.preventDefault();
2724
+ cb.onPlayToggle();
2725
+ } else if (e.code === "KeyK") {
2726
+ e.preventDefault();
2727
+ cb.onSplit();
2728
+ } else if (e.code === "KeyQ") {
2729
+ e.preventDefault();
2730
+ cb.onTrimLeft();
2731
+ } else if (e.code === "KeyW") {
2732
+ e.preventDefault();
2733
+ cb.onTrimRight();
2734
+ } else if ((e.metaKey || e.ctrlKey) && e.code === "KeyZ") {
2735
+ e.preventDefault();
2736
+ if (e.shiftKey) cb.onRedo();
2737
+ else cb.onUndo();
2738
+ } else if (e.code === "Delete" || e.code === "Backspace") {
2739
+ const sel = this.editor.getSelection();
2740
+ if (sel) {
2741
+ e.preventDefault();
2742
+ cb.onDeleteClip(sel);
2743
+ }
2744
+ }
2745
+ });
2746
+ }
2747
+ };
2748
+
2749
+ // src/editor.ts
2750
+ var DEFAULT_PX_PER_SEC = 80;
2751
+ var MIN_PX_PER_SEC = 10;
2752
+ var MAX_PX_PER_SEC = 400;
2753
+ var SNAP_PX2 = 8;
2754
+ var Editor = class _Editor {
2755
+ container;
2756
+ project;
2757
+ engine;
2758
+ ui;
2759
+ bus = new EventBus();
2760
+ history = new HistoryStack();
2761
+ selectedClipId = null;
2762
+ pxPerSec;
2763
+ snap;
2764
+ locale;
2765
+ destroyed = false;
2766
+ constructor(opts) {
2767
+ this.container = opts.container;
2768
+ this.project = normalizeProject(opts.project ?? createEmptyProject());
2769
+ this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
2770
+ this.snap = opts.initialSnap !== false;
2771
+ this.locale = mergeLocale(opts.locale);
2772
+ applyTheme(this.container, opts.theme);
2773
+ this.ui = new EditorUI(this.container, this, {
2774
+ onPlayToggle: () => this.togglePlay(),
2775
+ onSplit: () => this.split(),
2776
+ onTrimLeft: () => this.trimLeft(),
2777
+ onTrimRight: () => this.trimRight(),
2778
+ onUndo: () => this.undo(),
2779
+ onRedo: () => this.redo(),
2780
+ onReset: () => this.reset(),
2781
+ onFullscreen: () => this.ui.toggleFullscreen(),
2782
+ onSnapToggle: () => this.setSnap(!this.snap),
2783
+ onScaleChange: (s) => this.setScale(s),
2784
+ onSeek: (t) => this.seek(t),
2785
+ onSelectClip: (id) => this.setSelection(id),
2786
+ onDeleteClip: (id) => this.removeClip(id),
2787
+ onMoveClip: (id, opts2) => this.moveClip(id, opts2),
2788
+ onResizeClip: (id, edits) => this.resizeClip(id, edits)
2789
+ });
2790
+ this.engine = new PlaybackEngine(this.ui.previewHost, this.project);
2791
+ this.engine.onTimeUpdate = (ms) => {
2792
+ this.bus.emit("time", { timeMs: ms });
2793
+ this.ui.onTimeTick(ms);
2794
+ };
2795
+ this.engine.onEnded = () => this.bus.emit("pause", void 0);
2796
+ this.engine.onError = (err) => this.bus.emit("error", { error: err });
2797
+ this.engine.onReady = () => this.bus.emit("ready", { sourceId: null });
2798
+ this.engine.onSourceMetadata = (sourceId, durMs) => this.handleSourceMetadata(sourceId, durMs);
2799
+ if (opts.initialTime) this.engine.seek(opts.initialTime);
2800
+ this.ui.render();
2801
+ }
2802
+ static create(opts) {
2803
+ return new _Editor(opts);
2804
+ }
2805
+ get toolbarLeft() {
2806
+ return this.ui.toolbarLeft;
2807
+ }
2808
+ get toolbarRight() {
2809
+ return this.ui.toolbarRight;
2810
+ }
2811
+ // ---- playback -------------------------------------------------------
2812
+ play() {
2813
+ this.engine.play();
2814
+ this.bus.emit("play", void 0);
2815
+ this.ui.render();
2816
+ }
2817
+ pause() {
2818
+ this.engine.pause();
2819
+ this.bus.emit("pause", void 0);
2820
+ this.ui.render();
2821
+ }
2822
+ togglePlay() {
2823
+ if (this.engine.isPlaying()) this.pause();
2824
+ else this.play();
2825
+ }
2826
+ seek(timeMs) {
2827
+ this.engine.seek(timeMs);
2828
+ this.ui.render();
2829
+ }
2830
+ getTime() {
2831
+ return this.engine?.getTime() ?? 0;
2832
+ }
2833
+ getDuration() {
2834
+ return projectDuration(this.project);
2835
+ }
2836
+ isPlaying() {
2837
+ return this.engine?.isPlaying() ?? false;
2838
+ }
2839
+ /**
2840
+ * In-tab "fullscreen" — covers the browser viewport via fixed
2841
+ * positioning, NOT the OS Fullscreen API. This is what the reference
2842
+ * UI calls "全屏预览": the user stays in their tab, no browser
2843
+ * permission prompt, ESC exits. Browser fullscreen would also work
2844
+ * but is heavier UX and gets blocked in iframes.
2845
+ */
2846
+ async enterFullscreen() {
2847
+ this.ui.setFullscreen(true);
2848
+ }
2849
+ async exitFullscreen() {
2850
+ this.ui.setFullscreen(false);
2851
+ }
2852
+ isFullscreen() {
2853
+ return this.ui.isFullscreen();
2854
+ }
2855
+ // ---- editing --------------------------------------------------------
2856
+ /**
2857
+ * Split the clip at `timeMs` (or playhead). Returns the two new clip ids
2858
+ * or null if there's no clip to split at that time.
2859
+ */
2860
+ split(timeMs) {
2861
+ const t = timeMs ?? this.engine.getTime();
2862
+ let target = null;
2863
+ if (this.selectedClipId) {
2864
+ const trk = findTrackOfClip(this.project, this.selectedClipId);
2865
+ const cl = trk?.clips.find((c) => c.id === this.selectedClipId) ?? null;
2866
+ if (trk && cl && t > cl.start && t < clipEnd(cl)) target = { track: trk, clip: cl };
2867
+ }
2868
+ if (!target) {
2869
+ for (const trk of this.project.tracks) {
2870
+ const cl = findClipContaining(trk, t);
2871
+ if (cl && t > cl.start && t < clipEnd(cl)) {
2872
+ target = { track: trk, clip: cl };
2873
+ break;
2874
+ }
2875
+ }
2876
+ }
2877
+ if (!target) return null;
2878
+ const split = splitClipAt(target.clip, t - target.clip.start);
2879
+ if (!split) return null;
2880
+ this.pushHistory();
2881
+ const [left, right] = split;
2882
+ target.track.clips = target.track.clips.filter((c) => c.id !== target.clip.id).concat(left, right).sort((a, b) => a.start - b.start);
2883
+ this.afterMutation();
2884
+ return [left.id, right.id];
2885
+ }
2886
+ cut(timeMs) {
2887
+ return this.split(timeMs);
2888
+ }
2889
+ trimLeft(timeMs) {
2890
+ const t = timeMs ?? this.engine.getTime();
2891
+ const target = this.resolveTrimTarget(t);
2892
+ if (!target) return false;
2893
+ const { clip } = target;
2894
+ const delta = t - clip.start;
2895
+ if (delta <= 0 || delta >= clipDuration(clip)) return false;
2896
+ this.pushHistory();
2897
+ const oldStart = clip.start;
2898
+ clip.in += delta;
2899
+ clip.start += delta;
2900
+ const gapStart = oldStart;
2901
+ const gapEnd = clip.start;
2902
+ let covered = false;
2903
+ outer: for (const trk of this.project.tracks) {
2904
+ for (const c of trk.clips) {
2905
+ if (c.id === clip.id) continue;
2906
+ const cEnd = c.start + clipDuration(c);
2907
+ if (c.start < gapEnd && cEnd > gapStart) {
2908
+ covered = true;
2909
+ break outer;
2910
+ }
2911
+ }
2912
+ }
2913
+ if (!covered) {
2914
+ clip.start = gapStart;
2915
+ for (const trk of this.project.tracks) {
2916
+ for (const c of trk.clips) {
2917
+ if (c.id === clip.id) continue;
2918
+ if (c.start >= gapEnd) c.start -= delta;
2919
+ }
2920
+ trk.clips.sort((a, b) => a.start - b.start);
2921
+ }
2922
+ }
2923
+ this.afterMutation();
2924
+ return true;
2925
+ }
2926
+ trimRight(timeMs) {
2927
+ const t = timeMs ?? this.engine.getTime();
2928
+ const target = this.resolveTrimTarget(t);
2929
+ if (!target) return false;
2930
+ const { clip } = target;
2931
+ const delta = clipEnd(clip) - t;
2932
+ if (delta <= 0 || delta >= clipDuration(clip)) return false;
2933
+ this.pushHistory();
2934
+ clip.out -= delta;
2935
+ this.afterMutation();
2936
+ return true;
2937
+ }
2938
+ removeClip(clipId) {
2939
+ let removed = false;
2940
+ const before = JSON.stringify(this.project);
2941
+ for (const t of this.project.tracks) {
2942
+ const len = t.clips.length;
2943
+ t.clips = t.clips.filter((c) => c.id !== clipId);
2944
+ if (t.clips.length !== len) removed = true;
2945
+ }
2946
+ if (!removed) return false;
2947
+ this.history.push(JSON.parse(before));
2948
+ if (this.selectedClipId === clipId) {
2949
+ this.selectedClipId = null;
2950
+ this.bus.emit("selectionChange", { clipId: null });
2951
+ }
2952
+ this.afterMutation();
2953
+ return true;
2954
+ }
2955
+ setClipSpeed(clipId, speed) {
2956
+ if (!Number.isFinite(speed) || speed <= 0) return false;
2957
+ const trk = findTrackOfClip(this.project, clipId);
2958
+ const cl = trk?.clips.find((c) => c.id === clipId);
2959
+ if (!trk || !cl) return false;
2960
+ this.pushHistory();
2961
+ cl.speed = speed === 1 ? void 0 : speed;
2962
+ this.afterMutation();
2963
+ return true;
2964
+ }
2965
+ // ---- tracks ---------------------------------------------------------
2966
+ addTrack(kind) {
2967
+ this.pushHistory();
2968
+ const t = { id: createId("track"), kind, clips: [] };
2969
+ this.project.tracks.push(t);
2970
+ this.afterMutation();
2971
+ return t;
2972
+ }
2973
+ removeTrack(trackId) {
2974
+ const idx = this.project.tracks.findIndex((t) => t.id === trackId);
2975
+ if (idx < 0) return false;
2976
+ this.pushHistory();
2977
+ this.project.tracks.splice(idx, 1);
2978
+ this.afterMutation();
2979
+ return true;
2980
+ }
2981
+ /**
2982
+ * Pure prediction of where a `moveClip(...)` would land — same smart
2983
+ * routing as the real move (intended → source → other → new track),
2984
+ * just no mutation, no history. Lets the Timeline preview the
2985
+ * ACTUAL outcome of a drop so the ghost stops lying about new
2986
+ * tracks that won't get created.
2987
+ */
2988
+ previewMoveTarget(clipId, start, intendedTrackId) {
2989
+ const fromTrack = findTrackOfClip(this.project, clipId);
2990
+ const clip = fromTrack?.clips.find((c) => c.id === clipId);
2991
+ if (!fromTrack || !clip) return null;
2992
+ const nextStart = Math.max(0, start);
2993
+ const nextEnd = nextStart + clipDuration(clip);
2994
+ const intended = intendedTrackId ? this.project.tracks.find((t) => t.id === intendedTrackId) : fromTrack;
2995
+ const candidates = [];
2996
+ const seen = /* @__PURE__ */ new Set();
2997
+ const push = (t) => {
2998
+ if (!t || seen.has(t.id)) return;
2999
+ seen.add(t.id);
3000
+ candidates.push(t);
3001
+ };
3002
+ push(intended);
3003
+ push(fromTrack);
3004
+ for (const t of this.project.tracks) {
3005
+ if (t.kind === fromTrack.kind) push(t);
3006
+ }
3007
+ for (const c of candidates) {
3008
+ if (!wouldOverlap(c, clipId, nextStart, nextEnd)) {
3009
+ const idx = this.project.tracks.indexOf(c);
3010
+ return { trackIndex: idx, trackId: c.id, wouldCreateNew: false };
3011
+ }
3012
+ }
3013
+ return {
3014
+ trackIndex: this.project.tracks.length,
3015
+ trackId: "",
3016
+ wouldCreateNew: true
3017
+ };
3018
+ }
3019
+ moveClip(clipId, opts) {
3020
+ const fromTrack = findTrackOfClip(this.project, clipId);
3021
+ const clip = fromTrack?.clips.find((c) => c.id === clipId);
3022
+ if (!fromTrack || !clip) return false;
3023
+ const nextStart = Math.max(0, opts.start ?? clip.start);
3024
+ const nextEnd = nextStart + clipDuration(clip);
3025
+ if (opts.newTrack) {
3026
+ this.pushHistory();
3027
+ const created = this.appendTrack({ kind: fromTrack.kind });
3028
+ clip.start = nextStart;
3029
+ fromTrack.clips = fromTrack.clips.filter((c) => c.id !== clipId);
3030
+ created.clips.push(clip);
3031
+ if (fromTrack.clips.length === 0 && this.project.tracks.filter((t) => t.kind === fromTrack.kind).length > 1) {
3032
+ this.project.tracks = this.project.tracks.filter(
3033
+ (t) => t.id !== fromTrack.id
3034
+ );
3035
+ }
3036
+ this.afterMutation();
3037
+ return true;
3038
+ }
3039
+ const intended = opts.trackId ? this.project.tracks.find((t) => t.id === opts.trackId) : fromTrack;
3040
+ if (!intended) return false;
3041
+ this.pushHistory();
3042
+ const candidates = [];
3043
+ const seen = /* @__PURE__ */ new Set();
3044
+ const push = (t) => {
3045
+ if (!t || seen.has(t.id)) return;
3046
+ seen.add(t.id);
3047
+ candidates.push(t);
3048
+ };
3049
+ push(intended);
3050
+ push(fromTrack);
3051
+ for (const t of this.project.tracks) {
3052
+ if (t.kind === fromTrack.kind) push(t);
3053
+ }
3054
+ let targetTrack = null;
3055
+ for (const c of candidates) {
3056
+ if (!wouldOverlap(c, clipId, nextStart, nextEnd)) {
3057
+ targetTrack = c;
3058
+ break;
3059
+ }
3060
+ }
3061
+ if (!targetTrack) {
3062
+ targetTrack = this.appendTrack({ kind: fromTrack.kind });
3063
+ }
3064
+ clip.start = nextStart;
3065
+ if (targetTrack !== fromTrack) {
3066
+ fromTrack.clips = fromTrack.clips.filter((c) => c.id !== clipId);
3067
+ targetTrack.clips.push(clip);
3068
+ }
3069
+ targetTrack.clips.sort((a, b) => a.start - b.start);
3070
+ if (fromTrack !== targetTrack && fromTrack.clips.length === 0 && this.project.tracks.filter((t) => t.kind === fromTrack.kind).length > 1) {
3071
+ this.project.tracks = this.project.tracks.filter(
3072
+ (t) => t.id !== fromTrack.id
3073
+ );
3074
+ }
3075
+ this.afterMutation();
3076
+ return true;
3077
+ }
3078
+ resizeClip(clipId, edits) {
3079
+ const trk = findTrackOfClip(this.project, clipId);
3080
+ const cl = trk?.clips.find((c) => c.id === clipId);
3081
+ if (!trk || !cl) return false;
3082
+ const next = { ...cl, ...edits };
3083
+ if (next.out <= next.in) return false;
3084
+ if (next.start < 0) return false;
3085
+ this.pushHistory();
3086
+ Object.assign(cl, next);
3087
+ trk.clips.sort((a, b) => a.start - b.start);
3088
+ this.afterMutation();
3089
+ return true;
3090
+ }
3091
+ addSource(source, opts = {}) {
3092
+ const src = { ...source, id: source.id || createId("src") };
3093
+ this.pushHistory();
3094
+ this.project.sources.push(src);
3095
+ const append = opts.appendClip !== false;
3096
+ if (append && src.kind === "video") {
3097
+ const track = this.project.tracks.find((t) => t.kind === "video") ?? this.appendTrack({ kind: "video" });
3098
+ const start = trackEnd(track);
3099
+ track.clips.push({
3100
+ id: createId("clip"),
3101
+ sourceId: src.id,
3102
+ in: 0,
3103
+ out: src.duration ?? 0,
3104
+ start
3105
+ });
3106
+ }
3107
+ this.afterMutation();
3108
+ return src;
3109
+ }
3110
+ // ---- project --------------------------------------------------------
3111
+ setProject(project) {
3112
+ this.pushHistory();
3113
+ this.project = normalizeProject(project);
3114
+ this.engine.setProject(this.project);
3115
+ this.bus.emit("change", { project: this.getProject() });
3116
+ this.emitHistory();
3117
+ this.ui.resetAutoFit();
3118
+ this.ui.render();
3119
+ }
3120
+ getProject() {
3121
+ return JSON.parse(JSON.stringify(this.project));
3122
+ }
3123
+ /**
3124
+ * Restore the "fresh import" state: same media library, single
3125
+ * default video track, one full-length clip per video source laid
3126
+ * end-to-end. This mirrors the initial layout a host would get
3127
+ * after dropping their videos in, so "reset" feels like "start
3128
+ * over without re-importing" rather than "wipe everything".
3129
+ *
3130
+ * Goes through the regular history stack — ⌘Z brings the previous
3131
+ * edit back. Sources without a known duration are skipped (they'd
3132
+ * render as zero-width clips, which is worse than absent).
3133
+ */
3134
+ reset() {
3135
+ const sources = this.project.sources.map((s) => ({ ...s }));
3136
+ const trackId = createId("track");
3137
+ const clips = [];
3138
+ let start = 0;
3139
+ for (const src of sources) {
3140
+ if (src.kind !== "video") continue;
3141
+ const dur = src.duration;
3142
+ if (!dur || dur <= 0) continue;
3143
+ clips.push({
3144
+ id: createId("clip"),
3145
+ sourceId: src.id,
3146
+ in: 0,
3147
+ out: dur,
3148
+ start
3149
+ });
3150
+ start += dur;
3151
+ }
3152
+ this.setProject({
3153
+ version: 1,
3154
+ sources,
3155
+ tracks: [{ id: trackId, kind: "video", clips }]
3156
+ });
3157
+ }
3158
+ setTheme(theme) {
3159
+ applyTheme(this.container, theme);
3160
+ this.ui.render();
3161
+ }
3162
+ setLocale(locale) {
3163
+ this.locale = mergeLocale(locale);
3164
+ this.ui.setLocale(this.locale);
3165
+ }
3166
+ /** Internal — UI reads the resolved locale here on each render. */
3167
+ getLocale() {
3168
+ return this.locale;
3169
+ }
3170
+ requestExport() {
3171
+ this.bus.emit("export", { project: this.getProject() });
3172
+ }
3173
+ // ---- viewport -------------------------------------------------------
3174
+ getScale() {
3175
+ return this.pxPerSec;
3176
+ }
3177
+ setScale(pxPerSec) {
3178
+ const next = clampScale2(pxPerSec);
3179
+ if (next === this.pxPerSec) return;
3180
+ this.pxPerSec = next;
3181
+ this.bus.emit("scaleChange", { pxPerSec: next });
3182
+ this.ui.render();
3183
+ }
3184
+ getSnap() {
3185
+ return this.snap;
3186
+ }
3187
+ setSnap(snap) {
3188
+ if (snap === this.snap) return;
3189
+ this.snap = snap;
3190
+ this.bus.emit("snapChange", { snap });
3191
+ this.ui.render();
3192
+ }
3193
+ /** Snap a candidate ms to the nearest snappable surface within SNAP_PX. */
3194
+ snapMs(timeMs, ignoreClipId) {
3195
+ if (!this.snap) return timeMs;
3196
+ const snapTol = Math.max(20, SNAP_PX2 / this.pxPerSec * 1e3);
3197
+ const targets = [0, this.engine.getTime()];
3198
+ for (const t of this.project.tracks) {
3199
+ for (const c of t.clips) {
3200
+ if (c.id === ignoreClipId) continue;
3201
+ targets.push(c.start, clipEnd(c));
3202
+ }
3203
+ }
3204
+ let best = timeMs;
3205
+ let bestDist = snapTol;
3206
+ for (const t of targets) {
3207
+ const d = Math.abs(t - timeMs);
3208
+ if (d < bestDist) {
3209
+ bestDist = d;
3210
+ best = t;
3211
+ }
3212
+ }
3213
+ return best;
3214
+ }
3215
+ // ---- selection ------------------------------------------------------
3216
+ getSelection() {
3217
+ return this.selectedClipId;
3218
+ }
3219
+ setSelection(clipId) {
3220
+ if (clipId === this.selectedClipId) return;
3221
+ this.selectedClipId = clipId;
3222
+ this.bus.emit("selectionChange", { clipId });
3223
+ this.ui.render();
3224
+ }
3225
+ // ---- history --------------------------------------------------------
3226
+ canUndo() {
3227
+ return this.history.canUndo();
3228
+ }
3229
+ canRedo() {
3230
+ return this.history.canRedo();
3231
+ }
3232
+ undo() {
3233
+ const prev = this.history.undo(this.project);
3234
+ if (!prev) return false;
3235
+ this.project = prev;
3236
+ this.engine.setProject(this.project);
3237
+ this.bus.emit("change", { project: this.getProject() });
3238
+ this.emitHistory();
3239
+ this.ui.render();
3240
+ return true;
3241
+ }
3242
+ redo() {
3243
+ const next = this.history.redo(this.project);
3244
+ if (!next) return false;
3245
+ this.project = next;
3246
+ this.engine.setProject(this.project);
3247
+ this.bus.emit("change", { project: this.getProject() });
3248
+ this.emitHistory();
3249
+ this.ui.render();
3250
+ return true;
3251
+ }
3252
+ // ---- events ---------------------------------------------------------
3253
+ on(event, handler) {
3254
+ return this.bus.on(event, handler);
3255
+ }
3256
+ off(event, handler) {
3257
+ this.bus.off(event, handler);
3258
+ }
3259
+ destroy() {
3260
+ if (this.destroyed) return;
3261
+ this.destroyed = true;
3262
+ this.engine.destroy();
3263
+ this.ui.destroy();
3264
+ this.bus.clear();
3265
+ this.history.clear();
3266
+ }
3267
+ // ---- internals ------------------------------------------------------
3268
+ appendTrack(opts) {
3269
+ const t = { id: createId("track"), kind: opts.kind, clips: [] };
3270
+ this.project.tracks.push(t);
3271
+ return t;
3272
+ }
3273
+ resolveTrimTarget(timeMs) {
3274
+ if (this.selectedClipId) {
3275
+ const trk = findTrackOfClip(this.project, this.selectedClipId);
3276
+ const cl = trk?.clips.find((c) => c.id === this.selectedClipId);
3277
+ if (trk && cl && timeMs >= cl.start && timeMs <= clipEnd(cl)) {
3278
+ return { track: trk, clip: cl };
3279
+ }
3280
+ }
3281
+ for (const trk of this.project.tracks) {
3282
+ const cl = findClipContaining(trk, timeMs);
3283
+ if (cl) return { track: trk, clip: cl };
3284
+ }
3285
+ return null;
3286
+ }
3287
+ pushHistory() {
3288
+ this.history.push(this.project);
3289
+ this.emitHistory();
3290
+ }
3291
+ emitHistory() {
3292
+ this.bus.emit("historyChange", {
3293
+ canUndo: this.history.canUndo(),
3294
+ canRedo: this.history.canRedo()
3295
+ });
3296
+ }
3297
+ afterMutation() {
3298
+ this.engine.setProject(this.project);
3299
+ this.bus.emit("change", { project: this.getProject() });
3300
+ this.ui.render();
3301
+ }
3302
+ handleSourceMetadata(sourceId, durMs) {
3303
+ const src = this.project.sources.find((s) => s.id === sourceId);
3304
+ let mutated = false;
3305
+ if (src && !src.duration) {
3306
+ src.duration = durMs;
3307
+ mutated = true;
3308
+ }
3309
+ for (const t of this.project.tracks) {
3310
+ for (const c of t.clips) {
3311
+ if (c.sourceId === sourceId && c.out === 0) {
3312
+ c.out = durMs;
3313
+ mutated = true;
3314
+ }
3315
+ }
3316
+ }
3317
+ this.bus.emit("ready", { sourceId });
3318
+ if (mutated) {
3319
+ this.engine.setProject(this.project);
3320
+ this.bus.emit("change", { project: this.getProject() });
3321
+ this.ui.render();
3322
+ }
3323
+ }
3324
+ };
3325
+ function clampScale2(s) {
3326
+ return Math.max(MIN_PX_PER_SEC, Math.min(MAX_PX_PER_SEC, s));
3327
+ }
3328
+
3329
+ export { Editor, HEADER_WIDTH, RULER_HEIGHT, TRACK_HEIGHT, Timeline, createEmptyProject, createId, formatLabel, localeEn, localeZh, mergeLocale, normalizeProject };
3330
+ //# sourceMappingURL=index.js.map
3331
+ //# sourceMappingURL=index.js.map