@aicut/core 0.4.3 → 0.6.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.cjs CHANGED
@@ -80,7 +80,110 @@ function createId(prefix = "id") {
80
80
  return `${prefix}_${t}${rand}`;
81
81
  }
82
82
 
83
+ // src/keyframes/types.ts
84
+ var IDENTITY_TRANSFORM = {
85
+ panX: 0,
86
+ panY: 0,
87
+ scale: 1
88
+ };
89
+ function isIdentityTransform(t) {
90
+ return Math.abs(t.panX) < 1e-3 && Math.abs(t.panY) < 1e-3 && Math.abs(t.scale - 1) < 1e-4;
91
+ }
92
+
93
+ // src/keyframes/interpolate.ts
94
+ function applyEasing(t, easing) {
95
+ switch (easing) {
96
+ case "linear":
97
+ return t;
98
+ case "easeIn":
99
+ return t * t * t;
100
+ case "easeOut": {
101
+ const u = 1 - t;
102
+ return 1 - u * u * u;
103
+ }
104
+ case "easeInOut":
105
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
106
+ }
107
+ }
108
+ function defaultFor(prop) {
109
+ return prop === "scale" ? 1 : 0;
110
+ }
111
+ function staticValue(clip, prop) {
112
+ const v = clip[prop];
113
+ return v ?? defaultFor(prop);
114
+ }
115
+ function keyframesForProp(kfs, prop) {
116
+ const out = [];
117
+ for (const k of kfs) if (k.prop === prop) out.push(k);
118
+ out.sort((a, b) => a.time - b.time);
119
+ return out;
120
+ }
121
+ function hasKeyframesForProp(clip, prop) {
122
+ return clip.keyframes?.some((k) => k.prop === prop) ?? false;
123
+ }
124
+ function interpolateProp(clip, prop, localMs) {
125
+ if (!clip.keyframes || clip.keyframes.length === 0) {
126
+ return staticValue(clip, prop);
127
+ }
128
+ const arr = keyframesForProp(clip.keyframes, prop);
129
+ if (arr.length === 0) return staticValue(clip, prop);
130
+ if (arr.length === 1) return arr[0].value;
131
+ const first = arr[0];
132
+ const last = arr[arr.length - 1];
133
+ if (localMs <= first.time) return first.value;
134
+ if (localMs >= last.time) return last.value;
135
+ for (let i = 0; i < arr.length - 1; i += 1) {
136
+ const a = arr[i];
137
+ const b = arr[i + 1];
138
+ if (localMs >= a.time && localMs <= b.time) {
139
+ if (b.time === a.time) return a.value;
140
+ const rawT = (localMs - a.time) / (b.time - a.time);
141
+ const eased = applyEasing(rawT, a.easing ?? "linear");
142
+ return a.value + (b.value - a.value) * eased;
143
+ }
144
+ }
145
+ return last.value;
146
+ }
147
+ function getEffectiveTransform(clip, localMs) {
148
+ if ((!clip.keyframes || clip.keyframes.length === 0) && clip.panX === void 0 && clip.panY === void 0 && clip.scale === void 0) {
149
+ return IDENTITY_TRANSFORM;
150
+ }
151
+ return {
152
+ panX: interpolateProp(clip, "panX", localMs),
153
+ panY: interpolateProp(clip, "panY", localMs),
154
+ scale: interpolateProp(clip, "scale", localMs)
155
+ };
156
+ }
157
+ function getTransformAtTimelineTime(clip, timelineMs) {
158
+ return getEffectiveTransform(clip, timelineMs - clip.start);
159
+ }
160
+ function upsertKeyframe(kfs, prop, time, value, idFactory) {
161
+ const existing = kfs ?? [];
162
+ const idx = existing.findIndex(
163
+ (k) => k.prop === prop && Math.abs(k.time - time) < 16
164
+ );
165
+ if (idx >= 0) {
166
+ const next = existing.slice();
167
+ next[idx] = { ...next[idx], value };
168
+ return next;
169
+ }
170
+ return [...existing, { id: idFactory(), prop, time, value }];
171
+ }
172
+
83
173
  // src/model.ts
174
+ var KEYFRAME_PROPS = ["panX", "panY", "scale"];
175
+ var DEFAULT_FPS = 30;
176
+ var BIG_FRAME_STEP = 10;
177
+ function projectFps(project) {
178
+ const f = project.fps;
179
+ return f != null && f > 0 ? f : DEFAULT_FPS;
180
+ }
181
+ function frameStepMs(project) {
182
+ return Math.max(1, Math.round(1e3 / projectFps(project)));
183
+ }
184
+ function bigFrameStepMs(project) {
185
+ return Math.max(1, Math.round(BIG_FRAME_STEP * 1e3 / projectFps(project)));
186
+ }
84
187
  function createEmptyProject() {
85
188
  return {
86
189
  version: 1,
@@ -117,11 +220,58 @@ function findTrackOfClip(project, clipId) {
117
220
  function normalizeProject(project) {
118
221
  const sources = project.sources.map((s) => ({ ...s }));
119
222
  const tracks = project.tracks.map((t) => {
120
- 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);
223
+ const clips = t.clips.filter((c) => c.out > c.in).map((c) => {
224
+ const out = { ...c, id: c.id || createId("clip") };
225
+ if (c.keyframes && c.keyframes.length > 0) {
226
+ const duration = c.out - c.in;
227
+ out.keyframes = migrateKeyframes(c.keyframes).filter((kf) => kf.time >= 0 && kf.time <= duration).map((kf) => ({ ...kf, id: kf.id || createId("kf") })).sort((a, b) => {
228
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
229
+ return a.time - b.time;
230
+ });
231
+ }
232
+ return out;
233
+ }).sort((a, b) => a.start - b.start);
121
234
  return { ...t, id: t.id || createId("track"), clips };
122
235
  });
123
236
  return { version: 1, sources, tracks };
124
237
  }
238
+ function migrateKeyframes(raw) {
239
+ const out = [];
240
+ for (const kf of raw) {
241
+ if ("prop" in kf && "value" in kf) {
242
+ out.push(kf);
243
+ continue;
244
+ }
245
+ const tuple = kf;
246
+ const id = tuple.id;
247
+ const t = tuple.time;
248
+ if (typeof tuple.x === "number") {
249
+ out.push({
250
+ id: id ? `${id}-px` : createId("kf"),
251
+ prop: "panX",
252
+ time: t,
253
+ value: tuple.x
254
+ });
255
+ }
256
+ if (typeof tuple.y === "number") {
257
+ out.push({
258
+ id: id ? `${id}-py` : createId("kf"),
259
+ prop: "panY",
260
+ time: t,
261
+ value: tuple.y
262
+ });
263
+ }
264
+ if (typeof tuple.scale === "number") {
265
+ out.push({
266
+ id: id ? `${id}-s` : createId("kf"),
267
+ prop: "scale",
268
+ time: t,
269
+ value: tuple.scale
270
+ });
271
+ }
272
+ }
273
+ return out;
274
+ }
125
275
  function splitClipAt(clip, localOffset) {
126
276
  const sourceLen = clip.out - clip.in;
127
277
  if (localOffset <= 0 || localOffset >= sourceLen) return null;
@@ -132,6 +282,47 @@ function splitClipAt(clip, localOffset) {
132
282
  in: clip.in + localOffset,
133
283
  start: clip.start + localOffset
134
284
  };
285
+ if (clip.keyframes && clip.keyframes.length > 0) {
286
+ const leftKf = [];
287
+ const rightKf = [];
288
+ for (const prop of KEYFRAME_PROPS) {
289
+ const propKfs = clip.keyframes.filter((k) => k.prop === prop);
290
+ if (propKfs.length === 0) continue;
291
+ const boundaryValue = interpolateProp(clip, prop, localOffset);
292
+ let leftSeamPresent = false;
293
+ let rightSeamPresent = false;
294
+ for (const kf of propKfs) {
295
+ if (kf.time < localOffset) {
296
+ leftKf.push(kf);
297
+ } else if (kf.time > localOffset) {
298
+ rightKf.push({ ...kf, id: createId("kf"), time: kf.time - localOffset });
299
+ } else {
300
+ leftKf.push(kf);
301
+ leftSeamPresent = true;
302
+ rightKf.push({ ...kf, id: createId("kf"), time: 0 });
303
+ rightSeamPresent = true;
304
+ }
305
+ }
306
+ if (!leftSeamPresent) {
307
+ leftKf.push({
308
+ id: createId("kf"),
309
+ prop,
310
+ time: localOffset,
311
+ value: boundaryValue
312
+ });
313
+ }
314
+ if (!rightSeamPresent) {
315
+ rightKf.push({
316
+ id: createId("kf"),
317
+ prop,
318
+ time: 0,
319
+ value: boundaryValue
320
+ });
321
+ }
322
+ }
323
+ left.keyframes = leftKf.length > 0 ? leftKf : void 0;
324
+ right.keyframes = rightKf.length > 0 ? rightKf : void 0;
325
+ }
135
326
  return [left, right];
136
327
  }
137
328
  function projectDuration(project) {
@@ -144,18 +335,31 @@ function projectDuration(project) {
144
335
  }
145
336
 
146
337
  // src/timeline/layout.ts
147
- var TRACK_HEIGHT = 56;
148
- var RULER_HEIGHT = 24;
338
+ exports.TRACK_HEIGHT = 56;
339
+ exports.RULER_HEIGHT = 24;
149
340
  var HEADER_WIDTH = 96;
150
341
  var HANDLE_PX = 8;
151
342
  var CLIP_INSET = 6;
152
343
  var SCALE_MIN = 10;
153
344
  var SCALE_MAX = 400;
345
+ var TIMELINE_PAD_LEFT = 12;
346
+ var TIMELINE_PAD_RIGHT = 12;
347
+ function contentLeftX(showHeader) {
348
+ return (showHeader ? HEADER_WIDTH : 0) + TIMELINE_PAD_LEFT;
349
+ }
350
+ function setTimelineMetrics(opts) {
351
+ if (opts.trackHeight != null && opts.trackHeight > 0) {
352
+ exports.TRACK_HEIGHT = Math.round(opts.trackHeight);
353
+ }
354
+ if (opts.rulerHeight != null && opts.rulerHeight > 0) {
355
+ exports.RULER_HEIGHT = Math.round(opts.rulerHeight);
356
+ }
357
+ }
154
358
  var SCROLLBAR_THICKNESS = 10;
155
359
  var SCROLLBAR_MIN_THUMB = 24;
156
360
  var SCROLLBAR_INSET = 2;
157
361
  function contentHeight(tracks, isDragging) {
158
- return tracks.length * TRACK_HEIGHT + (isDragging ? TRACK_HEIGHT : 0);
362
+ return tracks.length * exports.TRACK_HEIGHT + (isDragging ? exports.TRACK_HEIGHT : 0);
159
363
  }
160
364
  function contentWidth(project, pxPerSec) {
161
365
  let max = 0;
@@ -168,18 +372,20 @@ function contentWidth(project, pxPerSec) {
168
372
  return max / 1e3 * pxPerSec;
169
373
  }
170
374
  function trackY(index) {
171
- return RULER_HEIGHT + index * TRACK_HEIGHT;
375
+ return exports.RULER_HEIGHT + index * exports.TRACK_HEIGHT;
172
376
  }
173
377
  function trackIndexAt(y, trackCount, scrollTop = 0) {
174
- if (y < RULER_HEIGHT) return -1;
175
- const contentY = y - RULER_HEIGHT + scrollTop;
176
- const idx = Math.floor(contentY / TRACK_HEIGHT);
378
+ if (y < exports.RULER_HEIGHT) return -1;
379
+ const contentY = y - exports.RULER_HEIGHT + scrollTop;
380
+ const idx = Math.floor(contentY / exports.TRACK_HEIGHT);
177
381
  if (idx < 0 || idx >= trackCount) return -1;
178
382
  return idx;
179
383
  }
180
384
  function xToMs(x, pxPerSec, scrollLeft, showHeader) {
181
- const base = showHeader ? HEADER_WIDTH : 0;
182
- return Math.max(0, (x - base + scrollLeft) / pxPerSec * 1e3);
385
+ return Math.max(
386
+ 0,
387
+ (x - contentLeftX(showHeader) + scrollLeft) / pxPerSec * 1e3
388
+ );
183
389
  }
184
390
  function niceTickSeconds(targetSec) {
185
391
  if (targetSec <= 0) return 1;
@@ -207,6 +413,9 @@ function snapTargets(project, playheadMs, ignoreClipId) {
207
413
  for (const c of t.clips) {
208
414
  if (c.id === ignoreClipId) continue;
209
415
  arr.push(c.start, c.start + (c.out - c.in));
416
+ if (c.keyframes) {
417
+ for (const kf of c.keyframes) arr.push(c.start + kf.time);
418
+ }
210
419
  }
211
420
  }
212
421
  return arr;
@@ -253,30 +462,40 @@ function uncoveredIntervals(project) {
253
462
  return gaps;
254
463
  }
255
464
 
256
- // src/playback.ts
257
- var PlaybackEngine = class {
465
+ // src/playback/html-video.ts
466
+ var HtmlVideoEngine = class {
258
467
  host;
259
468
  mount;
260
- videos = /* @__PURE__ */ new Map();
469
+ sources = /* @__PURE__ */ new Map();
261
470
  project;
262
471
  currentClipId = null;
263
472
  playing = false;
264
473
  timeMs = 0;
265
474
  rafHandle = null;
266
475
  lastFrameTs = 0;
476
+ /** Permanent rAF that positions the active wrapper at the output
477
+ * rect + pushes keyframe transform onto the inner video via CSS. */
478
+ transformRaf = null;
267
479
  /** Public event hooks — set by Editor. */
268
480
  onTimeUpdate;
269
481
  onEnded;
270
482
  onError;
271
483
  onReady;
272
484
  onSourceMetadata;
273
- constructor(host, project) {
274
- this.host = host;
275
- this.project = project;
485
+ constructor(opts) {
486
+ this.host = opts.host;
487
+ this.project = opts.project;
276
488
  this.mount = document.createElement("div");
277
489
  this.mount.className = "aicut-preview";
490
+ Object.assign(this.mount.style, {
491
+ position: "absolute",
492
+ inset: "0",
493
+ width: "100%",
494
+ height: "100%"
495
+ });
278
496
  this.host.appendChild(this.mount);
279
497
  this.syncSources();
498
+ this.startTransformLoop();
280
499
  }
281
500
  setProject(next) {
282
501
  this.project = next;
@@ -299,9 +518,9 @@ var PlaybackEngine = class {
299
518
  if (this.timeMs < clip.start) this.timeMs = clip.start;
300
519
  this.activate(clip);
301
520
  this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
302
- const v = this.videos.get(clip.sourceId);
303
- if (!v) return;
304
- void v.play().catch((err) => this.onError?.(err));
521
+ const s = this.sources.get(clip.sourceId);
522
+ if (!s) return;
523
+ void s.video.play().catch((err) => this.onError?.(err));
305
524
  this.playing = true;
306
525
  this.startTickLoop();
307
526
  }
@@ -312,10 +531,414 @@ var PlaybackEngine = class {
312
531
  if (this.currentClipId) {
313
532
  const clip = this.clipById(this.currentClipId);
314
533
  if (clip) {
315
- const v = this.videos.get(clip.sourceId);
316
- v?.pause();
534
+ this.sources.get(clip.sourceId)?.video.pause();
535
+ }
536
+ }
537
+ }
538
+ isPlaying() {
539
+ return this.playing;
540
+ }
541
+ getTime() {
542
+ return this.timeMs;
543
+ }
544
+ seek(timeMs) {
545
+ const total = this.totalDuration();
546
+ if (total <= 0) {
547
+ this.timeMs = 0;
548
+ return;
549
+ }
550
+ const clamped = Math.max(0, Math.min(timeMs, total));
551
+ this.timeMs = clamped;
552
+ const clip = this.clipAtTime(clamped);
553
+ if (clip) {
554
+ this.activate(clip);
555
+ this.seekVideoToClipOffset(clip, clamped - clip.start);
556
+ } else {
557
+ this.activate(null);
558
+ }
559
+ this.onTimeUpdate?.(clamped);
560
+ }
561
+ /**
562
+ * The OUTPUT frame — the fixed stage the rendered video is clipped
563
+ * to. Independent of the keyframe transform. Used by the overlay to
564
+ * draw the dashed border at a stable position.
565
+ */
566
+ getOutputFrameRect() {
567
+ return this.baseFrameRect();
568
+ }
569
+ /**
570
+ * The CONTENT frame — where the transformed video pixels actually
571
+ * land. Equal to the output frame when transform is identity; may
572
+ * extend outside (zoom in) or fit inside (zoom out) when not.
573
+ */
574
+ getFrameRect() {
575
+ const base = this.baseFrameRect();
576
+ if (!base) return null;
577
+ const clip = this.clipById(this.currentClipId);
578
+ if (!clip) return base;
579
+ const t = getEffectiveTransform(clip, this.timeMs - clip.start);
580
+ const cx = base.x + base.w / 2 + t.panX;
581
+ const cy = base.y + base.h / 2 + t.panY;
582
+ const w = base.w * t.scale;
583
+ const h = base.h * t.scale;
584
+ return { x: cx - w / 2, y: cy - h / 2, w, h };
585
+ }
586
+ /** Untransformed contain-letterbox rect — the OUTPUT frame. */
587
+ baseFrameRect() {
588
+ if (!this.currentClipId) return null;
589
+ const clip = this.clipById(this.currentClipId);
590
+ if (!clip) return null;
591
+ const s = this.sources.get(clip.sourceId);
592
+ if (!s) return null;
593
+ const v = s.video;
594
+ if (v.videoWidth === 0 || v.videoHeight === 0) return null;
595
+ const hostRect = this.host.getBoundingClientRect();
596
+ const cw = hostRect.width;
597
+ const ch = hostRect.height;
598
+ if (cw === 0 || ch === 0) return null;
599
+ const scale = Math.min(cw / v.videoWidth, ch / v.videoHeight);
600
+ const w = v.videoWidth * scale;
601
+ const h = v.videoHeight * scale;
602
+ return { x: (cw - w) / 2, y: (ch - h) / 2, w, h };
603
+ }
604
+ /**
605
+ * Permanent rAF that (a) sizes + positions the active wrapper to
606
+ * the output frame, and (b) writes the keyframe transform onto the
607
+ * inner video. Negligible cost — three style writes per frame max.
608
+ */
609
+ startTransformLoop() {
610
+ const tick = () => {
611
+ this.applyTransforms();
612
+ this.transformRaf = requestAnimationFrame(tick);
613
+ };
614
+ this.transformRaf = requestAnimationFrame(tick);
615
+ }
616
+ applyTransforms() {
617
+ const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
618
+ const outRect = this.baseFrameRect();
619
+ if (clip && outRect) {
620
+ const s = this.sources.get(clip.sourceId);
621
+ if (s) {
622
+ Object.assign(s.wrapper.style, {
623
+ left: `${outRect.x}px`,
624
+ top: `${outRect.y}px`,
625
+ width: `${outRect.w}px`,
626
+ height: `${outRect.h}px`
627
+ });
628
+ const t = getEffectiveTransform(clip, this.timeMs - clip.start);
629
+ s.video.style.transform = `translate(${t.panX.toFixed(2)}px, ${t.panY.toFixed(2)}px) scale(${t.scale.toFixed(4)})`;
630
+ }
631
+ }
632
+ for (const [id, s] of this.sources) {
633
+ if (clip && id === clip.sourceId) continue;
634
+ if (s.video.style.transform) s.video.style.transform = "";
635
+ }
636
+ }
637
+ destroy() {
638
+ this.stopTickLoop();
639
+ if (this.transformRaf != null) {
640
+ cancelAnimationFrame(this.transformRaf);
641
+ this.transformRaf = null;
642
+ }
643
+ for (const s of this.sources.values()) {
644
+ s.video.pause();
645
+ s.video.removeAttribute("src");
646
+ s.video.load();
647
+ s.wrapper.remove();
648
+ }
649
+ this.sources.clear();
650
+ this.mount.remove();
651
+ }
652
+ // --- internals -------------------------------------------------------
653
+ syncSources() {
654
+ const wanted = new Set(this.project.sources.map((s) => s.id));
655
+ for (const [id, s] of this.sources) {
656
+ if (!wanted.has(id)) {
657
+ s.video.pause();
658
+ s.wrapper.remove();
659
+ this.sources.delete(id);
660
+ }
661
+ }
662
+ for (const src of this.project.sources) {
663
+ if (src.kind !== "video") continue;
664
+ if (this.sources.has(src.id)) continue;
665
+ const wrapper = document.createElement("div");
666
+ wrapper.className = "aicut-preview-clip";
667
+ Object.assign(wrapper.style, {
668
+ position: "absolute",
669
+ overflow: "hidden",
670
+ visibility: "hidden",
671
+ // Initial bounds — applyTransforms overrides each frame.
672
+ left: "0",
673
+ top: "0",
674
+ width: "0",
675
+ height: "0"
676
+ });
677
+ const v = document.createElement("video");
678
+ v.preload = "auto";
679
+ v.playsInline = true;
680
+ v.muted = false;
681
+ v.src = src.url;
682
+ Object.assign(v.style, {
683
+ position: "absolute",
684
+ inset: "0",
685
+ width: "100%",
686
+ height: "100%",
687
+ objectFit: "fill",
688
+ // Transform origin at center so scale() scales around the
689
+ // video's centroid, not its top-left corner.
690
+ transformOrigin: "50% 50%"
691
+ });
692
+ const sourceId = src.id;
693
+ v.addEventListener(
694
+ "error",
695
+ () => this.onError?.(new Error(`Failed to load ${src.url}`))
696
+ );
697
+ v.addEventListener("loadedmetadata", () => {
698
+ this.onReady?.();
699
+ const durMs = Math.round(v.duration * 1e3);
700
+ if (Number.isFinite(durMs) && durMs > 0) {
701
+ this.onSourceMetadata?.(sourceId, durMs);
702
+ }
703
+ });
704
+ wrapper.appendChild(v);
705
+ this.mount.appendChild(wrapper);
706
+ this.sources.set(src.id, { wrapper, video: v });
707
+ }
708
+ }
709
+ activate(clip) {
710
+ if (clip?.id === this.currentClipId) return;
711
+ if (this.currentClipId) {
712
+ const prev = this.clipById(this.currentClipId);
713
+ if (prev) {
714
+ const s = this.sources.get(prev.sourceId);
715
+ if (s) {
716
+ s.video.pause();
717
+ s.wrapper.style.visibility = "hidden";
718
+ }
719
+ }
720
+ }
721
+ this.currentClipId = clip ? clip.id : null;
722
+ if (clip) {
723
+ const s = this.sources.get(clip.sourceId);
724
+ if (s) s.wrapper.style.visibility = "visible";
725
+ }
726
+ }
727
+ seekVideoToClipOffset(clip, offsetMs) {
728
+ const s = this.sources.get(clip.sourceId);
729
+ if (!s) return;
730
+ const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
731
+ if (Math.abs(s.video.currentTime - target) > 0.05) {
732
+ s.video.currentTime = target;
733
+ }
734
+ }
735
+ clipById(id) {
736
+ for (const t of this.project.tracks) {
737
+ for (const c of t.clips) if (c.id === id) return c;
738
+ }
739
+ return null;
740
+ }
741
+ /**
742
+ * Find the clip whose timeline range contains `timeMs`, searching
743
+ * across ALL video tracks. If multiple tracks have a clip at this
744
+ * moment, the lowest-index track wins.
745
+ */
746
+ clipAtTime(timeMs) {
747
+ for (const t of this.project.tracks) {
748
+ if (t.kind !== "video") continue;
749
+ for (const c of t.clips) {
750
+ if (timeMs >= c.start && timeMs < c.start + (c.out - c.in)) return c;
751
+ }
752
+ }
753
+ return null;
754
+ }
755
+ /** Earliest clip starting at-or-after `timeMs` across all video tracks. */
756
+ nextClipAfterTime(timeMs) {
757
+ let best = null;
758
+ for (const t of this.project.tracks) {
759
+ if (t.kind !== "video") continue;
760
+ for (const c of t.clips) {
761
+ if (c.start >= timeMs && (!best || c.start < best.start)) best = c;
762
+ }
763
+ }
764
+ return best;
765
+ }
766
+ /** Max clip end across all video tracks. */
767
+ totalDuration() {
768
+ let max = 0;
769
+ for (const t of this.project.tracks) {
770
+ if (t.kind !== "video") continue;
771
+ for (const c of t.clips) {
772
+ const e = c.start + (c.out - c.in);
773
+ if (e > max) max = e;
317
774
  }
318
775
  }
776
+ return max;
777
+ }
778
+ startTickLoop() {
779
+ this.lastFrameTs = performance.now();
780
+ const tick = (now) => {
781
+ if (!this.playing) return;
782
+ const dtMs = now - this.lastFrameTs;
783
+ this.lastFrameTs = now;
784
+ this.advance(dtMs);
785
+ this.rafHandle = requestAnimationFrame(tick);
786
+ };
787
+ this.rafHandle = requestAnimationFrame(tick);
788
+ }
789
+ stopTickLoop() {
790
+ if (this.rafHandle != null) {
791
+ cancelAnimationFrame(this.rafHandle);
792
+ this.rafHandle = null;
793
+ }
794
+ }
795
+ advance(dtMs) {
796
+ if (this.project.tracks.length === 0) return;
797
+ this.timeMs += dtMs;
798
+ const totalDur = this.totalDuration();
799
+ if (this.timeMs >= totalDur) {
800
+ this.timeMs = totalDur;
801
+ this.onTimeUpdate?.(this.timeMs);
802
+ this.pause();
803
+ this.onEnded?.();
804
+ return;
805
+ }
806
+ const clip = this.clipAtTime(this.timeMs);
807
+ if (!clip) {
808
+ const next = this.nextClipAfterTime(this.timeMs);
809
+ if (next) {
810
+ this.timeMs = next.start;
811
+ this.activate(next);
812
+ this.seekVideoToClipOffset(next, 0);
813
+ const s = this.sources.get(next.sourceId);
814
+ if (s) void s.video.play().catch((err) => this.onError?.(err));
815
+ } else {
816
+ this.pause();
817
+ this.onEnded?.();
818
+ return;
819
+ }
820
+ } else if (clip.id !== this.currentClipId) {
821
+ this.activate(clip);
822
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
823
+ const s = this.sources.get(clip.sourceId);
824
+ if (s) void s.video.play().catch((err) => this.onError?.(err));
825
+ }
826
+ this.onTimeUpdate?.(this.timeMs);
827
+ }
828
+ };
829
+ var htmlVideoEngineFactory = (opts) => new HtmlVideoEngine(opts);
830
+
831
+ // src/playback/canvas-compositor.ts
832
+ var CanvasCompositorEngine = class {
833
+ host;
834
+ mount;
835
+ canvas;
836
+ ctx;
837
+ /** Only created when constructed with `debug: true`. */
838
+ badge = null;
839
+ videos = /* @__PURE__ */ new Map();
840
+ project;
841
+ currentClipId = null;
842
+ playing = false;
843
+ timeMs = 0;
844
+ rafHandle = null;
845
+ lastFrameTs = 0;
846
+ paintedFrames = 0;
847
+ /** Output frame rect (no transform) — fixed bounds. */
848
+ lastOutputRect = null;
849
+ /** Post-transform content rect. */
850
+ lastFrameRect = null;
851
+ onTimeUpdate;
852
+ onEnded;
853
+ onError;
854
+ onReady;
855
+ onSourceMetadata;
856
+ constructor(opts) {
857
+ this.host = opts.host;
858
+ this.project = opts.project;
859
+ this.mount = document.createElement("div");
860
+ this.mount.className = "aicut-preview aicut-preview--canvas";
861
+ Object.assign(this.mount.style, {
862
+ position: "absolute",
863
+ inset: "0",
864
+ width: "100%",
865
+ height: "100%"
866
+ });
867
+ this.canvas = document.createElement("canvas");
868
+ Object.assign(this.canvas.style, {
869
+ position: "absolute",
870
+ inset: "0",
871
+ width: "100%",
872
+ height: "100%",
873
+ // Stretch with letterboxing handled by the draw loop.
874
+ objectFit: "contain",
875
+ // Black until the first frame is drawn so the swap from the
876
+ // previous engine doesn't flash the host background.
877
+ background: "#000"
878
+ });
879
+ this.mount.appendChild(this.canvas);
880
+ const ctx = this.canvas.getContext("2d");
881
+ if (!ctx) throw new Error("CanvasCompositorEngine: 2d context unavailable");
882
+ this.ctx = ctx;
883
+ if (opts.debug) {
884
+ const badge = document.createElement("div");
885
+ badge.className = "aicut-preview__badge";
886
+ Object.assign(badge.style, {
887
+ position: "absolute",
888
+ top: "8px",
889
+ left: "8px",
890
+ padding: "4px 8px",
891
+ borderRadius: "6px",
892
+ background: "rgba(0, 0, 0, 0.55)",
893
+ color: "rgba(255, 255, 255, 0.92)",
894
+ font: "11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
895
+ pointerEvents: "none",
896
+ zIndex: "2",
897
+ letterSpacing: "0.02em"
898
+ });
899
+ badge.textContent = "engine: canvas compositor";
900
+ this.mount.appendChild(badge);
901
+ this.badge = badge;
902
+ }
903
+ this.host.appendChild(this.mount);
904
+ this.syncSources();
905
+ this.resizeCanvas();
906
+ this.startTickLoop();
907
+ }
908
+ setProject(next) {
909
+ this.project = next;
910
+ this.syncSources();
911
+ const clip = this.clipAtTime(this.timeMs);
912
+ if (!clip) {
913
+ this.timeMs = 0;
914
+ this.activate(null);
915
+ this.onTimeUpdate?.(0);
916
+ } else {
917
+ this.activate(clip);
918
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
919
+ }
920
+ }
921
+ play() {
922
+ if (this.playing) return;
923
+ if (this.totalDuration() <= 0) return;
924
+ const clip = this.clipAtTime(this.timeMs) ?? this.nextClipAfterTime(this.timeMs);
925
+ if (!clip) return;
926
+ if (this.timeMs < clip.start) this.timeMs = clip.start;
927
+ this.activate(clip);
928
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
929
+ const v = this.videos.get(clip.sourceId);
930
+ if (!v) return;
931
+ void v.play().catch((err) => this.onError?.(err));
932
+ this.playing = true;
933
+ this.lastFrameTs = performance.now();
934
+ }
935
+ pause() {
936
+ if (!this.playing) return;
937
+ this.playing = false;
938
+ if (this.currentClipId) {
939
+ const clip = this.clipById(this.currentClipId);
940
+ if (clip) this.videos.get(clip.sourceId)?.pause();
941
+ }
319
942
  }
320
943
  isPlaying() {
321
944
  return this.playing;
@@ -346,7 +969,6 @@ var PlaybackEngine = class {
346
969
  v.pause();
347
970
  v.removeAttribute("src");
348
971
  v.load();
349
- v.remove();
350
972
  }
351
973
  this.videos.clear();
352
974
  this.mount.remove();
@@ -357,7 +979,6 @@ var PlaybackEngine = class {
357
979
  for (const [id, v] of this.videos) {
358
980
  if (!wanted.has(id)) {
359
981
  v.pause();
360
- v.remove();
361
982
  this.videos.delete(id);
362
983
  }
363
984
  }
@@ -369,12 +990,6 @@ var PlaybackEngine = class {
369
990
  v.playsInline = true;
370
991
  v.muted = false;
371
992
  v.src = src.url;
372
- v.style.position = "absolute";
373
- v.style.inset = "0";
374
- v.style.width = "100%";
375
- v.style.height = "100%";
376
- v.style.objectFit = "contain";
377
- v.style.visibility = "hidden";
378
993
  const sourceId = src.id;
379
994
  v.addEventListener(
380
995
  "error",
@@ -387,7 +1002,6 @@ var PlaybackEngine = class {
387
1002
  this.onSourceMetadata?.(sourceId, durMs);
388
1003
  }
389
1004
  });
390
- this.mount.appendChild(v);
391
1005
  this.videos.set(src.id, v);
392
1006
  }
393
1007
  }
@@ -395,19 +1009,9 @@ var PlaybackEngine = class {
395
1009
  if (clip?.id === this.currentClipId) return;
396
1010
  if (this.currentClipId) {
397
1011
  const prev = this.clipById(this.currentClipId);
398
- if (prev) {
399
- const v = this.videos.get(prev.sourceId);
400
- if (v) {
401
- v.pause();
402
- v.style.visibility = "hidden";
403
- }
404
- }
1012
+ if (prev) this.videos.get(prev.sourceId)?.pause();
405
1013
  }
406
1014
  this.currentClipId = clip ? clip.id : null;
407
- if (clip) {
408
- const v = this.videos.get(clip.sourceId);
409
- if (v) v.style.visibility = "visible";
410
- }
411
1015
  }
412
1016
  seekVideoToClipOffset(clip, offsetMs) {
413
1017
  const v = this.videos.get(clip.sourceId);
@@ -423,14 +1027,6 @@ var PlaybackEngine = class {
423
1027
  }
424
1028
  return null;
425
1029
  }
426
- /**
427
- * Find the clip whose timeline range contains `timeMs`, searching
428
- * across ALL video tracks. If multiple tracks have a clip at this
429
- * moment, the lowest-index track wins (matches the "Track 1 is
430
- * background" convention used in the auto-split UX — overlapping
431
- * placements would have created a new track on top, but here we
432
- * fall back to the underlying clip).
433
- */
434
1030
  clipAtTime(timeMs) {
435
1031
  for (const t of this.project.tracks) {
436
1032
  if (t.kind !== "video") continue;
@@ -440,7 +1036,6 @@ var PlaybackEngine = class {
440
1036
  }
441
1037
  return null;
442
1038
  }
443
- /** Earliest clip starting at-or-after `timeMs` across all video tracks. */
444
1039
  nextClipAfterTime(timeMs) {
445
1040
  let best = null;
446
1041
  for (const t of this.project.tracks) {
@@ -451,7 +1046,6 @@ var PlaybackEngine = class {
451
1046
  }
452
1047
  return best;
453
1048
  }
454
- /** Max clip end across all video tracks. */
455
1049
  totalDuration() {
456
1050
  let max = 0;
457
1051
  for (const t of this.project.tracks) {
@@ -463,13 +1057,26 @@ var PlaybackEngine = class {
463
1057
  }
464
1058
  return max;
465
1059
  }
1060
+ resizeCanvas() {
1061
+ const rect = this.mount.getBoundingClientRect();
1062
+ const dpr = window.devicePixelRatio || 1;
1063
+ const w = Math.max(1, Math.floor(rect.width * dpr));
1064
+ const h = Math.max(1, Math.floor(rect.height * dpr));
1065
+ if (this.canvas.width !== w || this.canvas.height !== h) {
1066
+ this.canvas.width = w;
1067
+ this.canvas.height = h;
1068
+ }
1069
+ }
466
1070
  startTickLoop() {
467
1071
  this.lastFrameTs = performance.now();
468
1072
  const tick = (now) => {
469
- if (!this.playing) return;
470
- const dtMs = now - this.lastFrameTs;
471
- this.lastFrameTs = now;
472
- this.advance(dtMs);
1073
+ this.resizeCanvas();
1074
+ if (this.playing) {
1075
+ const dtMs = now - this.lastFrameTs;
1076
+ this.lastFrameTs = now;
1077
+ this.advance(dtMs);
1078
+ }
1079
+ this.paint();
473
1080
  this.rafHandle = requestAnimationFrame(tick);
474
1081
  };
475
1082
  this.rafHandle = requestAnimationFrame(tick);
@@ -513,7 +1120,74 @@ var PlaybackEngine = class {
513
1120
  }
514
1121
  this.onTimeUpdate?.(this.timeMs);
515
1122
  }
1123
+ /**
1124
+ * One paint per rAF — clears the canvas, draws the current active
1125
+ * video frame letterboxed to fit, then refreshes the HUD. Done
1126
+ * unconditionally (not just on `playing`) so the HUD frame counter
1127
+ * and the seek preview both update when paused.
1128
+ */
1129
+ paint() {
1130
+ const cw = this.canvas.width;
1131
+ const ch = this.canvas.height;
1132
+ this.ctx.clearRect(0, 0, cw, ch);
1133
+ const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
1134
+ const v = clip ? this.videos.get(clip.sourceId) : null;
1135
+ if (v && v.videoWidth > 0 && v.videoHeight > 0 && clip) {
1136
+ const vw = v.videoWidth;
1137
+ const vh = v.videoHeight;
1138
+ const baseScale = Math.min(cw / vw, ch / vh);
1139
+ const dw = vw * baseScale;
1140
+ const dh = vh * baseScale;
1141
+ const cx = cw / 2;
1142
+ const cy = ch / 2;
1143
+ const dpr = window.devicePixelRatio || 1;
1144
+ const t = getEffectiveTransform(clip, this.timeMs - clip.start);
1145
+ const outX = (cw - dw) / 2;
1146
+ const outY = (ch - dh) / 2;
1147
+ this.ctx.save();
1148
+ this.ctx.beginPath();
1149
+ this.ctx.rect(outX, outY, dw, dh);
1150
+ this.ctx.clip();
1151
+ this.ctx.translate(cx + t.panX * dpr, cy + t.panY * dpr);
1152
+ this.ctx.scale(t.scale, t.scale);
1153
+ this.ctx.drawImage(v, -dw / 2, -dh / 2, dw, dh);
1154
+ this.ctx.restore();
1155
+ this.paintedFrames += 1;
1156
+ this.lastOutputRect = {
1157
+ x: outX / dpr,
1158
+ y: outY / dpr,
1159
+ w: dw / dpr,
1160
+ h: dh / dpr
1161
+ };
1162
+ const cssCx = cw / (2 * dpr) + t.panX;
1163
+ const cssCy = ch / (2 * dpr) + t.panY;
1164
+ const cssW = dw * t.scale / dpr;
1165
+ const cssH = dh * t.scale / dpr;
1166
+ this.lastFrameRect = {
1167
+ x: cssCx - cssW / 2,
1168
+ y: cssCy - cssH / 2,
1169
+ w: cssW,
1170
+ h: cssH
1171
+ };
1172
+ } else {
1173
+ this.lastFrameRect = null;
1174
+ this.lastOutputRect = null;
1175
+ }
1176
+ this.updateBadge();
1177
+ }
1178
+ getOutputFrameRect() {
1179
+ return this.lastOutputRect;
1180
+ }
1181
+ getFrameRect() {
1182
+ return this.lastFrameRect;
1183
+ }
1184
+ updateBadge() {
1185
+ if (!this.badge) return;
1186
+ const sec = (this.timeMs / 1e3).toFixed(2);
1187
+ this.badge.textContent = `engine: canvas compositor \u2022 t=${sec}s \u2022 frames painted: ${this.paintedFrames}`;
1188
+ }
516
1189
  };
1190
+ var canvasCompositorEngineFactory = (opts) => new CanvasCompositorEngine(opts);
517
1191
 
518
1192
  // src/theme.ts
519
1193
  var THEME_VARS = {
@@ -548,12 +1222,11 @@ function applyTheme(root, theme) {
548
1222
 
549
1223
  // src/i18n.ts
550
1224
  var localeEn = {
551
- undo: "Undo",
552
- redo: "Redo",
553
- split: "Split",
554
- trimLeft: "Trim left edge",
555
- trimRight: "Trim right edge",
556
- speedComingSoon: "Speed (coming soon)",
1225
+ undo: "Undo (\u2318Z)",
1226
+ redo: "Redo (\u21E7\u2318Z)",
1227
+ split: "Split (K)",
1228
+ trimLeft: "Trim left edge (Q)",
1229
+ trimRight: "Trim right edge (W)",
557
1230
  playPause: "Play / Pause (Space)",
558
1231
  fullscreen: "Fullscreen preview",
559
1232
  snap: "Snap",
@@ -562,6 +1235,25 @@ var localeEn = {
562
1235
  zoomOut: "Zoom out",
563
1236
  zoomIn: "Zoom in",
564
1237
  reset: "Reset edits (keep sources)",
1238
+ keyframeAdd: "Add keyframe at playhead",
1239
+ keyframeRemove: "Remove keyframe at playhead",
1240
+ seekClipStart: "Jump to clip start (I)",
1241
+ seekClipEnd: "Jump to clip end (O)",
1242
+ keyframePanelTitle: "Keyframe",
1243
+ keyframePanelLabelX: "X",
1244
+ keyframePanelLabelY: "Y",
1245
+ keyframePanelLabelScale: "Scale",
1246
+ keyframePanelLabelEasing: "Easing",
1247
+ keyframePanelReset: "Reset to 0 0 1",
1248
+ keyframePanelResetTitle: "Pin this keyframe to identity (panX=0, panY=0, scale=1)",
1249
+ keyframePanelBadgePinned: "Pinned at this moment",
1250
+ keyframePanelBadgeAnimated: "Animated \u2014 but not pinned at this exact moment",
1251
+ keyframePanelBadgeStatic: "Static value",
1252
+ keyframePanelTimeSuffix: "s",
1253
+ keyframeEasingLinear: "Linear",
1254
+ keyframeEasingEaseIn: "Ease in",
1255
+ keyframeEasingEaseOut: "Ease out",
1256
+ keyframeEasingEaseInOut: "Ease in-out",
565
1257
  exitFullscreen: "Exit fullscreen",
566
1258
  exitFullscreenTitle: "Exit fullscreen (Esc)",
567
1259
  newTrack: "+ New track",
@@ -569,12 +1261,11 @@ var localeEn = {
569
1261
  audioTrackLabel: "Audio {n}"
570
1262
  };
571
1263
  var localeZh = {
572
- undo: "\u64A4\u9500",
573
- redo: "\u91CD\u505A",
574
- split: "\u5206\u5272",
575
- trimLeft: "\u5411\u5DE6\u88C1\u526A",
576
- trimRight: "\u5411\u53F3\u88C1\u526A",
577
- speedComingSoon: "\u53D8\u901F\uFF08\u5373\u5C06\u5230\u6765\uFF09",
1264
+ undo: "\u64A4\u9500 (\u2318Z)",
1265
+ redo: "\u91CD\u505A (\u21E7\u2318Z)",
1266
+ split: "\u5206\u5272 (K)",
1267
+ trimLeft: "\u5411\u5DE6\u88C1\u526A (Q)",
1268
+ trimRight: "\u5411\u53F3\u88C1\u526A (W)",
578
1269
  playPause: "\u64AD\u653E / \u6682\u505C (Space)",
579
1270
  fullscreen: "\u5168\u5C4F\u9884\u89C8",
580
1271
  snap: "\u5438\u9644",
@@ -583,6 +1274,25 @@ var localeZh = {
583
1274
  zoomOut: "\u7F29\u5C0F",
584
1275
  zoomIn: "\u653E\u5927",
585
1276
  reset: "\u91CD\u7F6E\u7F16\u8F91\uFF08\u4FDD\u7559\u89C6\u9891\u6E90\uFF09",
1277
+ keyframeAdd: "\u6DFB\u52A0\u5173\u952E\u5E27",
1278
+ keyframeRemove: "\u5220\u9664\u5F53\u524D\u5173\u952E\u5E27",
1279
+ seekClipStart: "\u8DF3\u5230\u7247\u6BB5\u8D77\u70B9 (I)",
1280
+ seekClipEnd: "\u8DF3\u5230\u7247\u6BB5\u672B\u5C3E (O)",
1281
+ keyframePanelTitle: "\u5173\u952E\u5E27",
1282
+ keyframePanelLabelX: "X \u4F4D\u79FB",
1283
+ keyframePanelLabelY: "Y \u4F4D\u79FB",
1284
+ keyframePanelLabelScale: "\u7F29\u653E",
1285
+ keyframePanelLabelEasing: "\u7F13\u52A8",
1286
+ keyframePanelReset: "\u91CD\u7F6E\u4E3A 0 0 1",
1287
+ keyframePanelResetTitle: "\u5C06\u8BE5\u5173\u952E\u5E27\u91CD\u7F6E\u4E3A\u521D\u59CB\u59FF\u6001\uFF08panX=0, panY=0, scale=1\uFF09",
1288
+ keyframePanelBadgePinned: "\u5DF2\u5728\u8BE5\u65F6\u523B\u56FA\u5B9A",
1289
+ keyframePanelBadgeAnimated: "\u6574\u6BB5\u6709\u52A8\u753B\uFF0C\u4F46\u5F53\u524D\u65F6\u523B\u6CA1\u6709\u9501\u70B9",
1290
+ keyframePanelBadgeStatic: "\u672A\u52A8\u753B\uFF08\u6CBF\u7528\u9759\u6001\u503C\uFF09",
1291
+ keyframePanelTimeSuffix: "\u79D2",
1292
+ keyframeEasingLinear: "\u7EBF\u6027",
1293
+ keyframeEasingEaseIn: "\u7F13\u5165",
1294
+ keyframeEasingEaseOut: "\u7F13\u51FA",
1295
+ keyframeEasingEaseInOut: "\u7F13\u5165\u7F13\u51FA",
586
1296
  exitFullscreen: "\u9000\u51FA\u5168\u5C4F",
587
1297
  exitFullscreenTitle: "\u9000\u51FA\u5168\u5C4F (Esc)",
588
1298
  newTrack: "+ \u65B0\u8F68\u9053",
@@ -657,11 +1367,19 @@ var ThumbnailRibbon = class {
657
1367
  /**
658
1368
  * Paint thumbnails for the clip's visible window onto `ctx`. The
659
1369
  * canvas is the per-clip strip — width = clip's px width, height =
660
- * THUMB_HEIGHT. Source-time range derives from the clip's `in/out`
661
- * and the px range we're drawing into.
1370
+ * `pxHeight` (defaults to the cached `THUMB_HEIGHT`). Source-time
1371
+ * range derives from the clip's `in/out` and the px range we're
1372
+ * drawing into.
1373
+ *
1374
+ * `pxHeight` lets the caller stretch thumbs to fill a taller clip
1375
+ * body when `trackHeight` is configured above the default. Aspect
1376
+ * ratio is already broken per-thumb (we slice variable widths from a
1377
+ * fixed-aspect cached bitmap), so stretching height too is fine — it
1378
+ * preserves the "filmstrip" look without leaving an empty bottom
1379
+ * band of the brand gradient showing through.
662
1380
  */
663
- paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth) {
664
- ctx.clearRect(0, 0, pxWidth, THUMB_HEIGHT);
1381
+ paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth, pxHeight = THUMB_HEIGHT) {
1382
+ ctx.clearRect(0, 0, pxWidth, pxHeight);
665
1383
  const st = this.sources.get(sourceId);
666
1384
  if (!st) return;
667
1385
  if (sourceOutMs <= sourceInMs || pxWidth <= 0) return;
@@ -674,10 +1392,10 @@ var ThumbnailRibbon = class {
674
1392
  const x = Math.round(i * pxWidth / count);
675
1393
  const w = Math.round((i + 1) * pxWidth / count) - x;
676
1394
  if (bmp) {
677
- ctx.drawImage(bmp, x, 0, w, THUMB_HEIGHT);
1395
+ ctx.drawImage(bmp, x, 0, w, pxHeight);
678
1396
  } else {
679
1397
  ctx.fillStyle = "rgba(255,255,255,0.04)";
680
- ctx.fillRect(x, 0, w, THUMB_HEIGHT);
1398
+ ctx.fillRect(x, 0, w, pxHeight);
681
1399
  this.enqueue(st, bucket);
682
1400
  }
683
1401
  }
@@ -768,12 +1486,12 @@ function drawAll(ctx, state, style, thumbs) {
768
1486
  const { viewportWidth: W, viewportHeight: H } = state;
769
1487
  ctx.fillStyle = style.bg;
770
1488
  ctx.fillRect(0, 0, W, H);
771
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1489
+ const baseX = contentLeftX(state.showHeader);
772
1490
  const trackAreaW = W - baseX - SCROLLBAR_THICKNESS;
773
- const trackAreaH = H - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1491
+ const trackAreaH = H - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
774
1492
  ctx.save();
775
1493
  ctx.beginPath();
776
- ctx.rect(baseX, RULER_HEIGHT, trackAreaW, trackAreaH);
1494
+ ctx.rect(baseX, exports.RULER_HEIGHT, trackAreaW, trackAreaH);
777
1495
  ctx.clip();
778
1496
  ctx.translate(0, -state.scrollTop);
779
1497
  drawTracks(ctx, state, style, thumbs);
@@ -785,7 +1503,7 @@ function drawAll(ctx, state, style, thumbs) {
785
1503
  if (state.showHeader) {
786
1504
  ctx.save();
787
1505
  ctx.beginPath();
788
- ctx.rect(0, RULER_HEIGHT, HEADER_WIDTH, trackAreaH);
1506
+ ctx.rect(0, exports.RULER_HEIGHT, HEADER_WIDTH, trackAreaH);
789
1507
  ctx.clip();
790
1508
  ctx.translate(0, -state.scrollTop);
791
1509
  drawHeaders(ctx, state, style);
@@ -793,7 +1511,7 @@ function drawAll(ctx, state, style, thumbs) {
793
1511
  }
794
1512
  ctx.save();
795
1513
  ctx.beginPath();
796
- ctx.rect(baseX, 0, trackAreaW, RULER_HEIGHT);
1514
+ ctx.rect(baseX, 0, trackAreaW, exports.RULER_HEIGHT);
797
1515
  ctx.clip();
798
1516
  drawRuler(ctx, state, style);
799
1517
  ctx.restore();
@@ -808,16 +1526,22 @@ function drawAll(ctx, state, style, thumbs) {
808
1526
  ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
809
1527
  ctx.clip();
810
1528
  drawSnapGuide(ctx, state, style);
811
- drawPlayhead(ctx, state, style);
812
1529
  ctx.restore();
813
1530
  drawScrollbarV(ctx, state, style);
814
1531
  drawScrollbarH(ctx, state, style);
1532
+ const playheadLeft = state.showHeader ? HEADER_WIDTH : 0;
1533
+ ctx.save();
1534
+ ctx.beginPath();
1535
+ ctx.rect(playheadLeft, 0, W - playheadLeft, H);
1536
+ ctx.clip();
1537
+ drawPlayhead(ctx, state, style);
1538
+ ctx.restore();
815
1539
  }
816
1540
  function drawCoverageGaps(ctx, state, style) {
817
1541
  const gaps = uncoveredIntervals(state.project);
818
1542
  if (gaps.length === 0) return;
819
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
820
- const trackStackH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1543
+ const baseX = contentLeftX(state.showHeader);
1544
+ const trackStackH = state.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
821
1545
  for (const [s, e] of gaps) {
822
1546
  const x1 = Math.max(
823
1547
  baseX,
@@ -829,16 +1553,16 @@ function drawCoverageGaps(ctx, state, style) {
829
1553
  );
830
1554
  if (x2 <= x1) continue;
831
1555
  ctx.fillStyle = "rgba(250, 167, 0, 0.35)";
832
- ctx.fillRect(x1, 0, x2 - x1, RULER_HEIGHT);
1556
+ ctx.fillRect(x1, 0, x2 - x1, exports.RULER_HEIGHT);
833
1557
  ctx.fillStyle = "rgba(250, 167, 0, 0.12)";
834
- ctx.fillRect(x1, RULER_HEIGHT, x2 - x1, trackStackH);
1558
+ ctx.fillRect(x1, exports.RULER_HEIGHT, x2 - x1, trackStackH);
835
1559
  ctx.save();
836
1560
  ctx.strokeStyle = "rgba(250, 167, 0, 0.6)";
837
1561
  ctx.lineWidth = 1;
838
1562
  ctx.beginPath();
839
1563
  for (let hx = Math.floor(x1); hx < x2; hx += 6) {
840
- ctx.moveTo(hx, RULER_HEIGHT - 1);
841
- ctx.lineTo(hx + 6, RULER_HEIGHT - 7);
1564
+ ctx.moveTo(hx, exports.RULER_HEIGHT - 1);
1565
+ ctx.lineTo(hx + 6, exports.RULER_HEIGHT - 7);
842
1566
  }
843
1567
  ctx.stroke();
844
1568
  ctx.restore();
@@ -855,7 +1579,7 @@ function drawDragGhost(ctx, state, style, thumbs) {
855
1579
  }
856
1580
  }
857
1581
  if (!real) return;
858
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1582
+ const baseX = contentLeftX(state.showHeader);
859
1583
  const widthPx = Math.max(2, (real.out - real.in) / 1e3 * state.pxPerSec);
860
1584
  const startX = baseX + ghost.ghostStart / 1e3 * state.pxPerSec - state.scrollLeft;
861
1585
  const overlap = ghost.wouldOverlap;
@@ -887,7 +1611,7 @@ function drawDragGhost(ctx, state, style, thumbs) {
887
1611
  }
888
1612
  function drawDropOutline(ctx, startX, trackIndex, widthPx, color, emphasized) {
889
1613
  const y = trackY(trackIndex) + CLIP_INSET - 1;
890
- const h = TRACK_HEIGHT - CLIP_INSET * 2 + 2;
1614
+ const h = exports.TRACK_HEIGHT - CLIP_INSET * 2 + 2;
891
1615
  ctx.save();
892
1616
  if (emphasized) {
893
1617
  ctx.shadowColor = withAlpha(color, 0.45);
@@ -904,36 +1628,36 @@ function drawPhantomRow(ctx, trackIndex, baseX, state, style) {
904
1628
  const w = state.viewportWidth - baseX;
905
1629
  ctx.save();
906
1630
  ctx.fillStyle = withAlpha(style.info, 0.04);
907
- ctx.fillRect(baseX, y, w, TRACK_HEIGHT);
1631
+ ctx.fillRect(baseX, y, w, exports.TRACK_HEIGHT);
908
1632
  ctx.strokeStyle = withAlpha(style.info, 0.35);
909
1633
  ctx.lineWidth = 1;
910
1634
  ctx.setLineDash([3, 4]);
911
1635
  ctx.beginPath();
912
1636
  ctx.moveTo(baseX, y + 0.5);
913
1637
  ctx.lineTo(baseX + w, y + 0.5);
914
- ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
915
- ctx.lineTo(baseX + w, y + TRACK_HEIGHT - 0.5);
1638
+ ctx.moveTo(baseX, y + exports.TRACK_HEIGHT - 0.5);
1639
+ ctx.lineTo(baseX + w, y + exports.TRACK_HEIGHT - 0.5);
916
1640
  ctx.stroke();
917
1641
  ctx.setLineDash([]);
918
1642
  if (state.showHeader) {
919
1643
  ctx.fillStyle = withAlpha(style.info, 0.7);
920
1644
  ctx.font = "10px system-ui, -apple-system, sans-serif";
921
1645
  ctx.textBaseline = "middle";
922
- ctx.fillText(state.locale.newTrack, 12, y + TRACK_HEIGHT / 2);
1646
+ ctx.fillText(state.locale.newTrack, 12, y + exports.TRACK_HEIGHT / 2);
923
1647
  }
924
1648
  ctx.restore();
925
1649
  }
926
1650
  function drawRuler(ctx, state, style) {
927
1651
  const { pxPerSec, scrollLeft, viewportWidth: W } = state;
928
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1652
+ const baseX = contentLeftX(state.showHeader);
929
1653
  const rulerW = W - baseX;
930
1654
  ctx.fillStyle = style.bg;
931
- ctx.fillRect(baseX, 0, rulerW, RULER_HEIGHT);
1655
+ ctx.fillRect(baseX, 0, rulerW, exports.RULER_HEIGHT);
932
1656
  ctx.strokeStyle = style.border;
933
1657
  ctx.lineWidth = 1;
934
1658
  ctx.beginPath();
935
- ctx.moveTo(baseX, RULER_HEIGHT - 0.5);
936
- ctx.lineTo(W, RULER_HEIGHT - 0.5);
1659
+ ctx.moveTo(baseX, exports.RULER_HEIGHT - 0.5);
1660
+ ctx.lineTo(W, exports.RULER_HEIGHT - 0.5);
937
1661
  ctx.stroke();
938
1662
  const minPx = 80;
939
1663
  const tickSec = niceTickSeconds(minPx / pxPerSec);
@@ -954,12 +1678,12 @@ function drawRuler(ctx, state, style) {
954
1678
  ctx.lineWidth = 1;
955
1679
  const h = isMajor ? 10 : 6;
956
1680
  ctx.beginPath();
957
- ctx.moveTo(x + 0.5, RULER_HEIGHT - h);
958
- ctx.lineTo(x + 0.5, RULER_HEIGHT - 1);
1681
+ ctx.moveTo(x + 0.5, exports.RULER_HEIGHT - h);
1682
+ ctx.lineTo(x + 0.5, exports.RULER_HEIGHT - 1);
959
1683
  ctx.stroke();
960
1684
  if (isMajor) {
961
1685
  ctx.fillStyle = withAlpha(style.textMuted, 0.85);
962
- ctx.fillText(formatRulerLabel(s), x + 3, RULER_HEIGHT - 12);
1686
+ ctx.fillText(formatRulerLabel(s), x + 3, exports.RULER_HEIGHT - 12);
963
1687
  }
964
1688
  }
965
1689
  }
@@ -971,15 +1695,15 @@ function drawTracks(ctx, state, style, thumbs) {
971
1695
  }
972
1696
  function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
973
1697
  const { viewportWidth: W } = state;
974
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1698
+ const baseX = contentLeftX(state.showHeader);
975
1699
  const y = trackY(trackIndex);
976
1700
  ctx.fillStyle = style.trackBg;
977
- ctx.fillRect(baseX, y, W - baseX, TRACK_HEIGHT);
1701
+ ctx.fillRect(baseX, y, W - baseX, exports.TRACK_HEIGHT);
978
1702
  ctx.strokeStyle = style.border;
979
1703
  ctx.lineWidth = 1;
980
1704
  ctx.beginPath();
981
- ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
982
- ctx.lineTo(W, y + TRACK_HEIGHT - 0.5);
1705
+ ctx.moveTo(baseX, y + exports.TRACK_HEIGHT - 0.5);
1706
+ ctx.lineTo(W, y + exports.TRACK_HEIGHT - 0.5);
983
1707
  ctx.stroke();
984
1708
  if (state.dropTargetTrackIndex === trackIndex) {
985
1709
  ctx.strokeStyle = withAlpha(style.info, 0.45);
@@ -987,8 +1711,8 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
987
1711
  ctx.beginPath();
988
1712
  ctx.moveTo(baseX, y + 0.5);
989
1713
  ctx.lineTo(W, y + 0.5);
990
- ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
991
- ctx.lineTo(W, y + TRACK_HEIGHT - 0.5);
1714
+ ctx.moveTo(baseX, y + exports.TRACK_HEIGHT - 0.5);
1715
+ ctx.lineTo(W, y + exports.TRACK_HEIGHT - 0.5);
992
1716
  ctx.stroke();
993
1717
  }
994
1718
  for (const clip of track.clips) {
@@ -1009,11 +1733,11 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
1009
1733
  }
1010
1734
  function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumbs, dim, warn) {
1011
1735
  const { pxPerSec, scrollLeft } = state;
1012
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1736
+ const baseX = contentLeftX(state.showHeader);
1013
1737
  const startX = baseX + startMs / 1e3 * pxPerSec - scrollLeft;
1014
1738
  const widthPx = Math.max(2, (clip.out - clip.in) / 1e3 * pxPerSec);
1015
1739
  const y = trackY(trackIndex) + CLIP_INSET;
1016
- const h = TRACK_HEIGHT - CLIP_INSET * 2;
1740
+ const h = exports.TRACK_HEIGHT - CLIP_INSET * 2;
1017
1741
  if (startX + widthPx < baseX || startX > state.viewportWidth) return;
1018
1742
  ctx.save();
1019
1743
  if (dim) ctx.globalAlpha = 0.3;
@@ -1032,7 +1756,7 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
1032
1756
  roundRect(ctx, startX, y, widthPx, h, 6);
1033
1757
  ctx.clip();
1034
1758
  ctx.translate(startX, y);
1035
- thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx);
1759
+ thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx, h);
1036
1760
  ctx.restore();
1037
1761
  ctx.strokeStyle = "rgba(255,255,255,0.2)";
1038
1762
  ctx.lineWidth = 1;
@@ -1058,6 +1782,34 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
1058
1782
  ctx.fillRect(startX + 2, y + 12, 2, h - 24);
1059
1783
  ctx.fillRect(startX + widthPx - 4, y + 12, 2, h - 24);
1060
1784
  }
1785
+ if (!dim && state.keyframesEnabled && clip.keyframes && clip.keyframes.length > 0) {
1786
+ const diamondY = y + h / 2;
1787
+ const halfSize = 5;
1788
+ const moments = groupKeyframesByTime(clip.keyframes, 16);
1789
+ const ghost = state.keyframeDragGhost;
1790
+ for (const moment of moments) {
1791
+ const draggedHere = ghost ? moment.kfs.find(
1792
+ (k) => ghost.clipId === clip.id && ghost.keyframeId === k.id
1793
+ ) : void 0;
1794
+ const effectiveTime = draggedHere ? ghost.ghostTimeMs : moment.time;
1795
+ const kfX = startX + effectiveTime / 1e3 * pxPerSec;
1796
+ if (kfX < baseX - halfSize || kfX > state.viewportWidth + halfSize) continue;
1797
+ const isSelected = state.selectedKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.selectedKeyframe?.keyframeId);
1798
+ const isHovered = state.hoveredKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.hoveredKeyframe?.keyframeId);
1799
+ const drawSize = isHovered ? halfSize + 1.5 : halfSize;
1800
+ ctx.beginPath();
1801
+ ctx.moveTo(kfX, diamondY - drawSize);
1802
+ ctx.lineTo(kfX + drawSize, diamondY);
1803
+ ctx.lineTo(kfX, diamondY + drawSize);
1804
+ ctx.lineTo(kfX - drawSize, diamondY);
1805
+ ctx.closePath();
1806
+ ctx.fillStyle = isSelected ? style.selectedRing : isHovered ? "#ffffff" : withAlpha(style.text, 0.85);
1807
+ ctx.fill();
1808
+ ctx.strokeStyle = isHovered ? style.selectedRing : "rgba(0, 0, 0, 0.65)";
1809
+ ctx.lineWidth = isHovered ? 1.5 : 1;
1810
+ ctx.stroke();
1811
+ }
1812
+ }
1061
1813
  ctx.restore();
1062
1814
  }
1063
1815
  function drawHeaders(ctx, state, style) {
@@ -1076,18 +1828,18 @@ function drawHeaders(ctx, state, style) {
1076
1828
  const y = trackY(i);
1077
1829
  ctx.strokeStyle = style.border;
1078
1830
  ctx.beginPath();
1079
- ctx.moveTo(0, y + TRACK_HEIGHT - 0.5);
1080
- ctx.lineTo(HEADER_WIDTH, y + TRACK_HEIGHT - 0.5);
1831
+ ctx.moveTo(0, y + exports.TRACK_HEIGHT - 0.5);
1832
+ ctx.lineTo(HEADER_WIDTH, y + exports.TRACK_HEIGHT - 0.5);
1081
1833
  ctx.stroke();
1082
1834
  ctx.fillStyle = withAlpha(style.text, 0.7);
1083
1835
  const template = t.kind === "video" ? state.locale.videoTrackLabel : state.locale.audioTrackLabel;
1084
1836
  const label = formatLabel(template, { n: i + 1 });
1085
- ctx.fillText(label, 12, y + TRACK_HEIGHT / 2);
1837
+ ctx.fillText(label, 12, y + exports.TRACK_HEIGHT / 2);
1086
1838
  if (t.clips.length === 0) {
1087
1839
  const hovered = state.hoveredTrackIndex === i;
1088
1840
  const btnSize = 18;
1089
1841
  const btnLeft = HEADER_WIDTH - btnSize - 6;
1090
- const btnTop = y + (TRACK_HEIGHT - btnSize) / 2;
1842
+ const btnTop = y + (exports.TRACK_HEIGHT - btnSize) / 2;
1091
1843
  ctx.save();
1092
1844
  if (hovered) {
1093
1845
  ctx.fillStyle = withAlpha(style.text, 0.1);
@@ -1108,7 +1860,7 @@ function drawHeaders(ctx, state, style) {
1108
1860
  }
1109
1861
  }
1110
1862
  function drawPlayhead(ctx, state, style) {
1111
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1863
+ const baseX = contentLeftX(state.showHeader);
1112
1864
  const x = baseX + state.timeMs / 1e3 * state.pxPerSec - state.scrollLeft;
1113
1865
  if (x < baseX - 2 || x > state.viewportWidth + 2) return;
1114
1866
  ctx.strokeStyle = style.playhead;
@@ -1122,7 +1874,9 @@ function drawPlayhead(ctx, state, style) {
1122
1874
  const padX = 6;
1123
1875
  const w = ctx.measureText(label).width + padX * 2;
1124
1876
  const h = 14;
1125
- const bx = x - w / 2;
1877
+ const contentRight = state.viewportWidth - SCROLLBAR_THICKNESS;
1878
+ const rawBx = x - w / 2;
1879
+ const bx = Math.max(baseX, Math.min(contentRight - w, rawBx));
1126
1880
  const by = 2;
1127
1881
  ctx.fillStyle = style.playhead;
1128
1882
  roundRect(ctx, bx, by, w, h, 4);
@@ -1150,11 +1904,11 @@ function drawSnapGuide(ctx, state, style) {
1150
1904
  }
1151
1905
  function drawScrollbarV(ctx, state, style) {
1152
1906
  if (state.scrollbarOpacityY <= 0.01) return;
1153
- const visibleH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1907
+ const visibleH = state.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
1154
1908
  const contentH = contentHeight(state.project.tracks, state.isDragging);
1155
1909
  if (contentH <= visibleH) return;
1156
1910
  const trackX = state.viewportWidth - SCROLLBAR_THICKNESS + SCROLLBAR_INSET;
1157
- const trackY0 = RULER_HEIGHT + SCROLLBAR_INSET;
1911
+ const trackY0 = exports.RULER_HEIGHT + SCROLLBAR_INSET;
1158
1912
  const trackLen = visibleH - SCROLLBAR_INSET * 2;
1159
1913
  const thumbLen = Math.max(
1160
1914
  SCROLLBAR_MIN_THUMB,
@@ -1178,7 +1932,7 @@ function drawScrollbarV(ctx, state, style) {
1178
1932
  }
1179
1933
  function drawScrollbarH(ctx, state, style) {
1180
1934
  if (state.scrollbarOpacityX <= 0.01) return;
1181
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1935
+ const baseX = contentLeftX(state.showHeader);
1182
1936
  const visibleW = state.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1183
1937
  const contentW = contentWidth(state.project, state.pxPerSec);
1184
1938
  if (contentW <= visibleW) return;
@@ -1261,21 +2015,35 @@ function parseColor(s) {
1261
2015
  if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
1262
2016
  return null;
1263
2017
  }
2018
+ function groupKeyframesByTime(kfs, epsilonMs) {
2019
+ const sorted = [...kfs].sort((a, b) => a.time - b.time);
2020
+ const out = [];
2021
+ for (const k of sorted) {
2022
+ const last = out[out.length - 1];
2023
+ if (last && Math.abs(k.time - last.time) < epsilonMs) {
2024
+ last.kfs.push(k);
2025
+ } else {
2026
+ out.push({ time: k.time, kfs: [k] });
2027
+ }
2028
+ }
2029
+ return out;
2030
+ }
1264
2031
 
1265
2032
  // src/timeline/hit.ts
2033
+ var KEYFRAME_HIT_RADIUS = 8;
1266
2034
  function hitTest(x, y, ctx) {
1267
2035
  if (y < 0 || x < 0) return { kind: "outside" };
1268
- const baseX = ctx.showHeader ? HEADER_WIDTH : 0;
1269
- const visibleH = ctx.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
2036
+ const baseX = contentLeftX(ctx.showHeader);
2037
+ const visibleH = ctx.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
1270
2038
  const contentH = contentHeight(ctx.project.tracks, ctx.isDragging);
1271
- if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
2039
+ if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= exports.RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
1272
2040
  const trackLen = visibleH - SCROLLBAR_INSET * 2;
1273
2041
  const thumbLen = Math.max(
1274
2042
  SCROLLBAR_MIN_THUMB,
1275
2043
  trackLen * (visibleH / contentH)
1276
2044
  );
1277
2045
  const maxScroll = contentH - visibleH;
1278
- const thumbY = RULER_HEIGHT + SCROLLBAR_INSET + (maxScroll > 0 ? ctx.scrollTop / maxScroll * (trackLen - thumbLen) : 0);
2046
+ const thumbY = exports.RULER_HEIGHT + SCROLLBAR_INSET + (maxScroll > 0 ? ctx.scrollTop / maxScroll * (trackLen - thumbLen) : 0);
1279
2047
  if (y >= thumbY && y <= thumbY + thumbLen) {
1280
2048
  return { kind: "scrollbar-thumb-v", thumbY, thumbLen };
1281
2049
  }
@@ -1296,14 +2064,14 @@ function hitTest(x, y, ctx) {
1296
2064
  }
1297
2065
  return { kind: "scrollbar-track-h", before: x < thumbX };
1298
2066
  }
1299
- if (ctx.showHeader && x < HEADER_WIDTH && y >= RULER_HEIGHT) {
2067
+ if (ctx.showHeader && x < HEADER_WIDTH && y >= exports.RULER_HEIGHT) {
1300
2068
  const ti2 = trackIndexAt(y, ctx.project.tracks.length, ctx.scrollTop);
1301
2069
  if (ti2 >= 0) {
1302
2070
  const track2 = ctx.project.tracks[ti2];
1303
2071
  if (track2.clips.length === 0) {
1304
2072
  const btnSize = 18;
1305
2073
  const btnLeft = HEADER_WIDTH - btnSize - 6;
1306
- const btnTop = RULER_HEIGHT + ti2 * 56 + (56 - btnSize) / 2 - ctx.scrollTop;
2074
+ const btnTop = exports.RULER_HEIGHT + ti2 * exports.TRACK_HEIGHT + (exports.TRACK_HEIGHT - btnSize) / 2 - ctx.scrollTop;
1307
2075
  if (x >= btnLeft && x <= btnLeft + btnSize && y >= btnTop && y <= btnTop + btnSize) {
1308
2076
  return { kind: "header-delete", trackIndex: ti2 };
1309
2077
  }
@@ -1312,11 +2080,28 @@ function hitTest(x, y, ctx) {
1312
2080
  }
1313
2081
  return { kind: "outside" };
1314
2082
  }
1315
- if (y < RULER_HEIGHT) return { kind: "ruler" };
2083
+ if (y < exports.RULER_HEIGHT) return { kind: "ruler" };
1316
2084
  const ti = trackIndexAt(y, ctx.project.tracks.length, ctx.scrollTop);
1317
2085
  if (ti < 0) return { kind: "outside" };
1318
2086
  const track = ctx.project.tracks[ti];
1319
2087
  const ms = xToMs(x, ctx.pxPerSec, ctx.scrollLeft, ctx.showHeader);
2088
+ if (ctx.keyframesEnabled) {
2089
+ for (const clip of track.clips) {
2090
+ if (!clip.keyframes || clip.keyframes.length === 0) continue;
2091
+ const startX = msToXLocal(clip.start, ctx);
2092
+ for (const kf of clip.keyframes) {
2093
+ const kfX = startX + kf.time / 1e3 * ctx.pxPerSec;
2094
+ if (Math.abs(x - kfX) <= KEYFRAME_HIT_RADIUS) {
2095
+ return {
2096
+ kind: "keyframe",
2097
+ trackIndex: ti,
2098
+ clipId: clip.id,
2099
+ keyframeId: kf.id
2100
+ };
2101
+ }
2102
+ }
2103
+ }
2104
+ }
1320
2105
  for (const clip of track.clips) {
1321
2106
  const start = clip.start;
1322
2107
  const end = clip.start + (clip.out - clip.in);
@@ -1335,8 +2120,7 @@ function hitTest(x, y, ctx) {
1335
2120
  return { kind: "track-empty", trackIndex: ti };
1336
2121
  }
1337
2122
  function msToXLocal(ms, ctx) {
1338
- const base = ctx.showHeader ? HEADER_WIDTH : 0;
1339
- return base + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
2123
+ return contentLeftX(ctx.showHeader) + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
1340
2124
  }
1341
2125
 
1342
2126
  // src/timeline/index.ts
@@ -1370,6 +2154,8 @@ var Timeline = class _Timeline {
1370
2154
  readOnly;
1371
2155
  autoFitEnabled;
1372
2156
  locale;
2157
+ keyframesEnabled = false;
2158
+ selectedKeyframe = null;
1373
2159
  scrollLeft = 0;
1374
2160
  scrollTop = 0;
1375
2161
  viewportWidth = 0;
@@ -1388,6 +2174,7 @@ var Timeline = class _Timeline {
1388
2174
  scrollbarDrag = null;
1389
2175
  hoveredClipId = null;
1390
2176
  hoveredTrackIndex = null;
2177
+ hoveredKeyframe = null;
1391
2178
  hoverCursor = "default";
1392
2179
  dropTargetTrackIndex = null;
1393
2180
  snapX = null;
@@ -1426,6 +2213,8 @@ var Timeline = class _Timeline {
1426
2213
  this.readOnly = opts.readOnly === true;
1427
2214
  this.autoFitEnabled = opts.autoFit !== false;
1428
2215
  this.locale = mergeLocale(opts.locale);
2216
+ this.keyframesEnabled = opts.keyframesEnabled === true;
2217
+ this.selectedKeyframe = opts.selectedKeyframe ?? null;
1429
2218
  this.root.classList.add("aicut-timeline-canvas");
1430
2219
  this.root.innerHTML = "";
1431
2220
  this.root.style.position = this.root.style.position || "relative";
@@ -1473,6 +2262,7 @@ var Timeline = class _Timeline {
1473
2262
  this.thumbs.syncSources(this.project.sources);
1474
2263
  this.attachPointer();
1475
2264
  this.attachWheel();
2265
+ this.attachKeyboard();
1476
2266
  this.attachResize();
1477
2267
  this.resizeCanvas();
1478
2268
  this.scheduleRender();
@@ -1553,21 +2343,21 @@ var Timeline = class _Timeline {
1553
2343
  * Exposed publicly so React/Vue wrappers can forward it to a ref.
1554
2344
  */
1555
2345
  getDebugInfo() {
1556
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2346
+ const baseX = contentLeftX(this.showHeader);
1557
2347
  const clips = [];
1558
2348
  for (let ti = 0; ti < this.project.tracks.length; ti++) {
1559
2349
  const t = this.project.tracks[ti];
1560
2350
  for (const c of t.clips) {
1561
2351
  const x = baseX + c.start / 1e3 * this.pxPerSec - this.scrollLeft;
1562
2352
  const width = (c.out - c.in) / 1e3 * this.pxPerSec;
1563
- const y = RULER_HEIGHT + ti * TRACK_HEIGHT + 6;
2353
+ const y = exports.RULER_HEIGHT + ti * exports.TRACK_HEIGHT + 6;
1564
2354
  clips.push({
1565
2355
  id: c.id,
1566
2356
  trackIndex: ti,
1567
2357
  x,
1568
2358
  width,
1569
2359
  y,
1570
- height: TRACK_HEIGHT - 12
2360
+ height: exports.TRACK_HEIGHT - 12
1571
2361
  });
1572
2362
  }
1573
2363
  }
@@ -1597,30 +2387,29 @@ var Timeline = class _Timeline {
1597
2387
  const rect = this.canvas.getBoundingClientRect();
1598
2388
  this.viewportWidth = Math.max(1, Math.floor(rect.width));
1599
2389
  this.viewportHeight = Math.max(
1600
- Math.floor(rect.height) || RULER_HEIGHT + TRACK_HEIGHT + SCROLLBAR_THICKNESS,
1601
- RULER_HEIGHT + TRACK_HEIGHT + SCROLLBAR_THICKNESS
2390
+ Math.floor(rect.height) || exports.RULER_HEIGHT + exports.TRACK_HEIGHT + SCROLLBAR_THICKNESS,
2391
+ exports.RULER_HEIGHT + exports.TRACK_HEIGHT + SCROLLBAR_THICKNESS
1602
2392
  );
1603
2393
  const dpr = window.devicePixelRatio || 1;
1604
2394
  this.canvas.width = Math.floor(this.viewportWidth * dpr);
1605
2395
  this.canvas.height = Math.floor(this.viewportHeight * dpr);
1606
- this.canvas.style.height = `${this.viewportHeight}px`;
1607
2396
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1608
2397
  }
1609
2398
  computeFitScale() {
1610
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2399
+ const baseX = contentLeftX(this.showHeader);
1611
2400
  const w = this.viewportWidth - baseX - 24;
1612
2401
  const dur = projectDuration(this.project);
1613
2402
  if (w <= 0 || dur <= 0) return null;
1614
2403
  return clampScale(w / (dur / 1e3));
1615
2404
  }
1616
2405
  maxScrollLeft() {
1617
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2406
+ const baseX = contentLeftX(this.showHeader);
1618
2407
  const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1619
2408
  const cw = contentWidth(this.project, this.pxPerSec);
1620
- return Math.max(0, cw - visibleW + 24);
2409
+ return Math.max(0, cw - visibleW + TIMELINE_PAD_RIGHT);
1621
2410
  }
1622
2411
  maxScrollTop() {
1623
- const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
2412
+ const visibleH = this.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
1624
2413
  const ch = contentHeight(this.project.tracks, this.drag?.kind === "move");
1625
2414
  return Math.max(0, ch - visibleH);
1626
2415
  }
@@ -1719,9 +2508,36 @@ var Timeline = class _Timeline {
1719
2508
  scrollbarOpacityX: this.scrollbarOpacity("h"),
1720
2509
  scrollbarActiveY: this.scrollbarDrag?.axis === "v",
1721
2510
  scrollbarActiveX: this.scrollbarDrag?.axis === "h",
1722
- locale: this.locale
2511
+ locale: this.locale,
2512
+ keyframesEnabled: this.keyframesEnabled,
2513
+ selectedKeyframe: this.selectedKeyframe,
2514
+ hoveredKeyframe: this.hoveredKeyframe,
2515
+ keyframeDragGhost: this.drag?.kind === "keyframe-drag" ? {
2516
+ clipId: this.drag.clipId,
2517
+ keyframeId: this.drag.keyframeId,
2518
+ ghostTimeMs: this.drag.ghostTimeMs
2519
+ } : null
1723
2520
  };
1724
2521
  }
2522
+ /** Host-pushed state — Editor calls this when its keyframe mode
2523
+ * changes or when a keyframe is selected/deselected externally. */
2524
+ setKeyframeState(state) {
2525
+ let dirty = false;
2526
+ if (state.enabled !== void 0 && state.enabled !== this.keyframesEnabled) {
2527
+ this.keyframesEnabled = state.enabled;
2528
+ dirty = true;
2529
+ }
2530
+ if (state.selected !== void 0) {
2531
+ const a = this.selectedKeyframe;
2532
+ const b = state.selected;
2533
+ const same = a?.clipId === b?.clipId && a?.keyframeId === b?.keyframeId;
2534
+ if (!same) {
2535
+ this.selectedKeyframe = b;
2536
+ dirty = true;
2537
+ }
2538
+ }
2539
+ if (dirty) this.scheduleRender();
2540
+ }
1725
2541
  readStyle() {
1726
2542
  const cs = getComputedStyle(this.root);
1727
2543
  const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback;
@@ -1750,6 +2566,7 @@ var Timeline = class _Timeline {
1750
2566
  this.canvas.addEventListener("pointerleave", () => {
1751
2567
  if (!this.drag && !this.scrollbarDrag) {
1752
2568
  this.hoveredClipId = null;
2569
+ this.hoveredKeyframe = null;
1753
2570
  this.hoverCursor = "default";
1754
2571
  this.hoverScrollbarY = false;
1755
2572
  this.hoverScrollbarX = false;
@@ -1781,8 +2598,8 @@ var Timeline = class _Timeline {
1781
2598
  }
1782
2599
  if (target.kind === "scrollbar-track-v") {
1783
2600
  const page = Math.max(
1784
- TRACK_HEIGHT,
1785
- this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS
2601
+ exports.TRACK_HEIGHT,
2602
+ this.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS
1786
2603
  );
1787
2604
  this.scrollTop += target.before ? -page : page;
1788
2605
  this.clampScroll();
@@ -1790,7 +2607,7 @@ var Timeline = class _Timeline {
1790
2607
  return;
1791
2608
  }
1792
2609
  if (target.kind === "scrollbar-track-h") {
1793
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2610
+ const baseX = contentLeftX(this.showHeader);
1794
2611
  const page = Math.max(
1795
2612
  80,
1796
2613
  this.viewportWidth - baseX - SCROLLBAR_THICKNESS
@@ -1830,6 +2647,33 @@ var Timeline = class _Timeline {
1830
2647
  this.scheduleRender();
1831
2648
  return;
1832
2649
  }
2650
+ if (target.kind === "keyframe") {
2651
+ const found = findClip(this.project, target.clipId);
2652
+ const kf = found?.clip.keyframes?.find((k) => k.id === target.keyframeId);
2653
+ if (!found || !kf) return;
2654
+ this.selectedKeyframe = {
2655
+ clipId: target.clipId,
2656
+ keyframeId: target.keyframeId
2657
+ };
2658
+ this.opts.onSelectKeyframe?.({
2659
+ clipId: target.clipId,
2660
+ keyframeId: target.keyframeId
2661
+ });
2662
+ const absMs = found.clip.start + kf.time;
2663
+ this.timeMs = absMs;
2664
+ this.opts.onSeek?.(absMs);
2665
+ this.drag = {
2666
+ kind: "keyframe-drag",
2667
+ clipId: target.clipId,
2668
+ keyframeId: target.keyframeId,
2669
+ trackIndex: target.trackIndex,
2670
+ pointerStartX: x,
2671
+ originalTimeMs: kf.time,
2672
+ ghostTimeMs: kf.time
2673
+ };
2674
+ this.scheduleRender();
2675
+ return;
2676
+ }
1833
2677
  if (target.kind === "clip") {
1834
2678
  const found = findClip(this.project, target.clipId);
1835
2679
  if (!found) return;
@@ -1881,7 +2725,7 @@ var Timeline = class _Timeline {
1881
2725
  const { x, y } = this.localCoords(e);
1882
2726
  if (this.scrollbarDrag) {
1883
2727
  if (this.scrollbarDrag.axis === "v") {
1884
- const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
2728
+ const visibleH = this.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
1885
2729
  const contentH = contentHeight(
1886
2730
  this.project.tracks,
1887
2731
  this.drag?.kind === "move"
@@ -1896,7 +2740,7 @@ var Timeline = class _Timeline {
1896
2740
  const ratio = maxScroll / free;
1897
2741
  this.scrollTop = this.scrollbarDrag.scrollStart + (y - this.scrollbarDrag.pointerStart) * ratio;
1898
2742
  } else {
1899
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2743
+ const baseX = contentLeftX(this.showHeader);
1900
2744
  const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1901
2745
  const contentW = contentWidth(this.project, this.pxPerSec);
1902
2746
  const trackLen = visibleW - SCROLLBAR_INSET * 2;
@@ -1920,10 +2764,19 @@ var Timeline = class _Timeline {
1920
2764
  let cursor = "default";
1921
2765
  let onScrollbarV = false;
1922
2766
  let onScrollbarH = false;
2767
+ let nextHoverKeyframe = null;
1923
2768
  if (target.kind === "clip") {
1924
2769
  nextHover = target.clipId;
1925
2770
  nextHoverTrack = target.trackIndex;
1926
2771
  cursor = this.readOnly ? "pointer" : "grab";
2772
+ } else if (target.kind === "keyframe") {
2773
+ nextHover = target.clipId;
2774
+ nextHoverTrack = target.trackIndex;
2775
+ nextHoverKeyframe = {
2776
+ clipId: target.clipId,
2777
+ keyframeId: target.keyframeId
2778
+ };
2779
+ cursor = "pointer";
1927
2780
  } else if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
1928
2781
  nextHover = target.clipId;
1929
2782
  nextHoverTrack = target.trackIndex;
@@ -1947,12 +2800,14 @@ var Timeline = class _Timeline {
1947
2800
  cursor = "default";
1948
2801
  }
1949
2802
  const hoverChanged = onScrollbarV !== this.hoverScrollbarY || onScrollbarH !== this.hoverScrollbarX;
1950
- if (nextHover !== this.hoveredClipId || nextHoverTrack !== this.hoveredTrackIndex || cursor !== this.hoverCursor || hoverChanged) {
2803
+ const kfHoverChanged = (nextHoverKeyframe?.clipId ?? null) !== (this.hoveredKeyframe?.clipId ?? null) || (nextHoverKeyframe?.keyframeId ?? null) !== (this.hoveredKeyframe?.keyframeId ?? null);
2804
+ if (nextHover !== this.hoveredClipId || nextHoverTrack !== this.hoveredTrackIndex || cursor !== this.hoverCursor || hoverChanged || kfHoverChanged) {
1951
2805
  this.hoveredClipId = nextHover;
1952
2806
  this.hoveredTrackIndex = nextHoverTrack;
1953
2807
  this.hoverCursor = cursor;
1954
2808
  this.hoverScrollbarY = onScrollbarV;
1955
2809
  this.hoverScrollbarX = onScrollbarH;
2810
+ this.hoveredKeyframe = nextHoverKeyframe;
1956
2811
  this.scheduleRender();
1957
2812
  }
1958
2813
  return;
@@ -1975,6 +2830,26 @@ var Timeline = class _Timeline {
1975
2830
  this.maybeStartDragAutoScroll();
1976
2831
  return;
1977
2832
  }
2833
+ if (this.drag.kind === "keyframe-drag") {
2834
+ const found = findClip(this.project, this.drag.clipId);
2835
+ if (!found) return;
2836
+ const clip = found.clip;
2837
+ const duration = clip.out - clip.in;
2838
+ const dxPx = x - this.drag.pointerStartX;
2839
+ const dxMs = dxPx / this.pxPerSec * 1e3;
2840
+ const nextLocal = Math.max(
2841
+ 0,
2842
+ Math.min(duration, this.drag.originalTimeMs + dxMs)
2843
+ );
2844
+ const snappedAbs = this.applySnap(clip.start + nextLocal, null);
2845
+ const snappedLocal = Math.max(
2846
+ 0,
2847
+ Math.min(duration, snappedAbs - clip.start)
2848
+ );
2849
+ this.drag.ghostTimeMs = Math.round(snappedLocal);
2850
+ this.scheduleRender();
2851
+ return;
2852
+ }
1978
2853
  if (this.drag.kind === "trim-left" || this.drag.kind === "trim-right") {
1979
2854
  const dxPx = x - this.drag.pointerStartX;
1980
2855
  const dxMs = dxPx / this.pxPerSec * 1e3;
@@ -2019,8 +2894,9 @@ var Timeline = class _Timeline {
2019
2894
  nextStart = this.applySnap(nextStart, drag.clipId);
2020
2895
  const tiRaw = this.trackIndexAtY(y);
2021
2896
  const phantomIdx = this.project.tracks.length;
2022
- const phantomScreenY = RULER_HEIGHT + phantomIdx * TRACK_HEIGHT - this.scrollTop;
2023
- const onPhantom = y >= phantomScreenY && y < phantomScreenY + TRACK_HEIGHT;
2897
+ const phantomScreenY = exports.RULER_HEIGHT + phantomIdx * exports.TRACK_HEIGHT - this.scrollTop;
2898
+ const viewportBottom = this.viewportHeight - SCROLLBAR_THICKNESS;
2899
+ const onPhantom = y >= phantomScreenY && y < Math.max(phantomScreenY + exports.TRACK_HEIGHT, viewportBottom);
2024
2900
  const intendedTrackIndex = onPhantom ? phantomIdx : tiRaw >= 0 ? tiRaw : drag.trackIndex;
2025
2901
  let ghostTrackIndex = intendedTrackIndex;
2026
2902
  let overlap = false;
@@ -2058,7 +2934,7 @@ var Timeline = class _Timeline {
2058
2934
  dragScrollSpeedY() {
2059
2935
  if (!this.drag || this.drag.kind !== "move") return 0;
2060
2936
  const y = this.lastDragPointerY;
2061
- const top = RULER_HEIGHT;
2937
+ const top = exports.RULER_HEIGHT;
2062
2938
  const bottom = this.viewportHeight - SCROLLBAR_THICKNESS;
2063
2939
  const zone = 36;
2064
2940
  const maxSpeed = 16;
@@ -2126,6 +3002,14 @@ var Timeline = class _Timeline {
2126
3002
  });
2127
3003
  this.opts.onChange?.(this.getProject());
2128
3004
  }
3005
+ } else if (drag.kind === "keyframe-drag") {
3006
+ if (drag.ghostTimeMs !== drag.originalTimeMs) {
3007
+ this.opts.onMoveKeyframe?.(
3008
+ drag.clipId,
3009
+ drag.keyframeId,
3010
+ drag.ghostTimeMs
3011
+ );
3012
+ }
2129
3013
  } else if (drag.kind === "trim-left" || drag.kind === "trim-right") {
2130
3014
  const found = findClip(this.project, drag.clipId);
2131
3015
  if (found) {
@@ -2139,6 +3023,25 @@ var Timeline = class _Timeline {
2139
3023
  }
2140
3024
  this.scheduleRender();
2141
3025
  }
3026
+ attachKeyboard() {
3027
+ this.canvas.tabIndex = -1;
3028
+ this.canvas.style.outline = "none";
3029
+ this.canvas.addEventListener("keydown", (e) => {
3030
+ if (e.code !== "ArrowLeft" && e.code !== "ArrowRight") return;
3031
+ e.preventDefault();
3032
+ const step = e.shiftKey ? bigFrameStepMs(this.project) : frameStepMs(this.project);
3033
+ const dir = e.code === "ArrowLeft" ? -1 : 1;
3034
+ const dur = projectDuration(this.project);
3035
+ const next = Math.max(0, Math.min(dur, this.timeMs + dir * step));
3036
+ if (next === this.timeMs) return;
3037
+ this.timeMs = next;
3038
+ this.opts.onSeek?.(next);
3039
+ this.scheduleRender();
3040
+ });
3041
+ this.canvas.addEventListener("pointerdown", () => {
3042
+ if (document.activeElement !== this.canvas) this.canvas.focus();
3043
+ });
3044
+ }
2142
3045
  attachWheel() {
2143
3046
  this.canvas.addEventListener(
2144
3047
  "wheel",
@@ -2159,7 +3062,7 @@ var Timeline = class _Timeline {
2159
3062
  if (Math.abs(next - this.pxPerSec) < 0.01) return;
2160
3063
  this.pxPerSec = next;
2161
3064
  this.hasAutoFitted = true;
2162
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
3065
+ const baseX = contentLeftX(this.showHeader);
2163
3066
  this.scrollLeft = anchorMs / 1e3 * this.pxPerSec - (cursorX - baseX);
2164
3067
  this.clampScroll();
2165
3068
  this.touchScrollbar("h");
@@ -2193,64 +3096,768 @@ var Timeline = class _Timeline {
2193
3096
  { passive: false }
2194
3097
  );
2195
3098
  }
2196
- attachResize() {
2197
- if (typeof ResizeObserver === "undefined") return;
2198
- this.resizeObs = new ResizeObserver(() => {
2199
- this.resizeCanvas();
2200
- if (!this.hasAutoFitted && this.autoFitEnabled) {
2201
- const fit = this.computeFitScale();
2202
- if (fit != null) {
2203
- this.pxPerSec = fit;
2204
- this.opts.onScaleChange?.(fit);
2205
- }
2206
- }
2207
- this.scheduleRender();
3099
+ attachResize() {
3100
+ if (typeof ResizeObserver === "undefined") return;
3101
+ this.resizeObs = new ResizeObserver(() => {
3102
+ this.resizeCanvas();
3103
+ if (!this.hasAutoFitted && this.autoFitEnabled) {
3104
+ const fit = this.computeFitScale();
3105
+ if (fit != null) {
3106
+ this.pxPerSec = fit;
3107
+ this.opts.onScaleChange?.(fit);
3108
+ }
3109
+ }
3110
+ this.scheduleRender();
3111
+ });
3112
+ this.resizeObs.observe(this.root);
3113
+ }
3114
+ // ---- helpers --------------------------------------------------------
3115
+ localCoords(e) {
3116
+ const rect = this.canvas.getBoundingClientRect();
3117
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
3118
+ }
3119
+ hitTarget(x, y) {
3120
+ return hitTest(x, y, {
3121
+ project: this.project,
3122
+ pxPerSec: this.pxPerSec,
3123
+ scrollLeft: this.scrollLeft,
3124
+ scrollTop: this.scrollTop,
3125
+ showHeader: this.showHeader,
3126
+ viewportWidth: this.viewportWidth,
3127
+ viewportHeight: this.viewportHeight,
3128
+ isDragging: this.drag?.kind === "move",
3129
+ keyframesEnabled: this.keyframesEnabled
3130
+ });
3131
+ }
3132
+ trackIndexAtY(y) {
3133
+ return trackIndexAt(y, this.project.tracks.length, this.scrollTop);
3134
+ }
3135
+ applySnap(ms, ignoreClipId) {
3136
+ if (!this.snapEnabled) {
3137
+ this.snapX = null;
3138
+ return ms;
3139
+ }
3140
+ const tolMs = Math.max(20, SNAP_PX / this.pxPerSec * 1e3);
3141
+ const targets = snapTargets(this.project, this.timeMs, ignoreClipId);
3142
+ let best = ms;
3143
+ let bestDist = tolMs;
3144
+ for (const t of targets) {
3145
+ const d = Math.abs(t - ms);
3146
+ if (d < bestDist) {
3147
+ bestDist = d;
3148
+ best = t;
3149
+ }
3150
+ }
3151
+ if (best !== ms) {
3152
+ const baseX = contentLeftX(this.showHeader);
3153
+ this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
3154
+ } else {
3155
+ this.snapX = null;
3156
+ }
3157
+ return best;
3158
+ }
3159
+ };
3160
+
3161
+ // src/ui/keyframe-overlay.ts
3162
+ var KeyframeOverlay = class _KeyframeOverlay {
3163
+ editor;
3164
+ host;
3165
+ root;
3166
+ frameBody;
3167
+ handles;
3168
+ rafHandle = null;
3169
+ destroyed = false;
3170
+ drag = null;
3171
+ capturedPointerId = null;
3172
+ /** Timer handle for the wheel-burst → interaction commit. */
3173
+ wheelInteractionTimer = null;
3174
+ /** Snap-target threshold in CSS px — the same feel as the timeline. */
3175
+ static SNAP_PX = 8;
3176
+ constructor(host, editor) {
3177
+ this.host = host;
3178
+ this.editor = editor;
3179
+ this.root = document.createElement("div");
3180
+ this.root.className = "aicut-keyframe-overlay";
3181
+ this.root.setAttribute("data-testid", "aicut-keyframe-overlay");
3182
+ this.root.style.display = "none";
3183
+ this.frameBody = document.createElement("div");
3184
+ this.frameBody.className = "aicut-keyframe-overlay__frame";
3185
+ this.frameBody.setAttribute("data-testid", "aicut-keyframe-frame");
3186
+ this.frameBody.addEventListener("pointerdown", (e) => this.onTransStart(e));
3187
+ this.frameBody.addEventListener(
3188
+ "wheel",
3189
+ (e) => this.onPinchScale(e),
3190
+ { passive: false }
3191
+ );
3192
+ this.root.appendChild(this.frameBody);
3193
+ this.handles = {
3194
+ tl: this.makeHandle("tl"),
3195
+ tr: this.makeHandle("tr"),
3196
+ bl: this.makeHandle("bl"),
3197
+ br: this.makeHandle("br")
3198
+ };
3199
+ host.appendChild(this.root);
3200
+ this.startTick();
3201
+ }
3202
+ destroy() {
3203
+ this.destroyed = true;
3204
+ if (this.rafHandle != null) cancelAnimationFrame(this.rafHandle);
3205
+ if (this.wheelInteractionTimer != null) {
3206
+ clearTimeout(this.wheelInteractionTimer);
3207
+ this.wheelInteractionTimer = null;
3208
+ this.editor.endInteraction();
3209
+ }
3210
+ this.root.remove();
3211
+ }
3212
+ // ---- frame body drag (translate) -------------------------------------
3213
+ onTransStart(e) {
3214
+ if (e.button !== 0) return;
3215
+ const ctx = this.ensureSelectedClip();
3216
+ if (!ctx) return;
3217
+ e.preventDefault();
3218
+ e.stopPropagation();
3219
+ this.frameBody.setPointerCapture(e.pointerId);
3220
+ this.capturedPointerId = e.pointerId;
3221
+ this.drag = {
3222
+ kind: "translate",
3223
+ clipId: ctx.clip.id,
3224
+ pointerStartX: e.clientX,
3225
+ pointerStartY: e.clientY,
3226
+ startPanX: ctx.transform.panX,
3227
+ startPanY: ctx.transform.panY
3228
+ };
3229
+ this.editor.beginInteraction();
3230
+ this.frameBody.addEventListener("pointermove", this.onPointerMove);
3231
+ this.frameBody.addEventListener("pointerup", this.onPointerUp);
3232
+ this.frameBody.addEventListener("pointercancel", this.onPointerUp);
3233
+ }
3234
+ // ---- pinch-to-scale --------------------------------------------------
3235
+ onPinchScale(e) {
3236
+ if (!e.ctrlKey) return;
3237
+ const ctx = this.ensureSelectedClip();
3238
+ if (!ctx) return;
3239
+ e.preventDefault();
3240
+ e.stopPropagation();
3241
+ const step = Math.max(-50, Math.min(50, -e.deltaY));
3242
+ const factor = Math.exp(step * 0.01);
3243
+ const next = Math.max(
3244
+ 0.05,
3245
+ Math.min(16, ctx.transform.scale * factor)
3246
+ );
3247
+ if (this.wheelInteractionTimer == null) {
3248
+ this.editor.beginInteraction();
3249
+ } else {
3250
+ clearTimeout(this.wheelInteractionTimer);
3251
+ }
3252
+ this.wheelInteractionTimer = window.setTimeout(() => {
3253
+ this.wheelInteractionTimer = null;
3254
+ this.editor.endInteraction();
3255
+ }, 200);
3256
+ this.editor.setValueAtPlayhead(
3257
+ ctx.clip.id,
3258
+ "scale",
3259
+ Math.round(next * 100) / 100
3260
+ );
3261
+ }
3262
+ // ---- corner-handle drag (scale) --------------------------------------
3263
+ onScaleStart(corner, e) {
3264
+ if (e.button !== 0) return;
3265
+ const ctx = this.ensureSelectedClip();
3266
+ if (!ctx) return;
3267
+ e.preventDefault();
3268
+ e.stopPropagation();
3269
+ const rect = this.editor.getActiveOutputFrameRect() ?? this.editor.getActiveFrameRect();
3270
+ if (!rect) return;
3271
+ const hostRect = this.host.getBoundingClientRect();
3272
+ const cx = hostRect.left + rect.x + rect.w / 2;
3273
+ const cy = hostRect.top + rect.y + rect.h / 2;
3274
+ const startDist = Math.hypot(e.clientX - cx, e.clientY - cy);
3275
+ if (startDist < 1) return;
3276
+ const target = this.handles[corner];
3277
+ target.setPointerCapture(e.pointerId);
3278
+ this.capturedPointerId = e.pointerId;
3279
+ this.drag = {
3280
+ kind: "scale",
3281
+ clipId: ctx.clip.id,
3282
+ centerX: cx,
3283
+ centerY: cy,
3284
+ startDistance: startDist,
3285
+ startScale: ctx.transform.scale
3286
+ };
3287
+ this.editor.beginInteraction();
3288
+ target.addEventListener("pointermove", this.onPointerMove);
3289
+ target.addEventListener("pointerup", this.onPointerUp);
3290
+ target.addEventListener("pointercancel", this.onPointerUp);
3291
+ }
3292
+ onPointerMove = (e) => {
3293
+ if (!this.drag) return;
3294
+ if (this.drag.kind === "translate") {
3295
+ const dx = e.clientX - this.drag.pointerStartX;
3296
+ const dy = e.clientY - this.drag.pointerStartY;
3297
+ const rawPanX = this.drag.startPanX + dx;
3298
+ const rawPanY = this.drag.startPanY + dy;
3299
+ const snapped = this.applySnap(this.drag.clipId, rawPanX, rawPanY);
3300
+ this.editor.setValueAtPlayhead(
3301
+ this.drag.clipId,
3302
+ "panX",
3303
+ Math.round(snapped.panX)
3304
+ );
3305
+ this.editor.setValueAtPlayhead(
3306
+ this.drag.clipId,
3307
+ "panY",
3308
+ Math.round(snapped.panY)
3309
+ );
3310
+ } else {
3311
+ const dist = Math.hypot(
3312
+ e.clientX - this.drag.centerX,
3313
+ e.clientY - this.drag.centerY
3314
+ );
3315
+ const ratio = dist / this.drag.startDistance;
3316
+ const next = Math.max(
3317
+ 0.05,
3318
+ Math.min(16, this.drag.startScale * ratio)
3319
+ );
3320
+ this.editor.setValueAtPlayhead(
3321
+ this.drag.clipId,
3322
+ "scale",
3323
+ Math.round(next * 100) / 100
3324
+ );
3325
+ }
3326
+ };
3327
+ /**
3328
+ * Snap raw pan to: centered (panX/Y = 0) and the four edge-alignment
3329
+ * stops (content's L/R/T/B edge flush with the output's matching
3330
+ * edge). When content is smaller than output, the edge stops collapse
3331
+ * to the same point as 0 — harmless dup. Threshold = 8 CSS px.
3332
+ */
3333
+ applySnap(clipId, rawPanX, rawPanY) {
3334
+ const out = this.editor.getActiveOutputFrameRect();
3335
+ if (!out) return { panX: rawPanX, panY: rawPanY };
3336
+ const clip = this.findClip(clipId);
3337
+ if (!clip) return { panX: rawPanX, panY: rawPanY };
3338
+ const t = (() => {
3339
+ try {
3340
+ const transformer = this.editor.getActiveFrameRect();
3341
+ if (!transformer) return null;
3342
+ return { w: transformer.w, h: transformer.h };
3343
+ } catch {
3344
+ return null;
3345
+ }
3346
+ })();
3347
+ const contentW = t?.w ?? out.w;
3348
+ const contentH = t?.h ?? out.h;
3349
+ const edgeX = (contentW - out.w) / 2;
3350
+ const edgeY = (contentH - out.h) / 2;
3351
+ const xTargets = [0, edgeX, -edgeX];
3352
+ const yTargets = [0, edgeY, -edgeY];
3353
+ const px = nearestSnap(rawPanX, xTargets, _KeyframeOverlay.SNAP_PX);
3354
+ const py = nearestSnap(rawPanY, yTargets, _KeyframeOverlay.SNAP_PX);
3355
+ return { panX: px, panY: py };
3356
+ }
3357
+ findClip(clipId) {
3358
+ const project = this.editor.getProject();
3359
+ for (const t of project.tracks) {
3360
+ const c = t.clips.find((cl) => cl.id === clipId);
3361
+ if (c) return c;
3362
+ }
3363
+ return null;
3364
+ }
3365
+ onPointerUp = (e) => {
3366
+ if (!this.drag) return;
3367
+ const targetEl = e.currentTarget;
3368
+ if (targetEl && this.capturedPointerId === e.pointerId) {
3369
+ try {
3370
+ targetEl.releasePointerCapture(e.pointerId);
3371
+ } catch {
3372
+ }
3373
+ }
3374
+ targetEl?.removeEventListener("pointermove", this.onPointerMove);
3375
+ targetEl?.removeEventListener("pointerup", this.onPointerUp);
3376
+ targetEl?.removeEventListener("pointercancel", this.onPointerUp);
3377
+ this.drag = null;
3378
+ this.capturedPointerId = null;
3379
+ this.editor.endInteraction();
3380
+ };
3381
+ // ---- per-frame layout ------------------------------------------------
3382
+ startTick() {
3383
+ const tick = () => {
3384
+ if (this.destroyed) return;
3385
+ this.layout();
3386
+ this.rafHandle = requestAnimationFrame(tick);
3387
+ };
3388
+ this.rafHandle = requestAnimationFrame(tick);
3389
+ }
3390
+ layout() {
3391
+ const enabled = this.editor.isKeyframesEnabled();
3392
+ if (!enabled) {
3393
+ this.root.style.display = "none";
3394
+ return;
3395
+ }
3396
+ const outRect = this.editor.getActiveOutputFrameRect();
3397
+ const contentRect = this.editor.getActiveFrameRect() ?? outRect;
3398
+ if (!outRect) {
3399
+ this.root.style.display = "none";
3400
+ return;
3401
+ }
3402
+ this.root.style.display = "block";
3403
+ Object.assign(this.frameBody.style, {
3404
+ left: `${outRect.x}px`,
3405
+ top: `${outRect.y}px`,
3406
+ width: `${outRect.w}px`,
3407
+ height: `${outRect.h}px`
3408
+ });
3409
+ const fullyCovered = contentRect ? contentRect.x <= outRect.x + 0.5 && contentRect.x + contentRect.w >= outRect.x + outRect.w - 0.5 && contentRect.y <= outRect.y + 0.5 && contentRect.y + contentRect.h >= outRect.y + outRect.h - 0.5 : true;
3410
+ this.frameBody.classList.toggle(
3411
+ "aicut-keyframe-overlay__frame--warn",
3412
+ !fullyCovered
3413
+ );
3414
+ const halfHandle = 6;
3415
+ const r = contentRect ?? outRect;
3416
+ const fbLeft = r.x;
3417
+ const fbTop = r.y;
3418
+ const fbRight = r.x + r.w;
3419
+ const fbBottom = r.y + r.h;
3420
+ const place = (el, cx, cy) => {
3421
+ el.style.left = `${cx - halfHandle}px`;
3422
+ el.style.top = `${cy - halfHandle}px`;
3423
+ };
3424
+ place(this.handles.tl, fbLeft, fbTop);
3425
+ place(this.handles.tr, fbRight, fbTop);
3426
+ place(this.handles.bl, fbLeft, fbBottom);
3427
+ place(this.handles.br, fbRight, fbBottom);
3428
+ }
3429
+ // ---- helpers ---------------------------------------------------------
3430
+ makeHandle(name) {
3431
+ const el = document.createElement("div");
3432
+ el.className = `aicut-keyframe-overlay__handle aicut-keyframe-overlay__handle--${name}`;
3433
+ el.setAttribute("data-testid", `aicut-keyframe-handle-${name}`);
3434
+ el.addEventListener("pointerdown", (e) => this.onScaleStart(name, e));
3435
+ this.root.appendChild(el);
3436
+ return el;
3437
+ }
3438
+ /**
3439
+ * Resolve the currently selected clip + its current effective
3440
+ * transform (so drag baselines are correct). Returns null when no
3441
+ * clip is selected or the playhead isn't over it.
3442
+ */
3443
+ ensureSelectedClip() {
3444
+ const selectedClipId = this.editor.getSelection();
3445
+ if (!selectedClipId) return null;
3446
+ const project = this.editor.getProject();
3447
+ let clip = null;
3448
+ for (const t of project.tracks) {
3449
+ const c = t.clips.find((cl) => cl.id === selectedClipId);
3450
+ if (c) {
3451
+ clip = c;
3452
+ break;
3453
+ }
3454
+ }
3455
+ if (!clip) return null;
3456
+ const playheadLocal = this.editor.getTime() - clip.start;
3457
+ if (playheadLocal < 0 || playheadLocal > clip.out - clip.in) {
3458
+ return null;
3459
+ }
3460
+ const transform = getEffectiveTransform(clip, playheadLocal);
3461
+ return { clip, transform };
3462
+ }
3463
+ };
3464
+ function nearestSnap(raw, targets, threshold) {
3465
+ let best = raw;
3466
+ let bestDist = threshold;
3467
+ for (const t of targets) {
3468
+ const d = Math.abs(raw - t);
3469
+ if (d < bestDist) {
3470
+ bestDist = d;
3471
+ best = t;
3472
+ }
3473
+ }
3474
+ return best;
3475
+ }
3476
+
3477
+ // src/ui/keyframe-panel.ts
3478
+ var EASING_VALUES = [
3479
+ "linear",
3480
+ "easeIn",
3481
+ "easeOut",
3482
+ "easeInOut"
3483
+ ];
3484
+ function easingLabel(value, locale) {
3485
+ switch (value) {
3486
+ case "linear":
3487
+ return locale.keyframeEasingLinear;
3488
+ case "easeIn":
3489
+ return locale.keyframeEasingEaseIn;
3490
+ case "easeOut":
3491
+ return locale.keyframeEasingEaseOut;
3492
+ case "easeInOut":
3493
+ return locale.keyframeEasingEaseInOut;
3494
+ }
3495
+ }
3496
+ var TIME_EPS_MS = 16;
3497
+ var KeyframePanel = class {
3498
+ editor;
3499
+ locale;
3500
+ root;
3501
+ inputs;
3502
+ kfBadges;
3503
+ timeLabel;
3504
+ titleLabel;
3505
+ resetBtn;
3506
+ easingTrigger;
3507
+ easingTriggerLabel;
3508
+ easingMenu;
3509
+ easingItems;
3510
+ easingValue = "linear";
3511
+ easingDisabled = false;
3512
+ easingOpen = false;
3513
+ easingLabelEl;
3514
+ rowLabels;
3515
+ lastSyncKey = "";
3516
+ // Bound once so add/remove listener pairs reference the same fn.
3517
+ boundOutsideClick = null;
3518
+ boundDocKeydown = null;
3519
+ constructor(host, editor, locale) {
3520
+ this.editor = editor;
3521
+ this.locale = locale;
3522
+ this.root = document.createElement("div");
3523
+ this.root.className = "aicut-keyframe-panel";
3524
+ this.root.setAttribute("data-testid", "aicut-keyframe-panel");
3525
+ this.root.style.display = "none";
3526
+ this.root.addEventListener("pointerdown", (e) => e.stopPropagation());
3527
+ this.root.addEventListener("wheel", (e) => e.stopPropagation());
3528
+ const title = document.createElement("div");
3529
+ title.className = "aicut-keyframe-panel__title";
3530
+ this.titleLabel = document.createElement("span");
3531
+ this.timeLabel = document.createElement("span");
3532
+ this.timeLabel.className = "aicut-keyframe-panel__time";
3533
+ title.append(this.titleLabel, this.timeLabel);
3534
+ this.root.appendChild(title);
3535
+ const xRow = this.makeRow("kf-x", "panX", 1);
3536
+ const yRow = this.makeRow("kf-y", "panY", 1);
3537
+ const scaleRow = this.makeRow("kf-scale", "scale", 0.05);
3538
+ this.inputs = {
3539
+ panX: xRow.input,
3540
+ panY: yRow.input,
3541
+ scale: scaleRow.input
3542
+ };
3543
+ this.rowLabels = {
3544
+ panX: xRow.label,
3545
+ panY: yRow.label,
3546
+ scale: scaleRow.label
3547
+ };
3548
+ this.kfBadges = {
3549
+ panX: this.makeBadge(this.inputs.panX),
3550
+ panY: this.makeBadge(this.inputs.panY),
3551
+ scale: this.makeBadge(this.inputs.scale)
3552
+ };
3553
+ const easingRow = document.createElement("div");
3554
+ easingRow.className = "aicut-keyframe-panel__row aicut-keyframe-panel__row--easing";
3555
+ this.easingLabelEl = document.createElement("label");
3556
+ const dd = document.createElement("div");
3557
+ dd.className = "aicut-keyframe-panel__dropdown";
3558
+ dd.setAttribute("data-testid", "aicut-kf-easing");
3559
+ this.easingTrigger = document.createElement("button");
3560
+ this.easingTrigger.type = "button";
3561
+ this.easingTrigger.className = "aicut-keyframe-panel__dropdown-trigger";
3562
+ this.easingTrigger.setAttribute("aria-haspopup", "listbox");
3563
+ this.easingTrigger.setAttribute("aria-expanded", "false");
3564
+ this.easingTriggerLabel = document.createElement("span");
3565
+ this.easingTriggerLabel.className = "aicut-keyframe-panel__dropdown-trigger-label";
3566
+ const chevron = document.createElement("span");
3567
+ chevron.className = "aicut-keyframe-panel__dropdown-chevron";
3568
+ chevron.setAttribute("aria-hidden", "true");
3569
+ this.easingTrigger.append(this.easingTriggerLabel, chevron);
3570
+ this.easingTrigger.addEventListener("click", (e) => {
3571
+ e.stopPropagation();
3572
+ if (this.easingDisabled) return;
3573
+ this.toggleEasingMenu();
3574
+ });
3575
+ this.easingTrigger.addEventListener("keydown", (e) => {
3576
+ if (this.easingDisabled) return;
3577
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
3578
+ e.preventDefault();
3579
+ if (!this.easingOpen) this.openEasingMenu();
3580
+ this.easingItems.get(this.easingValue)?.focus();
3581
+ }
3582
+ });
3583
+ this.easingMenu = document.createElement("ul");
3584
+ this.easingMenu.className = "aicut-keyframe-panel__dropdown-menu";
3585
+ this.easingMenu.setAttribute("role", "listbox");
3586
+ this.easingMenu.style.display = "none";
3587
+ this.easingItems = /* @__PURE__ */ new Map();
3588
+ for (const value of EASING_VALUES) {
3589
+ const li = document.createElement("li");
3590
+ li.className = "aicut-keyframe-panel__dropdown-item";
3591
+ li.setAttribute("role", "option");
3592
+ li.setAttribute("data-value", value);
3593
+ li.setAttribute("tabindex", "-1");
3594
+ li.addEventListener("click", (e) => {
3595
+ e.stopPropagation();
3596
+ this.selectEasing(value);
3597
+ });
3598
+ li.addEventListener("keydown", (e) => this.onMenuKeydown(e, value));
3599
+ this.easingItems.set(value, li);
3600
+ this.easingMenu.appendChild(li);
3601
+ }
3602
+ dd.append(this.easingTrigger, this.easingMenu);
3603
+ easingRow.append(this.easingLabelEl, dd);
3604
+ this.root.appendChild(easingRow);
3605
+ const actions = document.createElement("div");
3606
+ actions.className = "aicut-keyframe-panel__actions";
3607
+ this.resetBtn = document.createElement("button");
3608
+ this.resetBtn.type = "button";
3609
+ this.resetBtn.className = "aicut-keyframe-panel__reset";
3610
+ this.resetBtn.setAttribute("data-testid", "aicut-keyframe-reset");
3611
+ this.resetBtn.addEventListener("click", () => this.onReset());
3612
+ actions.appendChild(this.resetBtn);
3613
+ this.root.appendChild(actions);
3614
+ this.applyLocaleText();
3615
+ host.appendChild(this.root);
3616
+ }
3617
+ setLocale(locale) {
3618
+ this.locale = locale;
3619
+ this.applyLocaleText();
3620
+ this.lastSyncKey = "";
3621
+ this.render();
3622
+ }
3623
+ applyLocaleText() {
3624
+ this.titleLabel.textContent = this.locale.keyframePanelTitle;
3625
+ this.rowLabels.panX.textContent = this.locale.keyframePanelLabelX;
3626
+ this.rowLabels.panY.textContent = this.locale.keyframePanelLabelY;
3627
+ this.rowLabels.scale.textContent = this.locale.keyframePanelLabelScale;
3628
+ this.easingLabelEl.textContent = this.locale.keyframePanelLabelEasing;
3629
+ this.resetBtn.textContent = this.locale.keyframePanelReset;
3630
+ this.resetBtn.title = this.locale.keyframePanelResetTitle;
3631
+ for (const [value, li] of this.easingItems) {
3632
+ li.textContent = easingLabel(value, this.locale);
3633
+ }
3634
+ this.easingTriggerLabel.textContent = easingLabel(
3635
+ this.easingValue,
3636
+ this.locale
3637
+ );
3638
+ }
3639
+ destroy() {
3640
+ this.closeEasingMenu();
3641
+ this.root.remove();
3642
+ }
3643
+ render() {
3644
+ const enabled = this.editor.isKeyframesEnabled();
3645
+ const sel = this.editor.getSelectedKeyframe();
3646
+ if (!enabled || !sel) {
3647
+ this.root.style.display = "none";
3648
+ this.lastSyncKey = "";
3649
+ return;
3650
+ }
3651
+ const clip = this.findClip(sel.clipId);
3652
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3653
+ if (!clip || !anchorKf) {
3654
+ this.root.style.display = "none";
3655
+ this.lastSyncKey = "";
3656
+ return;
3657
+ }
3658
+ const time = anchorKf.time;
3659
+ const moment = (clip.keyframes ?? []).filter(
3660
+ (k) => Math.abs(k.time - time) < TIME_EPS_MS
3661
+ );
3662
+ const interp = getEffectiveTransform(clip, time);
3663
+ const valueOf = (prop) => {
3664
+ const m = moment.find((k) => k.prop === prop);
3665
+ if (m) return m.value;
3666
+ return interp[prop];
3667
+ };
3668
+ const v = {
3669
+ panX: valueOf("panX"),
3670
+ panY: valueOf("panY"),
3671
+ scale: valueOf("scale")
3672
+ };
3673
+ const sharedEasing = (() => {
3674
+ if (moment.length === 0) return "linear";
3675
+ const anchor = moment.find((k) => k.id === sel.keyframeId) ?? moment[0];
3676
+ return anchor.easing ?? "linear";
3677
+ })();
3678
+ const syncKey = `${clip.id}|${time}|${v.panX.toFixed(2)}|${v.panY.toFixed(2)}|${v.scale.toFixed(4)}|${moment.map((m) => m.prop).join(",")}|${sharedEasing}`;
3679
+ this.root.style.display = "flex";
3680
+ if (syncKey === this.lastSyncKey) return;
3681
+ this.lastSyncKey = syncKey;
3682
+ this.setIfBlur(this.inputs.panX, String(Math.round(v.panX)));
3683
+ this.setIfBlur(this.inputs.panY, String(Math.round(v.panY)));
3684
+ this.setIfBlur(this.inputs.scale, v.scale.toFixed(2));
3685
+ this.timeLabel.textContent = `${(time / 1e3).toFixed(2)}${this.locale.keyframePanelTimeSuffix}`;
3686
+ this.setEasingValue(sharedEasing);
3687
+ this.setEasingDisabled(moment.length === 0);
3688
+ for (const p of ["panX", "panY", "scale"]) {
3689
+ const animated = moment.some((k) => k.prop === p) || hasKeyframesForProp(clip, p);
3690
+ const pinned = moment.some((k) => k.prop === p);
3691
+ this.kfBadges[p].classList.toggle(
3692
+ "aicut-keyframe-panel__badge--on",
3693
+ pinned
3694
+ );
3695
+ this.kfBadges[p].title = pinned ? this.locale.keyframePanelBadgePinned : animated ? this.locale.keyframePanelBadgeAnimated : this.locale.keyframePanelBadgeStatic;
3696
+ }
3697
+ this.resetBtn.disabled = false;
3698
+ }
3699
+ // ---- internals ------------------------------------------------------
3700
+ makeRow(testId, prop, step) {
3701
+ const row = document.createElement("div");
3702
+ row.className = "aicut-keyframe-panel__row";
3703
+ const lab = document.createElement("label");
3704
+ const input = document.createElement("input");
3705
+ input.type = "number";
3706
+ input.step = String(step);
3707
+ input.setAttribute("data-testid", `aicut-${testId}`);
3708
+ input.addEventListener("blur", () => this.commit(prop, input.value));
3709
+ input.addEventListener("keydown", (e) => {
3710
+ if (e.key === "Enter") input.blur();
2208
3711
  });
2209
- this.resizeObs.observe(this.root);
3712
+ row.append(lab, input);
3713
+ this.root.appendChild(row);
3714
+ return { input, label: lab };
3715
+ }
3716
+ makeBadge(input) {
3717
+ const dot = document.createElement("span");
3718
+ dot.className = "aicut-keyframe-panel__badge";
3719
+ input.parentElement?.appendChild(dot);
3720
+ return dot;
3721
+ }
3722
+ commit(prop, raw) {
3723
+ const num = Number(raw);
3724
+ if (!Number.isFinite(num)) return;
3725
+ const sel = this.editor.getSelectedKeyframe();
3726
+ if (!sel) return;
3727
+ const clip = this.findClip(sel.clipId);
3728
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3729
+ if (!clip || !anchorKf) return;
3730
+ this.editor.addKeyframe(sel.clipId, prop, {
3731
+ time: anchorKf.time,
3732
+ value: num
3733
+ });
3734
+ if (anchorKf.prop !== prop) {
3735
+ const refreshedClip = this.findClip(sel.clipId);
3736
+ const created = (refreshedClip?.keyframes ?? []).find(
3737
+ (k) => k.prop === prop && Math.abs(k.time - anchorKf.time) < TIME_EPS_MS
3738
+ );
3739
+ if (created) {
3740
+ this.editor.setSelectedKeyframe({
3741
+ clipId: sel.clipId,
3742
+ keyframeId: created.id
3743
+ });
3744
+ }
3745
+ }
2210
3746
  }
2211
- // ---- helpers --------------------------------------------------------
2212
- localCoords(e) {
2213
- const rect = this.canvas.getBoundingClientRect();
2214
- return { x: e.clientX - rect.left, y: e.clientY - rect.top };
3747
+ // ---- custom dropdown -------------------------------------------------
3748
+ setEasingValue(value) {
3749
+ if (this.easingValue === value) return;
3750
+ this.easingValue = value;
3751
+ this.easingTriggerLabel.textContent = easingLabel(value, this.locale);
3752
+ for (const [v, li] of this.easingItems) {
3753
+ li.classList.toggle(
3754
+ "aicut-keyframe-panel__dropdown-item--selected",
3755
+ v === value
3756
+ );
3757
+ li.setAttribute("aria-selected", v === value ? "true" : "false");
3758
+ }
2215
3759
  }
2216
- hitTarget(x, y) {
2217
- return hitTest(x, y, {
2218
- project: this.project,
2219
- pxPerSec: this.pxPerSec,
2220
- scrollLeft: this.scrollLeft,
2221
- scrollTop: this.scrollTop,
2222
- showHeader: this.showHeader,
2223
- viewportWidth: this.viewportWidth,
2224
- viewportHeight: this.viewportHeight,
2225
- isDragging: this.drag?.kind === "move"
3760
+ setEasingDisabled(disabled) {
3761
+ if (this.easingDisabled === disabled) return;
3762
+ this.easingDisabled = disabled;
3763
+ this.easingTrigger.disabled = disabled;
3764
+ this.easingTrigger.classList.toggle(
3765
+ "aicut-keyframe-panel__dropdown-trigger--disabled",
3766
+ disabled
3767
+ );
3768
+ if (disabled && this.easingOpen) this.closeEasingMenu();
3769
+ }
3770
+ toggleEasingMenu() {
3771
+ if (this.easingOpen) this.closeEasingMenu();
3772
+ else this.openEasingMenu();
3773
+ }
3774
+ openEasingMenu() {
3775
+ if (this.easingOpen || this.easingDisabled) return;
3776
+ this.easingOpen = true;
3777
+ this.easingMenu.style.display = "";
3778
+ this.easingTrigger.setAttribute("aria-expanded", "true");
3779
+ this.easingTrigger.classList.add(
3780
+ "aicut-keyframe-panel__dropdown-trigger--open"
3781
+ );
3782
+ requestAnimationFrame(() => {
3783
+ if (!this.easingOpen) return;
3784
+ this.boundOutsideClick = (e) => {
3785
+ if (!this.easingMenu.contains(e.target) && !this.easingTrigger.contains(e.target)) {
3786
+ this.closeEasingMenu();
3787
+ }
3788
+ };
3789
+ this.boundDocKeydown = (e) => {
3790
+ if (e.key === "Escape") {
3791
+ e.stopPropagation();
3792
+ this.closeEasingMenu();
3793
+ this.easingTrigger.focus();
3794
+ } else if (e.key === "Tab") {
3795
+ this.closeEasingMenu();
3796
+ }
3797
+ };
3798
+ document.addEventListener("click", this.boundOutsideClick, true);
3799
+ document.addEventListener("keydown", this.boundDocKeydown);
2226
3800
  });
2227
3801
  }
2228
- trackIndexAtY(y) {
2229
- return trackIndexAt(y, this.project.tracks.length, this.scrollTop);
3802
+ closeEasingMenu() {
3803
+ if (!this.easingOpen) return;
3804
+ this.easingOpen = false;
3805
+ this.easingMenu.style.display = "none";
3806
+ this.easingTrigger.setAttribute("aria-expanded", "false");
3807
+ this.easingTrigger.classList.remove(
3808
+ "aicut-keyframe-panel__dropdown-trigger--open"
3809
+ );
3810
+ if (this.boundOutsideClick) {
3811
+ document.removeEventListener("click", this.boundOutsideClick, true);
3812
+ this.boundOutsideClick = null;
3813
+ }
3814
+ if (this.boundDocKeydown) {
3815
+ document.removeEventListener("keydown", this.boundDocKeydown);
3816
+ this.boundDocKeydown = null;
3817
+ }
2230
3818
  }
2231
- applySnap(ms, ignoreClipId) {
2232
- if (!this.snapEnabled) {
2233
- this.snapX = null;
2234
- return ms;
3819
+ selectEasing(value) {
3820
+ this.closeEasingMenu();
3821
+ this.easingTrigger.focus();
3822
+ const sel = this.editor.getSelectedKeyframe();
3823
+ if (!sel) return;
3824
+ const clip = this.findClip(sel.clipId);
3825
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3826
+ if (!clip || !anchorKf) return;
3827
+ this.editor.setKeyframesEasingAtTime(sel.clipId, anchorKf.time, value);
3828
+ }
3829
+ onMenuKeydown(e, value) {
3830
+ if (e.key === "Enter" || e.key === " ") {
3831
+ e.preventDefault();
3832
+ this.selectEasing(value);
3833
+ return;
2235
3834
  }
2236
- const tolMs = Math.max(20, SNAP_PX / this.pxPerSec * 1e3);
2237
- const targets = snapTargets(this.project, this.timeMs, ignoreClipId);
2238
- let best = ms;
2239
- let bestDist = tolMs;
2240
- for (const t of targets) {
2241
- const d = Math.abs(t - ms);
2242
- if (d < bestDist) {
2243
- bestDist = d;
2244
- best = t;
2245
- }
3835
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
3836
+ e.preventDefault();
3837
+ const idx = EASING_VALUES.indexOf(value);
3838
+ const next = e.key === "ArrowDown" ? EASING_VALUES[(idx + 1) % EASING_VALUES.length] : EASING_VALUES[(idx - 1 + EASING_VALUES.length) % EASING_VALUES.length];
3839
+ this.easingItems.get(next)?.focus();
2246
3840
  }
2247
- if (best !== ms) {
2248
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2249
- this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
2250
- } else {
2251
- this.snapX = null;
3841
+ }
3842
+ onReset() {
3843
+ const sel = this.editor.getSelectedKeyframe();
3844
+ if (!sel) return;
3845
+ const clip = this.findClip(sel.clipId);
3846
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3847
+ if (!clip || !anchorKf) return;
3848
+ this.editor.resetKeyframesAtTime(sel.clipId, anchorKf.time);
3849
+ }
3850
+ setIfBlur(input, value) {
3851
+ if (document.activeElement === input) return;
3852
+ if (input.value !== value) input.value = value;
3853
+ }
3854
+ findClip(clipId) {
3855
+ const project = this.editor.getProject();
3856
+ for (const t of project.tracks) {
3857
+ const c = t.clips.find((cl) => cl.id === clipId);
3858
+ if (c) return c;
2252
3859
  }
2253
- return best;
3860
+ return null;
2254
3861
  }
2255
3862
  };
2256
3863
 
@@ -2297,6 +3904,24 @@ var ICONS = {
2297
3904
  trash: wrap(
2298
3905
  `<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>`
2299
3906
  ),
3907
+ /** "Skip to start" — vertical bar + left-pointing triangle. Sits to
3908
+ * the left of the keyframe diamond so the clip-edge nav cluster
3909
+ * reads as [|◀ ◇ ▶|] = "go to clip start / add kf / go to clip end". */
3910
+ seekClipStart: wrap(
3911
+ `<g transform="translate(2 3)" fill="currentColor"><rect x="0" y="0" width="1.6" height="10" rx="0.4"/><path d="M11 0.6c0-0.5-0.55-0.78-0.95-0.48l-6.5 4.4c-0.34 0.23-0.34 0.73 0 0.96l6.5 4.4c0.4 0.3 0.95 0.02 0.95-0.48z"/></g>`
3912
+ ),
3913
+ /** "Skip to end" — mirror of seekClipStart. */
3914
+ seekClipEnd: wrap(
3915
+ `<g transform="translate(1 3)" fill="currentColor"><path d="M0 0.6c0-0.5 0.55-0.78 0.95-0.48l6.5 4.4c0.34 0.23 0.34 0.73 0 0.96l-6.5 4.4c-0.4 0.3-0.95 0.02-0.95-0.48z"/><rect x="10.4" y="0" width="1.6" height="10" rx="0.4"/></g>`
3916
+ ),
3917
+ /** Outlined diamond (rotated square) — "add keyframe" affordance. */
3918
+ keyframeOutline: wrap(
3919
+ `<g transform="translate(8 8) rotate(45) translate(-4 -4)" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="0.5" y="0.5" width="7" height="7" rx="0.5"/></g>`
3920
+ ),
3921
+ /** Filled diamond — shown when a keyframe already exists at playhead. */
3922
+ keyframeFilled: wrap(
3923
+ `<g transform="translate(8 8) rotate(45) translate(-4 -4)" fill="currentColor"><rect x="0" y="0" width="8" height="8" rx="0.8"/></g>`
3924
+ ),
2300
3925
  /** Counter-clockwise circular arrow — "reset to initial layout". */
2301
3926
  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>`
2302
3927
  };
@@ -2323,6 +3948,9 @@ var Toolbar = class {
2323
3948
  splitBtn;
2324
3949
  trimLeftBtn;
2325
3950
  trimRightBtn;
3951
+ seekClipStartBtn;
3952
+ seekClipEndBtn;
3953
+ keyframeBtn;
2326
3954
  playBtn;
2327
3955
  playIcon;
2328
3956
  timeLabel;
@@ -2348,9 +3976,37 @@ var Toolbar = class {
2348
3976
  this.splitBtn = mkIconButton("split", locale.split, () => cb.onSplit(), "aicut-split");
2349
3977
  this.trimLeftBtn = mkIconButton("trimLeft", locale.trimLeft, () => cb.onTrimLeft(), "aicut-trim-left");
2350
3978
  this.trimRightBtn = mkIconButton("trimRight", locale.trimRight, () => cb.onTrimRight(), "aicut-trim-right");
2351
- const speedBtn = mkIconButton("speed", locale.speedComingSoon, () => void 0, "aicut-speed");
2352
- speedBtn.disabled = true;
2353
- left.append(this.undoBtn, this.redoBtn, this.splitBtn, this.trimLeftBtn, this.trimRightBtn, speedBtn);
3979
+ this.seekClipStartBtn = mkIconButton(
3980
+ "seekClipStart",
3981
+ locale.seekClipStart,
3982
+ () => cb.onSeekClipStart(),
3983
+ "aicut-seek-clip-start"
3984
+ );
3985
+ this.seekClipStartBtn.style.display = "none";
3986
+ this.keyframeBtn = mkIconButton(
3987
+ "keyframeOutline",
3988
+ locale.keyframeAdd,
3989
+ () => cb.onKeyframeToggle(),
3990
+ "aicut-keyframe"
3991
+ );
3992
+ this.keyframeBtn.style.display = "none";
3993
+ this.seekClipEndBtn = mkIconButton(
3994
+ "seekClipEnd",
3995
+ locale.seekClipEnd,
3996
+ () => cb.onSeekClipEnd(),
3997
+ "aicut-seek-clip-end"
3998
+ );
3999
+ this.seekClipEndBtn.style.display = "none";
4000
+ left.append(
4001
+ this.undoBtn,
4002
+ this.redoBtn,
4003
+ this.splitBtn,
4004
+ this.trimLeftBtn,
4005
+ this.trimRightBtn,
4006
+ this.seekClipStartBtn,
4007
+ this.keyframeBtn,
4008
+ this.seekClipEndBtn
4009
+ );
2354
4010
  const center = mkGroup("aicut-toolbar-center");
2355
4011
  this.timeLabel = mkSpan("aicut-time-current", "00:00", "aicut-time-current");
2356
4012
  this.playBtn = document.createElement("button");
@@ -2427,11 +4083,38 @@ var Toolbar = class {
2427
4083
  this.trimLeftBtn.disabled = !state.canTrim;
2428
4084
  this.trimRightBtn.disabled = !state.canTrim;
2429
4085
  }
4086
+ if (!this.lastState || this.lastState.clipEdgeNavEnabled !== state.clipEdgeNavEnabled) {
4087
+ const display = state.clipEdgeNavEnabled ? "" : "none";
4088
+ this.seekClipStartBtn.style.display = display;
4089
+ this.seekClipEndBtn.style.display = display;
4090
+ }
4091
+ if (!this.lastState || this.lastState.canSeekClipEdge !== state.canSeekClipEdge) {
4092
+ this.seekClipStartBtn.disabled = !state.canSeekClipEdge;
4093
+ this.seekClipEndBtn.disabled = !state.canSeekClipEdge;
4094
+ }
2430
4095
  if (!this.lastState || this.lastState.snap !== state.snap) {
2431
4096
  this.snapBtn.setAttribute("aria-pressed", state.snap ? "true" : "false");
2432
4097
  this.snapBtn.classList.toggle("aicut-toggle-on", state.snap);
2433
4098
  this.snapBtn.title = state.snap ? this.locale.snapOnTitle : this.locale.snapOffTitle;
2434
4099
  }
4100
+ if (!this.lastState || this.lastState.keyframesEnabled !== state.keyframesEnabled) {
4101
+ this.keyframeBtn.style.display = state.keyframesEnabled ? "" : "none";
4102
+ }
4103
+ if (state.keyframesEnabled) {
4104
+ if (!this.lastState || this.lastState.hasKeyframeAtPlayhead !== state.hasKeyframeAtPlayhead) {
4105
+ this.keyframeBtn.innerHTML = state.hasKeyframeAtPlayhead ? ICONS.keyframeFilled : ICONS.keyframeOutline;
4106
+ const title = state.hasKeyframeAtPlayhead ? this.locale.keyframeRemove : this.locale.keyframeAdd;
4107
+ this.keyframeBtn.title = title;
4108
+ this.keyframeBtn.setAttribute("aria-label", title);
4109
+ this.keyframeBtn.setAttribute(
4110
+ "data-state",
4111
+ state.hasKeyframeAtPlayhead ? "on" : "off"
4112
+ );
4113
+ }
4114
+ if (!this.lastState || this.lastState.canKeyframe !== state.canKeyframe) {
4115
+ this.keyframeBtn.disabled = !state.canKeyframe;
4116
+ }
4117
+ }
2435
4118
  if (!this.lastState || this.lastState.pxPerSec !== state.pxPerSec) {
2436
4119
  const ratio = scaleToSlider(state.pxPerSec);
2437
4120
  const nextVal = String(Math.round(ratio * 100));
@@ -2464,12 +4147,20 @@ var Toolbar = class {
2464
4147
  applyTitle(this.splitBtn, locale.split);
2465
4148
  applyTitle(this.trimLeftBtn, locale.trimLeft);
2466
4149
  applyTitle(this.trimRightBtn, locale.trimRight);
4150
+ applyTitle(this.seekClipStartBtn, locale.seekClipStart);
4151
+ applyTitle(this.seekClipEndBtn, locale.seekClipEnd);
2467
4152
  applyTitle(this.playBtn, locale.playPause);
2468
4153
  applyTitle(this.fullscreenBtn, locale.fullscreen);
2469
4154
  applyTitle(this.snapBtn, locale.snap);
2470
4155
  applyTitle(this.zoomOutBtn, locale.zoomOut);
2471
4156
  applyTitle(this.zoomInBtn, locale.zoomIn);
2472
4157
  applyTitle(this.resetBtn, locale.reset);
4158
+ if (this.keyframeBtn) {
4159
+ const hasKf = this.lastState?.hasKeyframeAtPlayhead === true;
4160
+ const t = hasKf ? locale.keyframeRemove : locale.keyframeAdd;
4161
+ this.keyframeBtn.title = t;
4162
+ this.keyframeBtn.setAttribute("aria-label", t);
4163
+ }
2473
4164
  if (this.lastState) {
2474
4165
  this.snapBtn.title = this.lastState.snap ? locale.snapOnTitle : locale.snapOffTitle;
2475
4166
  }
@@ -2529,6 +4220,8 @@ var EditorUI = class {
2529
4220
  toolbar;
2530
4221
  timelineHost;
2531
4222
  timeline;
4223
+ keyframePanel;
4224
+ keyframeOverlay;
2532
4225
  fullscreen = false;
2533
4226
  onDocKeydown = null;
2534
4227
  constructor(root, editor, cb) {
@@ -2575,10 +4268,14 @@ var EditorUI = class {
2575
4268
  snap: editor.getSnap(),
2576
4269
  autoFit: true,
2577
4270
  locale,
4271
+ keyframesEnabled: editor.isKeyframesEnabled(),
4272
+ selectedKeyframe: editor.getSelectedKeyframe(),
2578
4273
  onSeek: cb.onSeek,
2579
4274
  onSelectClip: cb.onSelectClip,
2580
4275
  onMoveClip: cb.onMoveClip,
2581
4276
  onResizeClip: cb.onResizeClip,
4277
+ onSelectKeyframe: cb.onSelectKeyframe,
4278
+ onMoveKeyframe: cb.onMoveKeyframe,
2582
4279
  onScaleChange: cb.onScaleChange,
2583
4280
  onDeleteTrack: (trackId) => editor.removeTrack(trackId),
2584
4281
  // Mirror the editor's smart routing into the drag preview so
@@ -2603,6 +4300,8 @@ var EditorUI = class {
2603
4300
  };
2604
4301
  }
2605
4302
  });
4303
+ this.keyframePanel = new KeyframePanel(this.preview, editor, locale);
4304
+ this.keyframeOverlay = new KeyframeOverlay(this.preview, editor);
2606
4305
  this.attachKeyboard(cb);
2607
4306
  }
2608
4307
  // ---- fullscreen -----------------------------------------------------
@@ -2652,6 +4351,13 @@ var EditorUI = class {
2652
4351
  const selectedClipId = this.editor.getSelection();
2653
4352
  const pxPerSec = this.editor.getScale();
2654
4353
  const snap = this.editor.getSnap();
4354
+ const kfEnabled = this.editor.isKeyframesEnabled();
4355
+ const kfState = this.computeKeyframeToolbarState(
4356
+ project,
4357
+ selectedClipId,
4358
+ time,
4359
+ kfEnabled
4360
+ );
2655
4361
  this.toolbar.render({
2656
4362
  playing: this.editor.isPlaying(),
2657
4363
  time,
@@ -2660,18 +4366,34 @@ var EditorUI = class {
2660
4366
  canRedo: this.editor.canRedo(),
2661
4367
  canSplit: this.canSplitAt(time),
2662
4368
  canTrim: this.canTrimAt(time, selectedClipId),
4369
+ canSeekClipEdge: selectedClipId != null,
4370
+ clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
2663
4371
  snap,
2664
- pxPerSec
4372
+ pxPerSec,
4373
+ ...kfState
2665
4374
  });
2666
4375
  this.timeline.setProject(project);
2667
4376
  this.timeline.setTime(time);
2668
4377
  this.timeline.setScale(pxPerSec);
2669
4378
  this.timeline.setSelection(selectedClipId);
2670
4379
  this.timeline.setSnap(snap);
4380
+ this.timeline.setKeyframeState({
4381
+ enabled: this.editor.isKeyframesEnabled(),
4382
+ selected: this.editor.getSelectedKeyframe()
4383
+ });
4384
+ this.keyframePanel.render();
2671
4385
  }
2672
4386
  /** Playback-fast path: nudge playhead + toolbar time label only. */
2673
4387
  onTimeTick(timeMs) {
2674
4388
  this.timeline.setTime(timeMs);
4389
+ const selectedClipId = this.editor.getSelection();
4390
+ const kfEnabled = this.editor.isKeyframesEnabled();
4391
+ const kfState = this.computeKeyframeToolbarState(
4392
+ this.editor.getProject(),
4393
+ selectedClipId,
4394
+ timeMs,
4395
+ kfEnabled
4396
+ );
2675
4397
  this.toolbar.render({
2676
4398
  playing: this.editor.isPlaying(),
2677
4399
  time: timeMs,
@@ -2679,9 +4401,12 @@ var EditorUI = class {
2679
4401
  canUndo: this.editor.canUndo(),
2680
4402
  canRedo: this.editor.canRedo(),
2681
4403
  canSplit: this.canSplitAt(timeMs),
2682
- canTrim: this.canTrimAt(timeMs, this.editor.getSelection()),
4404
+ canTrim: this.canTrimAt(timeMs, selectedClipId),
4405
+ canSeekClipEdge: selectedClipId != null,
4406
+ clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
2683
4407
  snap: this.editor.getSnap(),
2684
- pxPerSec: this.editor.getScale()
4408
+ pxPerSec: this.editor.getScale(),
4409
+ ...kfState
2685
4410
  });
2686
4411
  }
2687
4412
  /** Explicit re-fit — Editor calls this when a brand-new project replaces the current one. */
@@ -2693,6 +4418,7 @@ var EditorUI = class {
2693
4418
  this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
2694
4419
  this.fullscreenExitBtn.textContent = locale.exitFullscreen;
2695
4420
  this.timeline.setLocale(locale);
4421
+ this.keyframePanel.setLocale(locale);
2696
4422
  this.render();
2697
4423
  }
2698
4424
  destroy() {
@@ -2702,10 +4428,56 @@ var EditorUI = class {
2702
4428
  }
2703
4429
  this.toolbar.destroy();
2704
4430
  this.timeline.destroy();
4431
+ this.keyframePanel.destroy();
4432
+ this.keyframeOverlay.destroy();
2705
4433
  this.root.innerHTML = "";
2706
4434
  this.root.classList.remove("aicut-root", "aicut-fullscreen");
2707
4435
  }
2708
4436
  // ---- helpers --------------------------------------------------------
4437
+ /** Walk the selected clip + playhead state to figure out (a) whether
4438
+ * the keyframe button should be enabled, and (b) whether a keyframe
4439
+ * already exists at the playhead's clip-local time (so the button
4440
+ * swaps to "remove" mode). */
4441
+ computeKeyframeToolbarState(project, selectedClipId, time, keyframesEnabled) {
4442
+ if (!keyframesEnabled || !selectedClipId) {
4443
+ return {
4444
+ canKeyframe: false,
4445
+ hasKeyframeAtPlayhead: false,
4446
+ keyframesEnabled
4447
+ };
4448
+ }
4449
+ let clip = null;
4450
+ for (const t of project.tracks) {
4451
+ const c = t.clips.find((cl) => cl.id === selectedClipId);
4452
+ if (c) {
4453
+ clip = c;
4454
+ break;
4455
+ }
4456
+ }
4457
+ if (!clip) {
4458
+ return {
4459
+ canKeyframe: false,
4460
+ hasKeyframeAtPlayhead: false,
4461
+ keyframesEnabled
4462
+ };
4463
+ }
4464
+ const localMs = time - clip.start;
4465
+ const duration = clipDuration(clip);
4466
+ if (localMs < 0 || localMs > duration) {
4467
+ return {
4468
+ canKeyframe: false,
4469
+ hasKeyframeAtPlayhead: false,
4470
+ keyframesEnabled
4471
+ };
4472
+ }
4473
+ const roundedLocal = Math.round(localMs);
4474
+ const hasKf = clip.keyframes?.some((k) => k.time === roundedLocal) ?? false;
4475
+ return {
4476
+ canKeyframe: true,
4477
+ hasKeyframeAtPlayhead: hasKf,
4478
+ keyframesEnabled
4479
+ };
4480
+ }
2709
4481
  canSplitAt(timeMs) {
2710
4482
  const project = this.editor.getProject();
2711
4483
  for (const t of project.tracks) {
@@ -2747,6 +4519,22 @@ var EditorUI = class {
2747
4519
  } else if (e.code === "KeyW") {
2748
4520
  e.preventDefault();
2749
4521
  cb.onTrimRight();
4522
+ } else if (e.code === "KeyI" && this.editor.isClipEdgeNavEnabled()) {
4523
+ e.preventDefault();
4524
+ cb.onSeekClipStart();
4525
+ } else if (e.code === "KeyO" && this.editor.isClipEdgeNavEnabled()) {
4526
+ e.preventDefault();
4527
+ cb.onSeekClipEnd();
4528
+ } else if (e.code === "ArrowLeft" || e.code === "ArrowRight") {
4529
+ e.preventDefault();
4530
+ const project = this.editor.getProject();
4531
+ const step = e.shiftKey ? bigFrameStepMs(project) : frameStepMs(project);
4532
+ const dir = e.code === "ArrowLeft" ? -1 : 1;
4533
+ const next = Math.max(
4534
+ 0,
4535
+ Math.min(this.editor.getDuration(), this.editor.getTime() + dir * step)
4536
+ );
4537
+ cb.onSeek(next);
2750
4538
  } else if ((e.metaKey || e.ctrlKey) && e.code === "KeyZ") {
2751
4539
  e.preventDefault();
2752
4540
  if (e.shiftKey) cb.onRedo();
@@ -2775,6 +4563,13 @@ var Editor = class _Editor {
2775
4563
  bus = new EventBus();
2776
4564
  history = new HistoryStack();
2777
4565
  selectedClipId = null;
4566
+ selectedKeyframe = null;
4567
+ keyframesEnabled;
4568
+ clipEdgeNavEnabled;
4569
+ /** Drag-session bookkeeping for ripple-merge undo. See
4570
+ * beginInteraction / endInteraction docs on EditorApi. */
4571
+ interactionDepth = 0;
4572
+ interactionStartSnapshot = null;
2778
4573
  pxPerSec;
2779
4574
  snap;
2780
4575
  locale;
@@ -2785,7 +4580,21 @@ var Editor = class _Editor {
2785
4580
  this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
2786
4581
  this.snap = opts.initialSnap !== false;
2787
4582
  this.locale = mergeLocale(opts.locale);
4583
+ this.keyframesEnabled = opts.keyframes?.enabled === true;
4584
+ this.clipEdgeNavEnabled = opts.clipEdgeNav?.enabled === true;
4585
+ if (opts.trackHeight != null || opts.rulerHeight != null) {
4586
+ setTimelineMetrics({
4587
+ ...opts.trackHeight != null ? { trackHeight: opts.trackHeight } : {},
4588
+ ...opts.rulerHeight != null ? { rulerHeight: opts.rulerHeight } : {}
4589
+ });
4590
+ }
2788
4591
  applyTheme(this.container, opts.theme);
4592
+ if (opts.timelineHeight != null && opts.timelineHeight > 0) {
4593
+ this.container.style.setProperty(
4594
+ "--aicut-timeline-height",
4595
+ `${Math.round(opts.timelineHeight)}px`
4596
+ );
4597
+ }
2789
4598
  this.ui = new EditorUI(this.container, this, {
2790
4599
  onPlayToggle: () => this.togglePlay(),
2791
4600
  onSplit: () => this.split(),
@@ -2801,9 +4610,18 @@ var Editor = class _Editor {
2801
4610
  onSelectClip: (id) => this.setSelection(id),
2802
4611
  onDeleteClip: (id) => this.removeClip(id),
2803
4612
  onMoveClip: (id, opts2) => this.moveClip(id, opts2),
2804
- onResizeClip: (id, edits) => this.resizeClip(id, edits)
4613
+ onResizeClip: (id, edits) => this.resizeClip(id, edits),
4614
+ onSelectKeyframe: (target) => this.setSelectedKeyframe(target),
4615
+ onMoveKeyframe: (clipId, keyframeId, timeMs) => this.moveKeyframe(clipId, keyframeId, timeMs),
4616
+ onKeyframeToggle: () => this.toggleKeyframeAtPlayhead(),
4617
+ onSeekClipStart: () => this.seekToSelectedClipEdge("start"),
4618
+ onSeekClipEnd: () => this.seekToSelectedClipEdge("end")
4619
+ });
4620
+ const engineFactory = opts.playbackEngine ?? ((o) => new HtmlVideoEngine(o));
4621
+ this.engine = engineFactory({
4622
+ host: this.ui.previewHost,
4623
+ project: this.project
2805
4624
  });
2806
- this.engine = new PlaybackEngine(this.ui.previewHost, this.project);
2807
4625
  this.engine.onTimeUpdate = (ms) => {
2808
4626
  this.bus.emit("time", { timeMs: ms });
2809
4627
  this.ui.onTimeTick(ms);
@@ -3221,6 +5039,9 @@ var Editor = class _Editor {
3221
5039
  for (const c of t.clips) {
3222
5040
  if (c.id === ignoreClipId) continue;
3223
5041
  targets.push(c.start, clipEnd(c));
5042
+ if (c.keyframes) {
5043
+ for (const kf of c.keyframes) targets.push(c.start + kf.time);
5044
+ }
3224
5045
  }
3225
5046
  }
3226
5047
  let best = timeMs;
@@ -3242,6 +5063,304 @@ var Editor = class _Editor {
3242
5063
  if (clipId === this.selectedClipId) return;
3243
5064
  this.selectedClipId = clipId;
3244
5065
  this.bus.emit("selectionChange", { clipId });
5066
+ if (this.selectedKeyframe && this.selectedKeyframe.clipId !== clipId) {
5067
+ this.selectedKeyframe = null;
5068
+ this.bus.emit("keyframeSelectionChange", { target: null });
5069
+ }
5070
+ this.ui.render();
5071
+ }
5072
+ // ---- keyframes ------------------------------------------------------
5073
+ isKeyframesEnabled() {
5074
+ return this.keyframesEnabled;
5075
+ }
5076
+ /**
5077
+ * Screen-space CSS-pixel rect of the actively painted frame
5078
+ * (post-transform), relative to the editor's preview element.
5079
+ * Null when no clip is active, the engine doesn't expose
5080
+ * `getFrameRect`, or the rect isn't computed yet. Used by the
5081
+ * library's keyframe-editing overlay.
5082
+ */
5083
+ getActiveFrameRect() {
5084
+ return this.engine.getFrameRect?.() ?? null;
5085
+ }
5086
+ /**
5087
+ * Screen-space CSS-pixel rect of the OUTPUT FRAME (the fixed
5088
+ * stage that clips the rendered video). Different from
5089
+ * `getActiveFrameRect` which includes the keyframe transform —
5090
+ * this one stays put as the user drags / scales the content.
5091
+ * Used by the overlay to anchor the dashed border + drag body.
5092
+ */
5093
+ getActiveOutputFrameRect() {
5094
+ return this.engine.getOutputFrameRect?.() ?? null;
5095
+ }
5096
+ setKeyframesEnabled(enabled) {
5097
+ if (enabled === this.keyframesEnabled) return;
5098
+ this.keyframesEnabled = enabled;
5099
+ if (!enabled && this.selectedKeyframe) {
5100
+ this.selectedKeyframe = null;
5101
+ this.bus.emit("keyframeSelectionChange", { target: null });
5102
+ }
5103
+ this.bus.emit("keyframesEnabledChange", { enabled });
5104
+ this.ui.render();
5105
+ }
5106
+ isClipEdgeNavEnabled() {
5107
+ return this.clipEdgeNavEnabled;
5108
+ }
5109
+ setClipEdgeNavEnabled(enabled) {
5110
+ if (enabled === this.clipEdgeNavEnabled) return;
5111
+ this.clipEdgeNavEnabled = enabled;
5112
+ this.bus.emit("clipEdgeNavEnabledChange", { enabled });
5113
+ this.ui.render();
5114
+ }
5115
+ addKeyframe(clipId, prop, opts = {}) {
5116
+ const trk = findTrackOfClip(this.project, clipId);
5117
+ const cl = trk?.clips.find((c) => c.id === clipId);
5118
+ if (!trk || !cl) return null;
5119
+ const duration = clipDuration(cl);
5120
+ const playheadLocal = this.engine.getTime() - cl.start;
5121
+ const rawTime = opts.time ?? playheadLocal;
5122
+ const time = Math.max(0, Math.min(duration, Math.round(rawTime)));
5123
+ const value = opts.value ?? interpolateProp(cl, prop, time);
5124
+ this.pushHistory();
5125
+ cl.keyframes = upsertKeyframe(
5126
+ cl.keyframes,
5127
+ prop,
5128
+ time,
5129
+ value,
5130
+ () => createId("kf")
5131
+ );
5132
+ cl.keyframes.sort((a, b) => {
5133
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5134
+ return a.time - b.time;
5135
+ });
5136
+ this.afterMutation();
5137
+ const created = cl.keyframes.find(
5138
+ (k) => k.prop === prop && Math.abs(k.time - time) < 16
5139
+ );
5140
+ return created?.id ?? null;
5141
+ }
5142
+ removeKeyframe(clipId, keyframeId) {
5143
+ const trk = findTrackOfClip(this.project, clipId);
5144
+ const cl = trk?.clips.find((c) => c.id === clipId);
5145
+ if (!trk || !cl || !cl.keyframes) return false;
5146
+ const idx = cl.keyframes.findIndex((k) => k.id === keyframeId);
5147
+ if (idx < 0) return false;
5148
+ this.pushHistory();
5149
+ const next = cl.keyframes.slice();
5150
+ next.splice(idx, 1);
5151
+ cl.keyframes = next.length > 0 ? next : void 0;
5152
+ if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId && this.selectedKeyframe.keyframeId === keyframeId) {
5153
+ this.selectedKeyframe = null;
5154
+ this.bus.emit("keyframeSelectionChange", { target: null });
5155
+ }
5156
+ this.afterMutation();
5157
+ return true;
5158
+ }
5159
+ moveKeyframe(clipId, keyframeId, timeMs) {
5160
+ const trk = findTrackOfClip(this.project, clipId);
5161
+ const cl = trk?.clips.find((c) => c.id === clipId);
5162
+ const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
5163
+ if (!trk || !cl || !kf || !cl.keyframes) return false;
5164
+ const duration = clipDuration(cl);
5165
+ const clamped = Math.max(0, Math.min(duration, Math.round(timeMs)));
5166
+ if (clamped === kf.time) return false;
5167
+ if (cl.keyframes.some(
5168
+ (k) => k.id !== keyframeId && k.prop === kf.prop && k.time === clamped
5169
+ )) {
5170
+ return false;
5171
+ }
5172
+ this.pushHistory();
5173
+ kf.time = clamped;
5174
+ cl.keyframes.sort((a, b) => {
5175
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5176
+ return a.time - b.time;
5177
+ });
5178
+ this.afterMutation();
5179
+ return true;
5180
+ }
5181
+ setKeyframeValue(clipId, keyframeId, value) {
5182
+ const trk = findTrackOfClip(this.project, clipId);
5183
+ const cl = trk?.clips.find((c) => c.id === clipId);
5184
+ const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
5185
+ if (!trk || !cl || !kf) return false;
5186
+ if (Math.abs(kf.value - value) < 1e-9) return false;
5187
+ this.pushHistory();
5188
+ kf.value = value;
5189
+ this.afterMutation();
5190
+ return true;
5191
+ }
5192
+ setKeyframeEasing(clipId, keyframeId, easing) {
5193
+ const trk = findTrackOfClip(this.project, clipId);
5194
+ const cl = trk?.clips.find((c) => c.id === clipId);
5195
+ const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
5196
+ if (!trk || !cl || !kf) return false;
5197
+ const current = kf.easing ?? "linear";
5198
+ if (current === easing) return false;
5199
+ this.pushHistory();
5200
+ if (easing === "linear") {
5201
+ delete kf.easing;
5202
+ } else {
5203
+ kf.easing = easing;
5204
+ }
5205
+ this.afterMutation();
5206
+ return true;
5207
+ }
5208
+ setKeyframesEasingAtTime(clipId, timeMs, easing) {
5209
+ const trk = findTrackOfClip(this.project, clipId);
5210
+ const cl = trk?.clips.find((c) => c.id === clipId);
5211
+ if (!trk || !cl || !cl.keyframes) return false;
5212
+ const t = Math.round(timeMs);
5213
+ const matches = cl.keyframes.filter((k) => Math.abs(k.time - t) < 16);
5214
+ if (matches.length === 0) return false;
5215
+ const anyChange = matches.some((k) => (k.easing ?? "linear") !== easing);
5216
+ if (!anyChange) return false;
5217
+ this.pushHistory();
5218
+ for (const kf of matches) {
5219
+ if (easing === "linear") delete kf.easing;
5220
+ else kf.easing = easing;
5221
+ }
5222
+ this.afterMutation();
5223
+ return true;
5224
+ }
5225
+ setValueAtPlayhead(clipId, prop, value) {
5226
+ const trk = findTrackOfClip(this.project, clipId);
5227
+ const cl = trk?.clips.find((c) => c.id === clipId);
5228
+ if (!trk || !cl) return false;
5229
+ const duration = clipDuration(cl);
5230
+ const playheadLocal = this.engine.getTime() - cl.start;
5231
+ const time = Math.max(0, Math.min(duration, Math.round(playheadLocal)));
5232
+ const hasKf = cl.keyframes?.some((k) => k.prop === prop) ?? false;
5233
+ if (hasKf) {
5234
+ this.pushHistory();
5235
+ cl.keyframes = upsertKeyframe(
5236
+ cl.keyframes,
5237
+ prop,
5238
+ time,
5239
+ value,
5240
+ () => createId("kf")
5241
+ );
5242
+ cl.keyframes.sort((a, b) => {
5243
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5244
+ return a.time - b.time;
5245
+ });
5246
+ this.afterMutation();
5247
+ return true;
5248
+ }
5249
+ if ((cl[prop] ?? (prop === "scale" ? 1 : 0)) === value) return false;
5250
+ this.pushHistory();
5251
+ cl[prop] = value;
5252
+ this.afterMutation();
5253
+ return true;
5254
+ }
5255
+ getSelectedKeyframe() {
5256
+ return this.selectedKeyframe;
5257
+ }
5258
+ resetClipTransform(clipId) {
5259
+ const trk = findTrackOfClip(this.project, clipId);
5260
+ const cl = trk?.clips.find((c) => c.id === clipId);
5261
+ if (!trk || !cl) return false;
5262
+ const dirty = cl.keyframes && cl.keyframes.length > 0 || cl.panX !== void 0 || cl.panY !== void 0 || cl.scale !== void 0;
5263
+ if (!dirty) return false;
5264
+ this.pushHistory();
5265
+ delete cl.panX;
5266
+ delete cl.panY;
5267
+ delete cl.scale;
5268
+ cl.keyframes = void 0;
5269
+ if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId) {
5270
+ this.selectedKeyframe = null;
5271
+ this.bus.emit("keyframeSelectionChange", { target: null });
5272
+ }
5273
+ this.afterMutation();
5274
+ return true;
5275
+ }
5276
+ resetKeyframesAtTime(clipId, timeMs) {
5277
+ const trk = findTrackOfClip(this.project, clipId);
5278
+ const cl = trk?.clips.find((c) => c.id === clipId);
5279
+ if (!trk || !cl) return false;
5280
+ const duration = clipDuration(cl);
5281
+ const t = Math.max(0, Math.min(duration, Math.round(timeMs)));
5282
+ this.pushHistory();
5283
+ let kfs = cl.keyframes ?? [];
5284
+ kfs = upsertKeyframe(kfs, "panX", t, 0, () => createId("kf"));
5285
+ kfs = upsertKeyframe(kfs, "panY", t, 0, () => createId("kf"));
5286
+ kfs = upsertKeyframe(kfs, "scale", t, 1, () => createId("kf"));
5287
+ kfs.sort((a, b) => {
5288
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5289
+ return a.time - b.time;
5290
+ });
5291
+ cl.keyframes = kfs;
5292
+ this.afterMutation();
5293
+ return true;
5294
+ }
5295
+ seekToClipEdge(clipId, edge) {
5296
+ const trk = findTrackOfClip(this.project, clipId);
5297
+ const cl = trk?.clips.find((c) => c.id === clipId);
5298
+ if (!trk || !cl) return false;
5299
+ const target = edge === "start" ? cl.start : Math.max(cl.start, clipEnd(cl) - 1);
5300
+ if (this.engine.getTime() === target) return false;
5301
+ this.seek(target);
5302
+ return true;
5303
+ }
5304
+ seekToSelectedClipEdge(edge) {
5305
+ if (!this.selectedClipId) return false;
5306
+ return this.seekToClipEdge(this.selectedClipId, edge);
5307
+ }
5308
+ toggleKeyframeAtPlayhead() {
5309
+ const clipId = this.selectedClipId;
5310
+ if (!clipId) return false;
5311
+ const trk = findTrackOfClip(this.project, clipId);
5312
+ const cl = trk?.clips.find((c) => c.id === clipId);
5313
+ if (!trk || !cl) return false;
5314
+ const localMs = this.engine.getTime() - cl.start;
5315
+ const duration = clipDuration(cl);
5316
+ if (localMs < 0 || localMs > duration) return false;
5317
+ const t = Math.round(localMs);
5318
+ const existing = cl.keyframes?.filter((k) => Math.abs(k.time - t) < 16);
5319
+ if (existing && existing.length > 0) {
5320
+ this.pushHistory();
5321
+ const ids = new Set(existing.map((k) => k.id));
5322
+ const next = cl.keyframes.filter((k) => !ids.has(k.id));
5323
+ cl.keyframes = next.length > 0 ? next : void 0;
5324
+ if (this.selectedKeyframe && ids.has(this.selectedKeyframe.keyframeId)) {
5325
+ this.selectedKeyframe = null;
5326
+ this.bus.emit("keyframeSelectionChange", { target: null });
5327
+ }
5328
+ this.afterMutation();
5329
+ return true;
5330
+ }
5331
+ const current = getEffectiveTransform(cl, t);
5332
+ this.pushHistory();
5333
+ let kfs = cl.keyframes ?? [];
5334
+ kfs = upsertKeyframe(kfs, "panX", t, current.panX, () => createId("kf"));
5335
+ kfs = upsertKeyframe(kfs, "panY", t, current.panY, () => createId("kf"));
5336
+ kfs = upsertKeyframe(kfs, "scale", t, current.scale, () => createId("kf"));
5337
+ kfs.sort((a, b) => {
5338
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5339
+ return a.time - b.time;
5340
+ });
5341
+ cl.keyframes = kfs;
5342
+ const anchor = kfs.find(
5343
+ (k) => k.prop === "panX" && Math.abs(k.time - t) < 16
5344
+ );
5345
+ if (anchor) {
5346
+ this.selectedKeyframe = { clipId, keyframeId: anchor.id };
5347
+ this.bus.emit("keyframeSelectionChange", {
5348
+ target: this.selectedKeyframe
5349
+ });
5350
+ }
5351
+ this.afterMutation();
5352
+ return true;
5353
+ }
5354
+ setSelectedKeyframe(target) {
5355
+ if (target?.clipId === this.selectedKeyframe?.clipId && target?.keyframeId === this.selectedKeyframe?.keyframeId) {
5356
+ return;
5357
+ }
5358
+ this.selectedKeyframe = target;
5359
+ if (target && target.clipId !== this.selectedClipId) {
5360
+ this.selectedClipId = target.clipId;
5361
+ this.bus.emit("selectionChange", { clipId: target.clipId });
5362
+ }
5363
+ this.bus.emit("keyframeSelectionChange", { target });
3245
5364
  this.ui.render();
3246
5365
  }
3247
5366
  // ---- history --------------------------------------------------------
@@ -3255,6 +5374,7 @@ var Editor = class _Editor {
3255
5374
  const prev = this.history.undo(this.project);
3256
5375
  if (!prev) return false;
3257
5376
  this.project = prev;
5377
+ this.reconcileSelectionsWithProject();
3258
5378
  this.engine.setProject(this.project);
3259
5379
  this.bus.emit("change", { project: this.getProject() });
3260
5380
  this.emitHistory();
@@ -3265,12 +5385,57 @@ var Editor = class _Editor {
3265
5385
  const next = this.history.redo(this.project);
3266
5386
  if (!next) return false;
3267
5387
  this.project = next;
5388
+ this.reconcileSelectionsWithProject();
3268
5389
  this.engine.setProject(this.project);
3269
5390
  this.bus.emit("change", { project: this.getProject() });
3270
5391
  this.emitHistory();
3271
5392
  this.ui.render();
3272
5393
  return true;
3273
5394
  }
5395
+ beginInteraction() {
5396
+ this.interactionDepth += 1;
5397
+ }
5398
+ endInteraction() {
5399
+ if (this.interactionDepth === 0) return;
5400
+ this.interactionDepth -= 1;
5401
+ if (this.interactionDepth > 0) return;
5402
+ const snapshot = this.interactionStartSnapshot;
5403
+ this.interactionStartSnapshot = null;
5404
+ if (snapshot == null) return;
5405
+ const now = JSON.stringify(this.project);
5406
+ if (now === snapshot) return;
5407
+ this.history.push(JSON.parse(snapshot));
5408
+ this.emitHistory();
5409
+ }
5410
+ /**
5411
+ * Selections (clipId + selectedKeyframe) live OUTSIDE the project
5412
+ * snapshot, so undo / redo can leave them pointing at ids that no
5413
+ * longer exist. Defend against dangling refs by clearing anything
5414
+ * the restored project doesn't actually contain — and emit the
5415
+ * paired change events so panels / overlays hide cleanly instead
5416
+ * of holding zombie references.
5417
+ */
5418
+ reconcileSelectionsWithProject() {
5419
+ if (this.selectedKeyframe) {
5420
+ const trk = findTrackOfClip(this.project, this.selectedKeyframe.clipId);
5421
+ const cl = trk?.clips.find((c) => c.id === this.selectedKeyframe.clipId);
5422
+ const kf = cl?.keyframes?.find(
5423
+ (k) => k.id === this.selectedKeyframe.keyframeId
5424
+ );
5425
+ if (!kf) {
5426
+ this.selectedKeyframe = null;
5427
+ this.bus.emit("keyframeSelectionChange", { target: null });
5428
+ }
5429
+ }
5430
+ if (this.selectedClipId) {
5431
+ const trk = findTrackOfClip(this.project, this.selectedClipId);
5432
+ const cl = trk?.clips.find((c) => c.id === this.selectedClipId);
5433
+ if (!cl) {
5434
+ this.selectedClipId = null;
5435
+ this.bus.emit("selectionChange", { clipId: null });
5436
+ }
5437
+ }
5438
+ }
3274
5439
  // ---- events ---------------------------------------------------------
3275
5440
  on(event, handler) {
3276
5441
  return this.bus.on(event, handler);
@@ -3307,6 +5472,12 @@ var Editor = class _Editor {
3307
5472
  return null;
3308
5473
  }
3309
5474
  pushHistory() {
5475
+ if (this.interactionDepth > 0) {
5476
+ if (this.interactionStartSnapshot == null) {
5477
+ this.interactionStartSnapshot = JSON.stringify(this.project);
5478
+ }
5479
+ return;
5480
+ }
3310
5481
  this.history.push(this.project);
3311
5482
  this.emitHistory();
3312
5483
  }
@@ -3348,17 +5519,24 @@ function clampScale2(s) {
3348
5519
  return Math.max(MIN_PX_PER_SEC, Math.min(MAX_PX_PER_SEC, s));
3349
5520
  }
3350
5521
 
5522
+ exports.CanvasCompositorEngine = CanvasCompositorEngine;
3351
5523
  exports.Editor = Editor;
3352
5524
  exports.HEADER_WIDTH = HEADER_WIDTH;
3353
- exports.RULER_HEIGHT = RULER_HEIGHT;
3354
- exports.TRACK_HEIGHT = TRACK_HEIGHT;
5525
+ exports.HtmlVideoEngine = HtmlVideoEngine;
5526
+ exports.IDENTITY_TRANSFORM = IDENTITY_TRANSFORM;
3355
5527
  exports.Timeline = Timeline;
5528
+ exports.canvasCompositorEngineFactory = canvasCompositorEngineFactory;
3356
5529
  exports.createEmptyProject = createEmptyProject;
3357
5530
  exports.createId = createId;
3358
5531
  exports.formatLabel = formatLabel;
5532
+ exports.getEffectiveTransform = getEffectiveTransform;
5533
+ exports.getTransformAtTimelineTime = getTransformAtTimelineTime;
5534
+ exports.htmlVideoEngineFactory = htmlVideoEngineFactory;
5535
+ exports.isIdentityTransform = isIdentityTransform;
3359
5536
  exports.localeEn = localeEn;
3360
5537
  exports.localeZh = localeZh;
3361
5538
  exports.mergeLocale = mergeLocale;
3362
5539
  exports.normalizeProject = normalizeProject;
5540
+ exports.setTimelineMetrics = setTimelineMetrics;
3363
5541
  //# sourceMappingURL=index.cjs.map
3364
5542
  //# sourceMappingURL=index.cjs.map