@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.js CHANGED
@@ -1,5 +1,7 @@
1
- import { mergeLocale, applyTheme, formatLabel } from './chunk-CCDON7CU.js';
2
- export { formatLabel, localeEn, localeZh, mergeLocale } from './chunk-CCDON7CU.js';
1
+ import { getEffectiveTransform, interpolateProp, upsertKeyframe, hasKeyframesForProp } from './chunk-WTCK3XQ6.js';
2
+ export { IDENTITY_TRANSFORM, getEffectiveTransform, getTransformAtTimelineTime, isIdentityTransform } from './chunk-WTCK3XQ6.js';
3
+ import { mergeLocale, applyTheme, formatLabel } from './chunk-H6AY6NW4.js';
4
+ export { formatLabel, localeEn, localeZh, mergeLocale } from './chunk-H6AY6NW4.js';
3
5
 
4
6
  // src/events.ts
5
7
  var EventBus = class {
@@ -82,6 +84,19 @@ function createId(prefix = "id") {
82
84
  }
83
85
 
84
86
  // src/model.ts
87
+ var KEYFRAME_PROPS = ["panX", "panY", "scale"];
88
+ var DEFAULT_FPS = 30;
89
+ var BIG_FRAME_STEP = 10;
90
+ function projectFps(project) {
91
+ const f = project.fps;
92
+ return f != null && f > 0 ? f : DEFAULT_FPS;
93
+ }
94
+ function frameStepMs(project) {
95
+ return Math.max(1, Math.round(1e3 / projectFps(project)));
96
+ }
97
+ function bigFrameStepMs(project) {
98
+ return Math.max(1, Math.round(BIG_FRAME_STEP * 1e3 / projectFps(project)));
99
+ }
85
100
  function createEmptyProject() {
86
101
  return {
87
102
  version: 1,
@@ -118,11 +133,58 @@ function findTrackOfClip(project, clipId) {
118
133
  function normalizeProject(project) {
119
134
  const sources = project.sources.map((s) => ({ ...s }));
120
135
  const tracks = project.tracks.map((t) => {
121
- 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);
136
+ const clips = t.clips.filter((c) => c.out > c.in).map((c) => {
137
+ const out = { ...c, id: c.id || createId("clip") };
138
+ if (c.keyframes && c.keyframes.length > 0) {
139
+ const duration = c.out - c.in;
140
+ out.keyframes = migrateKeyframes(c.keyframes).filter((kf) => kf.time >= 0 && kf.time <= duration).map((kf) => ({ ...kf, id: kf.id || createId("kf") })).sort((a, b) => {
141
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
142
+ return a.time - b.time;
143
+ });
144
+ }
145
+ return out;
146
+ }).sort((a, b) => a.start - b.start);
122
147
  return { ...t, id: t.id || createId("track"), clips };
123
148
  });
124
149
  return { version: 1, sources, tracks };
125
150
  }
151
+ function migrateKeyframes(raw) {
152
+ const out = [];
153
+ for (const kf of raw) {
154
+ if ("prop" in kf && "value" in kf) {
155
+ out.push(kf);
156
+ continue;
157
+ }
158
+ const tuple = kf;
159
+ const id = tuple.id;
160
+ const t = tuple.time;
161
+ if (typeof tuple.x === "number") {
162
+ out.push({
163
+ id: id ? `${id}-px` : createId("kf"),
164
+ prop: "panX",
165
+ time: t,
166
+ value: tuple.x
167
+ });
168
+ }
169
+ if (typeof tuple.y === "number") {
170
+ out.push({
171
+ id: id ? `${id}-py` : createId("kf"),
172
+ prop: "panY",
173
+ time: t,
174
+ value: tuple.y
175
+ });
176
+ }
177
+ if (typeof tuple.scale === "number") {
178
+ out.push({
179
+ id: id ? `${id}-s` : createId("kf"),
180
+ prop: "scale",
181
+ time: t,
182
+ value: tuple.scale
183
+ });
184
+ }
185
+ }
186
+ return out;
187
+ }
126
188
  function splitClipAt(clip, localOffset) {
127
189
  const sourceLen = clip.out - clip.in;
128
190
  if (localOffset <= 0 || localOffset >= sourceLen) return null;
@@ -133,6 +195,47 @@ function splitClipAt(clip, localOffset) {
133
195
  in: clip.in + localOffset,
134
196
  start: clip.start + localOffset
135
197
  };
198
+ if (clip.keyframes && clip.keyframes.length > 0) {
199
+ const leftKf = [];
200
+ const rightKf = [];
201
+ for (const prop of KEYFRAME_PROPS) {
202
+ const propKfs = clip.keyframes.filter((k) => k.prop === prop);
203
+ if (propKfs.length === 0) continue;
204
+ const boundaryValue = interpolateProp(clip, prop, localOffset);
205
+ let leftSeamPresent = false;
206
+ let rightSeamPresent = false;
207
+ for (const kf of propKfs) {
208
+ if (kf.time < localOffset) {
209
+ leftKf.push(kf);
210
+ } else if (kf.time > localOffset) {
211
+ rightKf.push({ ...kf, id: createId("kf"), time: kf.time - localOffset });
212
+ } else {
213
+ leftKf.push(kf);
214
+ leftSeamPresent = true;
215
+ rightKf.push({ ...kf, id: createId("kf"), time: 0 });
216
+ rightSeamPresent = true;
217
+ }
218
+ }
219
+ if (!leftSeamPresent) {
220
+ leftKf.push({
221
+ id: createId("kf"),
222
+ prop,
223
+ time: localOffset,
224
+ value: boundaryValue
225
+ });
226
+ }
227
+ if (!rightSeamPresent) {
228
+ rightKf.push({
229
+ id: createId("kf"),
230
+ prop,
231
+ time: 0,
232
+ value: boundaryValue
233
+ });
234
+ }
235
+ }
236
+ left.keyframes = leftKf.length > 0 ? leftKf : void 0;
237
+ right.keyframes = rightKf.length > 0 ? rightKf : void 0;
238
+ }
136
239
  return [left, right];
137
240
  }
138
241
  function projectDuration(project) {
@@ -152,6 +255,19 @@ var HANDLE_PX = 8;
152
255
  var CLIP_INSET = 6;
153
256
  var SCALE_MIN = 10;
154
257
  var SCALE_MAX = 400;
258
+ var TIMELINE_PAD_LEFT = 12;
259
+ var TIMELINE_PAD_RIGHT = 12;
260
+ function contentLeftX(showHeader) {
261
+ return (showHeader ? HEADER_WIDTH : 0) + TIMELINE_PAD_LEFT;
262
+ }
263
+ function setTimelineMetrics(opts) {
264
+ if (opts.trackHeight != null && opts.trackHeight > 0) {
265
+ TRACK_HEIGHT = Math.round(opts.trackHeight);
266
+ }
267
+ if (opts.rulerHeight != null && opts.rulerHeight > 0) {
268
+ RULER_HEIGHT = Math.round(opts.rulerHeight);
269
+ }
270
+ }
155
271
  var SCROLLBAR_THICKNESS = 10;
156
272
  var SCROLLBAR_MIN_THUMB = 24;
157
273
  var SCROLLBAR_INSET = 2;
@@ -179,8 +295,10 @@ function trackIndexAt(y, trackCount, scrollTop = 0) {
179
295
  return idx;
180
296
  }
181
297
  function xToMs(x, pxPerSec, scrollLeft, showHeader) {
182
- const base = showHeader ? HEADER_WIDTH : 0;
183
- return Math.max(0, (x - base + scrollLeft) / pxPerSec * 1e3);
298
+ return Math.max(
299
+ 0,
300
+ (x - contentLeftX(showHeader) + scrollLeft) / pxPerSec * 1e3
301
+ );
184
302
  }
185
303
  function niceTickSeconds(targetSec) {
186
304
  if (targetSec <= 0) return 1;
@@ -208,6 +326,9 @@ function snapTargets(project, playheadMs, ignoreClipId) {
208
326
  for (const c of t.clips) {
209
327
  if (c.id === ignoreClipId) continue;
210
328
  arr.push(c.start, c.start + (c.out - c.in));
329
+ if (c.keyframes) {
330
+ for (const kf of c.keyframes) arr.push(c.start + kf.time);
331
+ }
211
332
  }
212
333
  }
213
334
  return arr;
@@ -254,30 +375,40 @@ function uncoveredIntervals(project) {
254
375
  return gaps;
255
376
  }
256
377
 
257
- // src/playback.ts
258
- var PlaybackEngine = class {
378
+ // src/playback/html-video.ts
379
+ var HtmlVideoEngine = class {
259
380
  host;
260
381
  mount;
261
- videos = /* @__PURE__ */ new Map();
382
+ sources = /* @__PURE__ */ new Map();
262
383
  project;
263
384
  currentClipId = null;
264
385
  playing = false;
265
386
  timeMs = 0;
266
387
  rafHandle = null;
267
388
  lastFrameTs = 0;
389
+ /** Permanent rAF that positions the active wrapper at the output
390
+ * rect + pushes keyframe transform onto the inner video via CSS. */
391
+ transformRaf = null;
268
392
  /** Public event hooks — set by Editor. */
269
393
  onTimeUpdate;
270
394
  onEnded;
271
395
  onError;
272
396
  onReady;
273
397
  onSourceMetadata;
274
- constructor(host, project) {
275
- this.host = host;
276
- this.project = project;
398
+ constructor(opts) {
399
+ this.host = opts.host;
400
+ this.project = opts.project;
277
401
  this.mount = document.createElement("div");
278
402
  this.mount.className = "aicut-preview";
403
+ Object.assign(this.mount.style, {
404
+ position: "absolute",
405
+ inset: "0",
406
+ width: "100%",
407
+ height: "100%"
408
+ });
279
409
  this.host.appendChild(this.mount);
280
410
  this.syncSources();
411
+ this.startTransformLoop();
281
412
  }
282
413
  setProject(next) {
283
414
  this.project = next;
@@ -300,9 +431,9 @@ var PlaybackEngine = class {
300
431
  if (this.timeMs < clip.start) this.timeMs = clip.start;
301
432
  this.activate(clip);
302
433
  this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
303
- const v = this.videos.get(clip.sourceId);
304
- if (!v) return;
305
- void v.play().catch((err) => this.onError?.(err));
434
+ const s = this.sources.get(clip.sourceId);
435
+ if (!s) return;
436
+ void s.video.play().catch((err) => this.onError?.(err));
306
437
  this.playing = true;
307
438
  this.startTickLoop();
308
439
  }
@@ -313,8 +444,7 @@ var PlaybackEngine = class {
313
444
  if (this.currentClipId) {
314
445
  const clip = this.clipById(this.currentClipId);
315
446
  if (clip) {
316
- const v = this.videos.get(clip.sourceId);
317
- v?.pause();
447
+ this.sources.get(clip.sourceId)?.video.pause();
318
448
  }
319
449
  }
320
450
  }
@@ -341,13 +471,417 @@ var PlaybackEngine = class {
341
471
  }
342
472
  this.onTimeUpdate?.(clamped);
343
473
  }
474
+ /**
475
+ * The OUTPUT frame — the fixed stage the rendered video is clipped
476
+ * to. Independent of the keyframe transform. Used by the overlay to
477
+ * draw the dashed border at a stable position.
478
+ */
479
+ getOutputFrameRect() {
480
+ return this.baseFrameRect();
481
+ }
482
+ /**
483
+ * The CONTENT frame — where the transformed video pixels actually
484
+ * land. Equal to the output frame when transform is identity; may
485
+ * extend outside (zoom in) or fit inside (zoom out) when not.
486
+ */
487
+ getFrameRect() {
488
+ const base = this.baseFrameRect();
489
+ if (!base) return null;
490
+ const clip = this.clipById(this.currentClipId);
491
+ if (!clip) return base;
492
+ const t = getEffectiveTransform(clip, this.timeMs - clip.start);
493
+ const cx = base.x + base.w / 2 + t.panX;
494
+ const cy = base.y + base.h / 2 + t.panY;
495
+ const w = base.w * t.scale;
496
+ const h = base.h * t.scale;
497
+ return { x: cx - w / 2, y: cy - h / 2, w, h };
498
+ }
499
+ /** Untransformed contain-letterbox rect — the OUTPUT frame. */
500
+ baseFrameRect() {
501
+ if (!this.currentClipId) return null;
502
+ const clip = this.clipById(this.currentClipId);
503
+ if (!clip) return null;
504
+ const s = this.sources.get(clip.sourceId);
505
+ if (!s) return null;
506
+ const v = s.video;
507
+ if (v.videoWidth === 0 || v.videoHeight === 0) return null;
508
+ const hostRect = this.host.getBoundingClientRect();
509
+ const cw = hostRect.width;
510
+ const ch = hostRect.height;
511
+ if (cw === 0 || ch === 0) return null;
512
+ const scale = Math.min(cw / v.videoWidth, ch / v.videoHeight);
513
+ const w = v.videoWidth * scale;
514
+ const h = v.videoHeight * scale;
515
+ return { x: (cw - w) / 2, y: (ch - h) / 2, w, h };
516
+ }
517
+ /**
518
+ * Permanent rAF that (a) sizes + positions the active wrapper to
519
+ * the output frame, and (b) writes the keyframe transform onto the
520
+ * inner video. Negligible cost — three style writes per frame max.
521
+ */
522
+ startTransformLoop() {
523
+ const tick = () => {
524
+ this.applyTransforms();
525
+ this.transformRaf = requestAnimationFrame(tick);
526
+ };
527
+ this.transformRaf = requestAnimationFrame(tick);
528
+ }
529
+ applyTransforms() {
530
+ const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
531
+ const outRect = this.baseFrameRect();
532
+ if (clip && outRect) {
533
+ const s = this.sources.get(clip.sourceId);
534
+ if (s) {
535
+ Object.assign(s.wrapper.style, {
536
+ left: `${outRect.x}px`,
537
+ top: `${outRect.y}px`,
538
+ width: `${outRect.w}px`,
539
+ height: `${outRect.h}px`
540
+ });
541
+ const t = getEffectiveTransform(clip, this.timeMs - clip.start);
542
+ s.video.style.transform = `translate(${t.panX.toFixed(2)}px, ${t.panY.toFixed(2)}px) scale(${t.scale.toFixed(4)})`;
543
+ }
544
+ }
545
+ for (const [id, s] of this.sources) {
546
+ if (clip && id === clip.sourceId) continue;
547
+ if (s.video.style.transform) s.video.style.transform = "";
548
+ }
549
+ }
550
+ destroy() {
551
+ this.stopTickLoop();
552
+ if (this.transformRaf != null) {
553
+ cancelAnimationFrame(this.transformRaf);
554
+ this.transformRaf = null;
555
+ }
556
+ for (const s of this.sources.values()) {
557
+ s.video.pause();
558
+ s.video.removeAttribute("src");
559
+ s.video.load();
560
+ s.wrapper.remove();
561
+ }
562
+ this.sources.clear();
563
+ this.mount.remove();
564
+ }
565
+ // --- internals -------------------------------------------------------
566
+ syncSources() {
567
+ const wanted = new Set(this.project.sources.map((s) => s.id));
568
+ for (const [id, s] of this.sources) {
569
+ if (!wanted.has(id)) {
570
+ s.video.pause();
571
+ s.wrapper.remove();
572
+ this.sources.delete(id);
573
+ }
574
+ }
575
+ for (const src of this.project.sources) {
576
+ if (src.kind !== "video") continue;
577
+ if (this.sources.has(src.id)) continue;
578
+ const wrapper = document.createElement("div");
579
+ wrapper.className = "aicut-preview-clip";
580
+ Object.assign(wrapper.style, {
581
+ position: "absolute",
582
+ overflow: "hidden",
583
+ visibility: "hidden",
584
+ // Initial bounds — applyTransforms overrides each frame.
585
+ left: "0",
586
+ top: "0",
587
+ width: "0",
588
+ height: "0"
589
+ });
590
+ const v = document.createElement("video");
591
+ v.preload = "auto";
592
+ v.playsInline = true;
593
+ v.muted = false;
594
+ v.src = src.url;
595
+ Object.assign(v.style, {
596
+ position: "absolute",
597
+ inset: "0",
598
+ width: "100%",
599
+ height: "100%",
600
+ objectFit: "fill",
601
+ // Transform origin at center so scale() scales around the
602
+ // video's centroid, not its top-left corner.
603
+ transformOrigin: "50% 50%"
604
+ });
605
+ const sourceId = src.id;
606
+ v.addEventListener(
607
+ "error",
608
+ () => this.onError?.(new Error(`Failed to load ${src.url}`))
609
+ );
610
+ v.addEventListener("loadedmetadata", () => {
611
+ this.onReady?.();
612
+ const durMs = Math.round(v.duration * 1e3);
613
+ if (Number.isFinite(durMs) && durMs > 0) {
614
+ this.onSourceMetadata?.(sourceId, durMs);
615
+ }
616
+ });
617
+ wrapper.appendChild(v);
618
+ this.mount.appendChild(wrapper);
619
+ this.sources.set(src.id, { wrapper, video: v });
620
+ }
621
+ }
622
+ activate(clip) {
623
+ if (clip?.id === this.currentClipId) return;
624
+ if (this.currentClipId) {
625
+ const prev = this.clipById(this.currentClipId);
626
+ if (prev) {
627
+ const s = this.sources.get(prev.sourceId);
628
+ if (s) {
629
+ s.video.pause();
630
+ s.wrapper.style.visibility = "hidden";
631
+ }
632
+ }
633
+ }
634
+ this.currentClipId = clip ? clip.id : null;
635
+ if (clip) {
636
+ const s = this.sources.get(clip.sourceId);
637
+ if (s) s.wrapper.style.visibility = "visible";
638
+ }
639
+ }
640
+ seekVideoToClipOffset(clip, offsetMs) {
641
+ const s = this.sources.get(clip.sourceId);
642
+ if (!s) return;
643
+ const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
644
+ if (Math.abs(s.video.currentTime - target) > 0.05) {
645
+ s.video.currentTime = target;
646
+ }
647
+ }
648
+ clipById(id) {
649
+ for (const t of this.project.tracks) {
650
+ for (const c of t.clips) if (c.id === id) return c;
651
+ }
652
+ return null;
653
+ }
654
+ /**
655
+ * Find the clip whose timeline range contains `timeMs`, searching
656
+ * across ALL video tracks. If multiple tracks have a clip at this
657
+ * moment, the lowest-index track wins.
658
+ */
659
+ clipAtTime(timeMs) {
660
+ for (const t of this.project.tracks) {
661
+ if (t.kind !== "video") continue;
662
+ for (const c of t.clips) {
663
+ if (timeMs >= c.start && timeMs < c.start + (c.out - c.in)) return c;
664
+ }
665
+ }
666
+ return null;
667
+ }
668
+ /** Earliest clip starting at-or-after `timeMs` across all video tracks. */
669
+ nextClipAfterTime(timeMs) {
670
+ let best = null;
671
+ for (const t of this.project.tracks) {
672
+ if (t.kind !== "video") continue;
673
+ for (const c of t.clips) {
674
+ if (c.start >= timeMs && (!best || c.start < best.start)) best = c;
675
+ }
676
+ }
677
+ return best;
678
+ }
679
+ /** Max clip end across all video tracks. */
680
+ totalDuration() {
681
+ let max = 0;
682
+ for (const t of this.project.tracks) {
683
+ if (t.kind !== "video") continue;
684
+ for (const c of t.clips) {
685
+ const e = c.start + (c.out - c.in);
686
+ if (e > max) max = e;
687
+ }
688
+ }
689
+ return max;
690
+ }
691
+ startTickLoop() {
692
+ this.lastFrameTs = performance.now();
693
+ const tick = (now) => {
694
+ if (!this.playing) return;
695
+ const dtMs = now - this.lastFrameTs;
696
+ this.lastFrameTs = now;
697
+ this.advance(dtMs);
698
+ this.rafHandle = requestAnimationFrame(tick);
699
+ };
700
+ this.rafHandle = requestAnimationFrame(tick);
701
+ }
702
+ stopTickLoop() {
703
+ if (this.rafHandle != null) {
704
+ cancelAnimationFrame(this.rafHandle);
705
+ this.rafHandle = null;
706
+ }
707
+ }
708
+ advance(dtMs) {
709
+ if (this.project.tracks.length === 0) return;
710
+ this.timeMs += dtMs;
711
+ const totalDur = this.totalDuration();
712
+ if (this.timeMs >= totalDur) {
713
+ this.timeMs = totalDur;
714
+ this.onTimeUpdate?.(this.timeMs);
715
+ this.pause();
716
+ this.onEnded?.();
717
+ return;
718
+ }
719
+ const clip = this.clipAtTime(this.timeMs);
720
+ if (!clip) {
721
+ const next = this.nextClipAfterTime(this.timeMs);
722
+ if (next) {
723
+ this.timeMs = next.start;
724
+ this.activate(next);
725
+ this.seekVideoToClipOffset(next, 0);
726
+ const s = this.sources.get(next.sourceId);
727
+ if (s) void s.video.play().catch((err) => this.onError?.(err));
728
+ } else {
729
+ this.pause();
730
+ this.onEnded?.();
731
+ return;
732
+ }
733
+ } else if (clip.id !== this.currentClipId) {
734
+ this.activate(clip);
735
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
736
+ const s = this.sources.get(clip.sourceId);
737
+ if (s) void s.video.play().catch((err) => this.onError?.(err));
738
+ }
739
+ this.onTimeUpdate?.(this.timeMs);
740
+ }
741
+ };
742
+ var htmlVideoEngineFactory = (opts) => new HtmlVideoEngine(opts);
743
+
744
+ // src/playback/canvas-compositor.ts
745
+ var CanvasCompositorEngine = class {
746
+ host;
747
+ mount;
748
+ canvas;
749
+ ctx;
750
+ /** Only created when constructed with `debug: true`. */
751
+ badge = null;
752
+ videos = /* @__PURE__ */ new Map();
753
+ project;
754
+ currentClipId = null;
755
+ playing = false;
756
+ timeMs = 0;
757
+ rafHandle = null;
758
+ lastFrameTs = 0;
759
+ paintedFrames = 0;
760
+ /** Output frame rect (no transform) — fixed bounds. */
761
+ lastOutputRect = null;
762
+ /** Post-transform content rect. */
763
+ lastFrameRect = null;
764
+ onTimeUpdate;
765
+ onEnded;
766
+ onError;
767
+ onReady;
768
+ onSourceMetadata;
769
+ constructor(opts) {
770
+ this.host = opts.host;
771
+ this.project = opts.project;
772
+ this.mount = document.createElement("div");
773
+ this.mount.className = "aicut-preview aicut-preview--canvas";
774
+ Object.assign(this.mount.style, {
775
+ position: "absolute",
776
+ inset: "0",
777
+ width: "100%",
778
+ height: "100%"
779
+ });
780
+ this.canvas = document.createElement("canvas");
781
+ Object.assign(this.canvas.style, {
782
+ position: "absolute",
783
+ inset: "0",
784
+ width: "100%",
785
+ height: "100%",
786
+ // Stretch with letterboxing handled by the draw loop.
787
+ objectFit: "contain",
788
+ // Black until the first frame is drawn so the swap from the
789
+ // previous engine doesn't flash the host background.
790
+ background: "#000"
791
+ });
792
+ this.mount.appendChild(this.canvas);
793
+ const ctx = this.canvas.getContext("2d");
794
+ if (!ctx) throw new Error("CanvasCompositorEngine: 2d context unavailable");
795
+ this.ctx = ctx;
796
+ if (opts.debug) {
797
+ const badge = document.createElement("div");
798
+ badge.className = "aicut-preview__badge";
799
+ Object.assign(badge.style, {
800
+ position: "absolute",
801
+ top: "8px",
802
+ left: "8px",
803
+ padding: "4px 8px",
804
+ borderRadius: "6px",
805
+ background: "rgba(0, 0, 0, 0.55)",
806
+ color: "rgba(255, 255, 255, 0.92)",
807
+ font: "11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
808
+ pointerEvents: "none",
809
+ zIndex: "2",
810
+ letterSpacing: "0.02em"
811
+ });
812
+ badge.textContent = "engine: canvas compositor";
813
+ this.mount.appendChild(badge);
814
+ this.badge = badge;
815
+ }
816
+ this.host.appendChild(this.mount);
817
+ this.syncSources();
818
+ this.resizeCanvas();
819
+ this.startTickLoop();
820
+ }
821
+ setProject(next) {
822
+ this.project = next;
823
+ this.syncSources();
824
+ const clip = this.clipAtTime(this.timeMs);
825
+ if (!clip) {
826
+ this.timeMs = 0;
827
+ this.activate(null);
828
+ this.onTimeUpdate?.(0);
829
+ } else {
830
+ this.activate(clip);
831
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
832
+ }
833
+ }
834
+ play() {
835
+ if (this.playing) return;
836
+ if (this.totalDuration() <= 0) return;
837
+ const clip = this.clipAtTime(this.timeMs) ?? this.nextClipAfterTime(this.timeMs);
838
+ if (!clip) return;
839
+ if (this.timeMs < clip.start) this.timeMs = clip.start;
840
+ this.activate(clip);
841
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
842
+ const v = this.videos.get(clip.sourceId);
843
+ if (!v) return;
844
+ void v.play().catch((err) => this.onError?.(err));
845
+ this.playing = true;
846
+ this.lastFrameTs = performance.now();
847
+ }
848
+ pause() {
849
+ if (!this.playing) return;
850
+ this.playing = false;
851
+ if (this.currentClipId) {
852
+ const clip = this.clipById(this.currentClipId);
853
+ if (clip) this.videos.get(clip.sourceId)?.pause();
854
+ }
855
+ }
856
+ isPlaying() {
857
+ return this.playing;
858
+ }
859
+ getTime() {
860
+ return this.timeMs;
861
+ }
862
+ seek(timeMs) {
863
+ const total = this.totalDuration();
864
+ if (total <= 0) {
865
+ this.timeMs = 0;
866
+ return;
867
+ }
868
+ const clamped = Math.max(0, Math.min(timeMs, total));
869
+ this.timeMs = clamped;
870
+ const clip = this.clipAtTime(clamped);
871
+ if (clip) {
872
+ this.activate(clip);
873
+ this.seekVideoToClipOffset(clip, clamped - clip.start);
874
+ } else {
875
+ this.activate(null);
876
+ }
877
+ this.onTimeUpdate?.(clamped);
878
+ }
344
879
  destroy() {
345
880
  this.stopTickLoop();
346
881
  for (const v of this.videos.values()) {
347
882
  v.pause();
348
883
  v.removeAttribute("src");
349
884
  v.load();
350
- v.remove();
351
885
  }
352
886
  this.videos.clear();
353
887
  this.mount.remove();
@@ -358,7 +892,6 @@ var PlaybackEngine = class {
358
892
  for (const [id, v] of this.videos) {
359
893
  if (!wanted.has(id)) {
360
894
  v.pause();
361
- v.remove();
362
895
  this.videos.delete(id);
363
896
  }
364
897
  }
@@ -370,12 +903,6 @@ var PlaybackEngine = class {
370
903
  v.playsInline = true;
371
904
  v.muted = false;
372
905
  v.src = src.url;
373
- v.style.position = "absolute";
374
- v.style.inset = "0";
375
- v.style.width = "100%";
376
- v.style.height = "100%";
377
- v.style.objectFit = "contain";
378
- v.style.visibility = "hidden";
379
906
  const sourceId = src.id;
380
907
  v.addEventListener(
381
908
  "error",
@@ -388,7 +915,6 @@ var PlaybackEngine = class {
388
915
  this.onSourceMetadata?.(sourceId, durMs);
389
916
  }
390
917
  });
391
- this.mount.appendChild(v);
392
918
  this.videos.set(src.id, v);
393
919
  }
394
920
  }
@@ -396,19 +922,9 @@ var PlaybackEngine = class {
396
922
  if (clip?.id === this.currentClipId) return;
397
923
  if (this.currentClipId) {
398
924
  const prev = this.clipById(this.currentClipId);
399
- if (prev) {
400
- const v = this.videos.get(prev.sourceId);
401
- if (v) {
402
- v.pause();
403
- v.style.visibility = "hidden";
404
- }
405
- }
925
+ if (prev) this.videos.get(prev.sourceId)?.pause();
406
926
  }
407
927
  this.currentClipId = clip ? clip.id : null;
408
- if (clip) {
409
- const v = this.videos.get(clip.sourceId);
410
- if (v) v.style.visibility = "visible";
411
- }
412
928
  }
413
929
  seekVideoToClipOffset(clip, offsetMs) {
414
930
  const v = this.videos.get(clip.sourceId);
@@ -424,14 +940,6 @@ var PlaybackEngine = class {
424
940
  }
425
941
  return null;
426
942
  }
427
- /**
428
- * Find the clip whose timeline range contains `timeMs`, searching
429
- * across ALL video tracks. If multiple tracks have a clip at this
430
- * moment, the lowest-index track wins (matches the "Track 1 is
431
- * background" convention used in the auto-split UX — overlapping
432
- * placements would have created a new track on top, but here we
433
- * fall back to the underlying clip).
434
- */
435
943
  clipAtTime(timeMs) {
436
944
  for (const t of this.project.tracks) {
437
945
  if (t.kind !== "video") continue;
@@ -441,7 +949,6 @@ var PlaybackEngine = class {
441
949
  }
442
950
  return null;
443
951
  }
444
- /** Earliest clip starting at-or-after `timeMs` across all video tracks. */
445
952
  nextClipAfterTime(timeMs) {
446
953
  let best = null;
447
954
  for (const t of this.project.tracks) {
@@ -452,7 +959,6 @@ var PlaybackEngine = class {
452
959
  }
453
960
  return best;
454
961
  }
455
- /** Max clip end across all video tracks. */
456
962
  totalDuration() {
457
963
  let max = 0;
458
964
  for (const t of this.project.tracks) {
@@ -464,13 +970,26 @@ var PlaybackEngine = class {
464
970
  }
465
971
  return max;
466
972
  }
973
+ resizeCanvas() {
974
+ const rect = this.mount.getBoundingClientRect();
975
+ const dpr = window.devicePixelRatio || 1;
976
+ const w = Math.max(1, Math.floor(rect.width * dpr));
977
+ const h = Math.max(1, Math.floor(rect.height * dpr));
978
+ if (this.canvas.width !== w || this.canvas.height !== h) {
979
+ this.canvas.width = w;
980
+ this.canvas.height = h;
981
+ }
982
+ }
467
983
  startTickLoop() {
468
984
  this.lastFrameTs = performance.now();
469
985
  const tick = (now) => {
470
- if (!this.playing) return;
471
- const dtMs = now - this.lastFrameTs;
472
- this.lastFrameTs = now;
473
- this.advance(dtMs);
986
+ this.resizeCanvas();
987
+ if (this.playing) {
988
+ const dtMs = now - this.lastFrameTs;
989
+ this.lastFrameTs = now;
990
+ this.advance(dtMs);
991
+ }
992
+ this.paint();
474
993
  this.rafHandle = requestAnimationFrame(tick);
475
994
  };
476
995
  this.rafHandle = requestAnimationFrame(tick);
@@ -514,7 +1033,74 @@ var PlaybackEngine = class {
514
1033
  }
515
1034
  this.onTimeUpdate?.(this.timeMs);
516
1035
  }
1036
+ /**
1037
+ * One paint per rAF — clears the canvas, draws the current active
1038
+ * video frame letterboxed to fit, then refreshes the HUD. Done
1039
+ * unconditionally (not just on `playing`) so the HUD frame counter
1040
+ * and the seek preview both update when paused.
1041
+ */
1042
+ paint() {
1043
+ const cw = this.canvas.width;
1044
+ const ch = this.canvas.height;
1045
+ this.ctx.clearRect(0, 0, cw, ch);
1046
+ const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
1047
+ const v = clip ? this.videos.get(clip.sourceId) : null;
1048
+ if (v && v.videoWidth > 0 && v.videoHeight > 0 && clip) {
1049
+ const vw = v.videoWidth;
1050
+ const vh = v.videoHeight;
1051
+ const baseScale = Math.min(cw / vw, ch / vh);
1052
+ const dw = vw * baseScale;
1053
+ const dh = vh * baseScale;
1054
+ const cx = cw / 2;
1055
+ const cy = ch / 2;
1056
+ const dpr = window.devicePixelRatio || 1;
1057
+ const t = getEffectiveTransform(clip, this.timeMs - clip.start);
1058
+ const outX = (cw - dw) / 2;
1059
+ const outY = (ch - dh) / 2;
1060
+ this.ctx.save();
1061
+ this.ctx.beginPath();
1062
+ this.ctx.rect(outX, outY, dw, dh);
1063
+ this.ctx.clip();
1064
+ this.ctx.translate(cx + t.panX * dpr, cy + t.panY * dpr);
1065
+ this.ctx.scale(t.scale, t.scale);
1066
+ this.ctx.drawImage(v, -dw / 2, -dh / 2, dw, dh);
1067
+ this.ctx.restore();
1068
+ this.paintedFrames += 1;
1069
+ this.lastOutputRect = {
1070
+ x: outX / dpr,
1071
+ y: outY / dpr,
1072
+ w: dw / dpr,
1073
+ h: dh / dpr
1074
+ };
1075
+ const cssCx = cw / (2 * dpr) + t.panX;
1076
+ const cssCy = ch / (2 * dpr) + t.panY;
1077
+ const cssW = dw * t.scale / dpr;
1078
+ const cssH = dh * t.scale / dpr;
1079
+ this.lastFrameRect = {
1080
+ x: cssCx - cssW / 2,
1081
+ y: cssCy - cssH / 2,
1082
+ w: cssW,
1083
+ h: cssH
1084
+ };
1085
+ } else {
1086
+ this.lastFrameRect = null;
1087
+ this.lastOutputRect = null;
1088
+ }
1089
+ this.updateBadge();
1090
+ }
1091
+ getOutputFrameRect() {
1092
+ return this.lastOutputRect;
1093
+ }
1094
+ getFrameRect() {
1095
+ return this.lastFrameRect;
1096
+ }
1097
+ updateBadge() {
1098
+ if (!this.badge) return;
1099
+ const sec = (this.timeMs / 1e3).toFixed(2);
1100
+ this.badge.textContent = `engine: canvas compositor \u2022 t=${sec}s \u2022 frames painted: ${this.paintedFrames}`;
1101
+ }
517
1102
  };
1103
+ var canvasCompositorEngineFactory = (opts) => new CanvasCompositorEngine(opts);
518
1104
 
519
1105
  // src/ui/thumbnails.ts
520
1106
  var THUMB_HEIGHT = 44;
@@ -574,11 +1160,19 @@ var ThumbnailRibbon = class {
574
1160
  /**
575
1161
  * Paint thumbnails for the clip's visible window onto `ctx`. The
576
1162
  * canvas is the per-clip strip — width = clip's px width, height =
577
- * THUMB_HEIGHT. Source-time range derives from the clip's `in/out`
578
- * and the px range we're drawing into.
1163
+ * `pxHeight` (defaults to the cached `THUMB_HEIGHT`). Source-time
1164
+ * range derives from the clip's `in/out` and the px range we're
1165
+ * drawing into.
1166
+ *
1167
+ * `pxHeight` lets the caller stretch thumbs to fill a taller clip
1168
+ * body when `trackHeight` is configured above the default. Aspect
1169
+ * ratio is already broken per-thumb (we slice variable widths from a
1170
+ * fixed-aspect cached bitmap), so stretching height too is fine — it
1171
+ * preserves the "filmstrip" look without leaving an empty bottom
1172
+ * band of the brand gradient showing through.
579
1173
  */
580
- paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth) {
581
- ctx.clearRect(0, 0, pxWidth, THUMB_HEIGHT);
1174
+ paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth, pxHeight = THUMB_HEIGHT) {
1175
+ ctx.clearRect(0, 0, pxWidth, pxHeight);
582
1176
  const st = this.sources.get(sourceId);
583
1177
  if (!st) return;
584
1178
  if (sourceOutMs <= sourceInMs || pxWidth <= 0) return;
@@ -591,10 +1185,10 @@ var ThumbnailRibbon = class {
591
1185
  const x = Math.round(i * pxWidth / count);
592
1186
  const w = Math.round((i + 1) * pxWidth / count) - x;
593
1187
  if (bmp) {
594
- ctx.drawImage(bmp, x, 0, w, THUMB_HEIGHT);
1188
+ ctx.drawImage(bmp, x, 0, w, pxHeight);
595
1189
  } else {
596
1190
  ctx.fillStyle = "rgba(255,255,255,0.04)";
597
- ctx.fillRect(x, 0, w, THUMB_HEIGHT);
1191
+ ctx.fillRect(x, 0, w, pxHeight);
598
1192
  this.enqueue(st, bucket);
599
1193
  }
600
1194
  }
@@ -685,7 +1279,7 @@ function drawAll(ctx, state, style, thumbs) {
685
1279
  const { viewportWidth: W, viewportHeight: H } = state;
686
1280
  ctx.fillStyle = style.bg;
687
1281
  ctx.fillRect(0, 0, W, H);
688
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1282
+ const baseX = contentLeftX(state.showHeader);
689
1283
  const trackAreaW = W - baseX - SCROLLBAR_THICKNESS;
690
1284
  const trackAreaH = H - RULER_HEIGHT - SCROLLBAR_THICKNESS;
691
1285
  ctx.save();
@@ -725,15 +1319,21 @@ function drawAll(ctx, state, style, thumbs) {
725
1319
  ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
726
1320
  ctx.clip();
727
1321
  drawSnapGuide(ctx, state, style);
728
- drawPlayhead(ctx, state, style);
729
1322
  ctx.restore();
730
1323
  drawScrollbarV(ctx, state, style);
731
1324
  drawScrollbarH(ctx, state, style);
1325
+ const playheadLeft = state.showHeader ? HEADER_WIDTH : 0;
1326
+ ctx.save();
1327
+ ctx.beginPath();
1328
+ ctx.rect(playheadLeft, 0, W - playheadLeft, H);
1329
+ ctx.clip();
1330
+ drawPlayhead(ctx, state, style);
1331
+ ctx.restore();
732
1332
  }
733
1333
  function drawCoverageGaps(ctx, state, style) {
734
1334
  const gaps = uncoveredIntervals(state.project);
735
1335
  if (gaps.length === 0) return;
736
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1336
+ const baseX = contentLeftX(state.showHeader);
737
1337
  const trackStackH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
738
1338
  for (const [s, e] of gaps) {
739
1339
  const x1 = Math.max(
@@ -772,7 +1372,7 @@ function drawDragGhost(ctx, state, style, thumbs) {
772
1372
  }
773
1373
  }
774
1374
  if (!real) return;
775
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1375
+ const baseX = contentLeftX(state.showHeader);
776
1376
  const widthPx = Math.max(2, (real.out - real.in) / 1e3 * state.pxPerSec);
777
1377
  const startX = baseX + ghost.ghostStart / 1e3 * state.pxPerSec - state.scrollLeft;
778
1378
  const overlap = ghost.wouldOverlap;
@@ -842,7 +1442,7 @@ function drawPhantomRow(ctx, trackIndex, baseX, state, style) {
842
1442
  }
843
1443
  function drawRuler(ctx, state, style) {
844
1444
  const { pxPerSec, scrollLeft, viewportWidth: W } = state;
845
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1445
+ const baseX = contentLeftX(state.showHeader);
846
1446
  const rulerW = W - baseX;
847
1447
  ctx.fillStyle = style.bg;
848
1448
  ctx.fillRect(baseX, 0, rulerW, RULER_HEIGHT);
@@ -888,7 +1488,7 @@ function drawTracks(ctx, state, style, thumbs) {
888
1488
  }
889
1489
  function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
890
1490
  const { viewportWidth: W } = state;
891
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1491
+ const baseX = contentLeftX(state.showHeader);
892
1492
  const y = trackY(trackIndex);
893
1493
  ctx.fillStyle = style.trackBg;
894
1494
  ctx.fillRect(baseX, y, W - baseX, TRACK_HEIGHT);
@@ -926,7 +1526,7 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
926
1526
  }
927
1527
  function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumbs, dim, warn) {
928
1528
  const { pxPerSec, scrollLeft } = state;
929
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1529
+ const baseX = contentLeftX(state.showHeader);
930
1530
  const startX = baseX + startMs / 1e3 * pxPerSec - scrollLeft;
931
1531
  const widthPx = Math.max(2, (clip.out - clip.in) / 1e3 * pxPerSec);
932
1532
  const y = trackY(trackIndex) + CLIP_INSET;
@@ -949,7 +1549,7 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
949
1549
  roundRect(ctx, startX, y, widthPx, h, 6);
950
1550
  ctx.clip();
951
1551
  ctx.translate(startX, y);
952
- thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx);
1552
+ thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx, h);
953
1553
  ctx.restore();
954
1554
  ctx.strokeStyle = "rgba(255,255,255,0.2)";
955
1555
  ctx.lineWidth = 1;
@@ -975,6 +1575,34 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
975
1575
  ctx.fillRect(startX + 2, y + 12, 2, h - 24);
976
1576
  ctx.fillRect(startX + widthPx - 4, y + 12, 2, h - 24);
977
1577
  }
1578
+ if (!dim && state.keyframesEnabled && clip.keyframes && clip.keyframes.length > 0) {
1579
+ const diamondY = y + h / 2;
1580
+ const halfSize = 5;
1581
+ const moments = groupKeyframesByTime(clip.keyframes, 16);
1582
+ const ghost = state.keyframeDragGhost;
1583
+ for (const moment of moments) {
1584
+ const draggedHere = ghost ? moment.kfs.find(
1585
+ (k) => ghost.clipId === clip.id && ghost.keyframeId === k.id
1586
+ ) : void 0;
1587
+ const effectiveTime = draggedHere ? ghost.ghostTimeMs : moment.time;
1588
+ const kfX = startX + effectiveTime / 1e3 * pxPerSec;
1589
+ if (kfX < baseX - halfSize || kfX > state.viewportWidth + halfSize) continue;
1590
+ const isSelected = state.selectedKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.selectedKeyframe?.keyframeId);
1591
+ const isHovered = state.hoveredKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.hoveredKeyframe?.keyframeId);
1592
+ const drawSize = isHovered ? halfSize + 1.5 : halfSize;
1593
+ ctx.beginPath();
1594
+ ctx.moveTo(kfX, diamondY - drawSize);
1595
+ ctx.lineTo(kfX + drawSize, diamondY);
1596
+ ctx.lineTo(kfX, diamondY + drawSize);
1597
+ ctx.lineTo(kfX - drawSize, diamondY);
1598
+ ctx.closePath();
1599
+ ctx.fillStyle = isSelected ? style.selectedRing : isHovered ? "#ffffff" : withAlpha(style.text, 0.85);
1600
+ ctx.fill();
1601
+ ctx.strokeStyle = isHovered ? style.selectedRing : "rgba(0, 0, 0, 0.65)";
1602
+ ctx.lineWidth = isHovered ? 1.5 : 1;
1603
+ ctx.stroke();
1604
+ }
1605
+ }
978
1606
  ctx.restore();
979
1607
  }
980
1608
  function drawHeaders(ctx, state, style) {
@@ -1025,7 +1653,7 @@ function drawHeaders(ctx, state, style) {
1025
1653
  }
1026
1654
  }
1027
1655
  function drawPlayhead(ctx, state, style) {
1028
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1656
+ const baseX = contentLeftX(state.showHeader);
1029
1657
  const x = baseX + state.timeMs / 1e3 * state.pxPerSec - state.scrollLeft;
1030
1658
  if (x < baseX - 2 || x > state.viewportWidth + 2) return;
1031
1659
  ctx.strokeStyle = style.playhead;
@@ -1039,7 +1667,9 @@ function drawPlayhead(ctx, state, style) {
1039
1667
  const padX = 6;
1040
1668
  const w = ctx.measureText(label).width + padX * 2;
1041
1669
  const h = 14;
1042
- const bx = x - w / 2;
1670
+ const contentRight = state.viewportWidth - SCROLLBAR_THICKNESS;
1671
+ const rawBx = x - w / 2;
1672
+ const bx = Math.max(baseX, Math.min(contentRight - w, rawBx));
1043
1673
  const by = 2;
1044
1674
  ctx.fillStyle = style.playhead;
1045
1675
  roundRect(ctx, bx, by, w, h, 4);
@@ -1095,7 +1725,7 @@ function drawScrollbarV(ctx, state, style) {
1095
1725
  }
1096
1726
  function drawScrollbarH(ctx, state, style) {
1097
1727
  if (state.scrollbarOpacityX <= 0.01) return;
1098
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1728
+ const baseX = contentLeftX(state.showHeader);
1099
1729
  const visibleW = state.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1100
1730
  const contentW = contentWidth(state.project, state.pxPerSec);
1101
1731
  if (contentW <= visibleW) return;
@@ -1178,11 +1808,25 @@ function parseColor(s) {
1178
1808
  if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
1179
1809
  return null;
1180
1810
  }
1811
+ function groupKeyframesByTime(kfs, epsilonMs) {
1812
+ const sorted = [...kfs].sort((a, b) => a.time - b.time);
1813
+ const out = [];
1814
+ for (const k of sorted) {
1815
+ const last = out[out.length - 1];
1816
+ if (last && Math.abs(k.time - last.time) < epsilonMs) {
1817
+ last.kfs.push(k);
1818
+ } else {
1819
+ out.push({ time: k.time, kfs: [k] });
1820
+ }
1821
+ }
1822
+ return out;
1823
+ }
1181
1824
 
1182
1825
  // src/timeline/hit.ts
1826
+ var KEYFRAME_HIT_RADIUS = 8;
1183
1827
  function hitTest(x, y, ctx) {
1184
1828
  if (y < 0 || x < 0) return { kind: "outside" };
1185
- const baseX = ctx.showHeader ? HEADER_WIDTH : 0;
1829
+ const baseX = contentLeftX(ctx.showHeader);
1186
1830
  const visibleH = ctx.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1187
1831
  const contentH = contentHeight(ctx.project.tracks, ctx.isDragging);
1188
1832
  if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
@@ -1220,7 +1864,7 @@ function hitTest(x, y, ctx) {
1220
1864
  if (track2.clips.length === 0) {
1221
1865
  const btnSize = 18;
1222
1866
  const btnLeft = HEADER_WIDTH - btnSize - 6;
1223
- const btnTop = RULER_HEIGHT + ti2 * 56 + (56 - btnSize) / 2 - ctx.scrollTop;
1867
+ const btnTop = RULER_HEIGHT + ti2 * TRACK_HEIGHT + (TRACK_HEIGHT - btnSize) / 2 - ctx.scrollTop;
1224
1868
  if (x >= btnLeft && x <= btnLeft + btnSize && y >= btnTop && y <= btnTop + btnSize) {
1225
1869
  return { kind: "header-delete", trackIndex: ti2 };
1226
1870
  }
@@ -1234,6 +1878,23 @@ function hitTest(x, y, ctx) {
1234
1878
  if (ti < 0) return { kind: "outside" };
1235
1879
  const track = ctx.project.tracks[ti];
1236
1880
  const ms = xToMs(x, ctx.pxPerSec, ctx.scrollLeft, ctx.showHeader);
1881
+ if (ctx.keyframesEnabled) {
1882
+ for (const clip of track.clips) {
1883
+ if (!clip.keyframes || clip.keyframes.length === 0) continue;
1884
+ const startX = msToXLocal(clip.start, ctx);
1885
+ for (const kf of clip.keyframes) {
1886
+ const kfX = startX + kf.time / 1e3 * ctx.pxPerSec;
1887
+ if (Math.abs(x - kfX) <= KEYFRAME_HIT_RADIUS) {
1888
+ return {
1889
+ kind: "keyframe",
1890
+ trackIndex: ti,
1891
+ clipId: clip.id,
1892
+ keyframeId: kf.id
1893
+ };
1894
+ }
1895
+ }
1896
+ }
1897
+ }
1237
1898
  for (const clip of track.clips) {
1238
1899
  const start = clip.start;
1239
1900
  const end = clip.start + (clip.out - clip.in);
@@ -1252,8 +1913,7 @@ function hitTest(x, y, ctx) {
1252
1913
  return { kind: "track-empty", trackIndex: ti };
1253
1914
  }
1254
1915
  function msToXLocal(ms, ctx) {
1255
- const base = ctx.showHeader ? HEADER_WIDTH : 0;
1256
- return base + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
1916
+ return contentLeftX(ctx.showHeader) + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
1257
1917
  }
1258
1918
 
1259
1919
  // src/timeline/index.ts
@@ -1287,6 +1947,8 @@ var Timeline = class _Timeline {
1287
1947
  readOnly;
1288
1948
  autoFitEnabled;
1289
1949
  locale;
1950
+ keyframesEnabled = false;
1951
+ selectedKeyframe = null;
1290
1952
  scrollLeft = 0;
1291
1953
  scrollTop = 0;
1292
1954
  viewportWidth = 0;
@@ -1305,6 +1967,7 @@ var Timeline = class _Timeline {
1305
1967
  scrollbarDrag = null;
1306
1968
  hoveredClipId = null;
1307
1969
  hoveredTrackIndex = null;
1970
+ hoveredKeyframe = null;
1308
1971
  hoverCursor = "default";
1309
1972
  dropTargetTrackIndex = null;
1310
1973
  snapX = null;
@@ -1343,6 +2006,8 @@ var Timeline = class _Timeline {
1343
2006
  this.readOnly = opts.readOnly === true;
1344
2007
  this.autoFitEnabled = opts.autoFit !== false;
1345
2008
  this.locale = mergeLocale(opts.locale);
2009
+ this.keyframesEnabled = opts.keyframesEnabled === true;
2010
+ this.selectedKeyframe = opts.selectedKeyframe ?? null;
1346
2011
  this.root.classList.add("aicut-timeline-canvas");
1347
2012
  this.root.innerHTML = "";
1348
2013
  this.root.style.position = this.root.style.position || "relative";
@@ -1390,6 +2055,7 @@ var Timeline = class _Timeline {
1390
2055
  this.thumbs.syncSources(this.project.sources);
1391
2056
  this.attachPointer();
1392
2057
  this.attachWheel();
2058
+ this.attachKeyboard();
1393
2059
  this.attachResize();
1394
2060
  this.resizeCanvas();
1395
2061
  this.scheduleRender();
@@ -1470,7 +2136,7 @@ var Timeline = class _Timeline {
1470
2136
  * Exposed publicly so React/Vue wrappers can forward it to a ref.
1471
2137
  */
1472
2138
  getDebugInfo() {
1473
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2139
+ const baseX = contentLeftX(this.showHeader);
1474
2140
  const clips = [];
1475
2141
  for (let ti = 0; ti < this.project.tracks.length; ti++) {
1476
2142
  const t = this.project.tracks[ti];
@@ -1520,21 +2186,20 @@ var Timeline = class _Timeline {
1520
2186
  const dpr = window.devicePixelRatio || 1;
1521
2187
  this.canvas.width = Math.floor(this.viewportWidth * dpr);
1522
2188
  this.canvas.height = Math.floor(this.viewportHeight * dpr);
1523
- this.canvas.style.height = `${this.viewportHeight}px`;
1524
2189
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1525
2190
  }
1526
2191
  computeFitScale() {
1527
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2192
+ const baseX = contentLeftX(this.showHeader);
1528
2193
  const w = this.viewportWidth - baseX - 24;
1529
2194
  const dur = projectDuration(this.project);
1530
2195
  if (w <= 0 || dur <= 0) return null;
1531
2196
  return clampScale(w / (dur / 1e3));
1532
2197
  }
1533
2198
  maxScrollLeft() {
1534
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2199
+ const baseX = contentLeftX(this.showHeader);
1535
2200
  const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1536
2201
  const cw = contentWidth(this.project, this.pxPerSec);
1537
- return Math.max(0, cw - visibleW + 24);
2202
+ return Math.max(0, cw - visibleW + TIMELINE_PAD_RIGHT);
1538
2203
  }
1539
2204
  maxScrollTop() {
1540
2205
  const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
@@ -1636,9 +2301,36 @@ var Timeline = class _Timeline {
1636
2301
  scrollbarOpacityX: this.scrollbarOpacity("h"),
1637
2302
  scrollbarActiveY: this.scrollbarDrag?.axis === "v",
1638
2303
  scrollbarActiveX: this.scrollbarDrag?.axis === "h",
1639
- locale: this.locale
2304
+ locale: this.locale,
2305
+ keyframesEnabled: this.keyframesEnabled,
2306
+ selectedKeyframe: this.selectedKeyframe,
2307
+ hoveredKeyframe: this.hoveredKeyframe,
2308
+ keyframeDragGhost: this.drag?.kind === "keyframe-drag" ? {
2309
+ clipId: this.drag.clipId,
2310
+ keyframeId: this.drag.keyframeId,
2311
+ ghostTimeMs: this.drag.ghostTimeMs
2312
+ } : null
1640
2313
  };
1641
2314
  }
2315
+ /** Host-pushed state — Editor calls this when its keyframe mode
2316
+ * changes or when a keyframe is selected/deselected externally. */
2317
+ setKeyframeState(state) {
2318
+ let dirty = false;
2319
+ if (state.enabled !== void 0 && state.enabled !== this.keyframesEnabled) {
2320
+ this.keyframesEnabled = state.enabled;
2321
+ dirty = true;
2322
+ }
2323
+ if (state.selected !== void 0) {
2324
+ const a = this.selectedKeyframe;
2325
+ const b = state.selected;
2326
+ const same = a?.clipId === b?.clipId && a?.keyframeId === b?.keyframeId;
2327
+ if (!same) {
2328
+ this.selectedKeyframe = b;
2329
+ dirty = true;
2330
+ }
2331
+ }
2332
+ if (dirty) this.scheduleRender();
2333
+ }
1642
2334
  readStyle() {
1643
2335
  const cs = getComputedStyle(this.root);
1644
2336
  const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback;
@@ -1667,6 +2359,7 @@ var Timeline = class _Timeline {
1667
2359
  this.canvas.addEventListener("pointerleave", () => {
1668
2360
  if (!this.drag && !this.scrollbarDrag) {
1669
2361
  this.hoveredClipId = null;
2362
+ this.hoveredKeyframe = null;
1670
2363
  this.hoverCursor = "default";
1671
2364
  this.hoverScrollbarY = false;
1672
2365
  this.hoverScrollbarX = false;
@@ -1707,7 +2400,7 @@ var Timeline = class _Timeline {
1707
2400
  return;
1708
2401
  }
1709
2402
  if (target.kind === "scrollbar-track-h") {
1710
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2403
+ const baseX = contentLeftX(this.showHeader);
1711
2404
  const page = Math.max(
1712
2405
  80,
1713
2406
  this.viewportWidth - baseX - SCROLLBAR_THICKNESS
@@ -1747,6 +2440,33 @@ var Timeline = class _Timeline {
1747
2440
  this.scheduleRender();
1748
2441
  return;
1749
2442
  }
2443
+ if (target.kind === "keyframe") {
2444
+ const found = findClip(this.project, target.clipId);
2445
+ const kf = found?.clip.keyframes?.find((k) => k.id === target.keyframeId);
2446
+ if (!found || !kf) return;
2447
+ this.selectedKeyframe = {
2448
+ clipId: target.clipId,
2449
+ keyframeId: target.keyframeId
2450
+ };
2451
+ this.opts.onSelectKeyframe?.({
2452
+ clipId: target.clipId,
2453
+ keyframeId: target.keyframeId
2454
+ });
2455
+ const absMs = found.clip.start + kf.time;
2456
+ this.timeMs = absMs;
2457
+ this.opts.onSeek?.(absMs);
2458
+ this.drag = {
2459
+ kind: "keyframe-drag",
2460
+ clipId: target.clipId,
2461
+ keyframeId: target.keyframeId,
2462
+ trackIndex: target.trackIndex,
2463
+ pointerStartX: x,
2464
+ originalTimeMs: kf.time,
2465
+ ghostTimeMs: kf.time
2466
+ };
2467
+ this.scheduleRender();
2468
+ return;
2469
+ }
1750
2470
  if (target.kind === "clip") {
1751
2471
  const found = findClip(this.project, target.clipId);
1752
2472
  if (!found) return;
@@ -1813,7 +2533,7 @@ var Timeline = class _Timeline {
1813
2533
  const ratio = maxScroll / free;
1814
2534
  this.scrollTop = this.scrollbarDrag.scrollStart + (y - this.scrollbarDrag.pointerStart) * ratio;
1815
2535
  } else {
1816
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2536
+ const baseX = contentLeftX(this.showHeader);
1817
2537
  const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1818
2538
  const contentW = contentWidth(this.project, this.pxPerSec);
1819
2539
  const trackLen = visibleW - SCROLLBAR_INSET * 2;
@@ -1837,10 +2557,19 @@ var Timeline = class _Timeline {
1837
2557
  let cursor = "default";
1838
2558
  let onScrollbarV = false;
1839
2559
  let onScrollbarH = false;
2560
+ let nextHoverKeyframe = null;
1840
2561
  if (target.kind === "clip") {
1841
2562
  nextHover = target.clipId;
1842
2563
  nextHoverTrack = target.trackIndex;
1843
2564
  cursor = this.readOnly ? "pointer" : "grab";
2565
+ } else if (target.kind === "keyframe") {
2566
+ nextHover = target.clipId;
2567
+ nextHoverTrack = target.trackIndex;
2568
+ nextHoverKeyframe = {
2569
+ clipId: target.clipId,
2570
+ keyframeId: target.keyframeId
2571
+ };
2572
+ cursor = "pointer";
1844
2573
  } else if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
1845
2574
  nextHover = target.clipId;
1846
2575
  nextHoverTrack = target.trackIndex;
@@ -1864,12 +2593,14 @@ var Timeline = class _Timeline {
1864
2593
  cursor = "default";
1865
2594
  }
1866
2595
  const hoverChanged = onScrollbarV !== this.hoverScrollbarY || onScrollbarH !== this.hoverScrollbarX;
1867
- if (nextHover !== this.hoveredClipId || nextHoverTrack !== this.hoveredTrackIndex || cursor !== this.hoverCursor || hoverChanged) {
2596
+ const kfHoverChanged = (nextHoverKeyframe?.clipId ?? null) !== (this.hoveredKeyframe?.clipId ?? null) || (nextHoverKeyframe?.keyframeId ?? null) !== (this.hoveredKeyframe?.keyframeId ?? null);
2597
+ if (nextHover !== this.hoveredClipId || nextHoverTrack !== this.hoveredTrackIndex || cursor !== this.hoverCursor || hoverChanged || kfHoverChanged) {
1868
2598
  this.hoveredClipId = nextHover;
1869
2599
  this.hoveredTrackIndex = nextHoverTrack;
1870
2600
  this.hoverCursor = cursor;
1871
2601
  this.hoverScrollbarY = onScrollbarV;
1872
2602
  this.hoverScrollbarX = onScrollbarH;
2603
+ this.hoveredKeyframe = nextHoverKeyframe;
1873
2604
  this.scheduleRender();
1874
2605
  }
1875
2606
  return;
@@ -1892,6 +2623,26 @@ var Timeline = class _Timeline {
1892
2623
  this.maybeStartDragAutoScroll();
1893
2624
  return;
1894
2625
  }
2626
+ if (this.drag.kind === "keyframe-drag") {
2627
+ const found = findClip(this.project, this.drag.clipId);
2628
+ if (!found) return;
2629
+ const clip = found.clip;
2630
+ const duration = clip.out - clip.in;
2631
+ const dxPx = x - this.drag.pointerStartX;
2632
+ const dxMs = dxPx / this.pxPerSec * 1e3;
2633
+ const nextLocal = Math.max(
2634
+ 0,
2635
+ Math.min(duration, this.drag.originalTimeMs + dxMs)
2636
+ );
2637
+ const snappedAbs = this.applySnap(clip.start + nextLocal, null);
2638
+ const snappedLocal = Math.max(
2639
+ 0,
2640
+ Math.min(duration, snappedAbs - clip.start)
2641
+ );
2642
+ this.drag.ghostTimeMs = Math.round(snappedLocal);
2643
+ this.scheduleRender();
2644
+ return;
2645
+ }
1895
2646
  if (this.drag.kind === "trim-left" || this.drag.kind === "trim-right") {
1896
2647
  const dxPx = x - this.drag.pointerStartX;
1897
2648
  const dxMs = dxPx / this.pxPerSec * 1e3;
@@ -1937,7 +2688,8 @@ var Timeline = class _Timeline {
1937
2688
  const tiRaw = this.trackIndexAtY(y);
1938
2689
  const phantomIdx = this.project.tracks.length;
1939
2690
  const phantomScreenY = RULER_HEIGHT + phantomIdx * TRACK_HEIGHT - this.scrollTop;
1940
- const onPhantom = y >= phantomScreenY && y < phantomScreenY + TRACK_HEIGHT;
2691
+ const viewportBottom = this.viewportHeight - SCROLLBAR_THICKNESS;
2692
+ const onPhantom = y >= phantomScreenY && y < Math.max(phantomScreenY + TRACK_HEIGHT, viewportBottom);
1941
2693
  const intendedTrackIndex = onPhantom ? phantomIdx : tiRaw >= 0 ? tiRaw : drag.trackIndex;
1942
2694
  let ghostTrackIndex = intendedTrackIndex;
1943
2695
  let overlap = false;
@@ -2043,6 +2795,14 @@ var Timeline = class _Timeline {
2043
2795
  });
2044
2796
  this.opts.onChange?.(this.getProject());
2045
2797
  }
2798
+ } else if (drag.kind === "keyframe-drag") {
2799
+ if (drag.ghostTimeMs !== drag.originalTimeMs) {
2800
+ this.opts.onMoveKeyframe?.(
2801
+ drag.clipId,
2802
+ drag.keyframeId,
2803
+ drag.ghostTimeMs
2804
+ );
2805
+ }
2046
2806
  } else if (drag.kind === "trim-left" || drag.kind === "trim-right") {
2047
2807
  const found = findClip(this.project, drag.clipId);
2048
2808
  if (found) {
@@ -2056,6 +2816,25 @@ var Timeline = class _Timeline {
2056
2816
  }
2057
2817
  this.scheduleRender();
2058
2818
  }
2819
+ attachKeyboard() {
2820
+ this.canvas.tabIndex = -1;
2821
+ this.canvas.style.outline = "none";
2822
+ this.canvas.addEventListener("keydown", (e) => {
2823
+ if (e.code !== "ArrowLeft" && e.code !== "ArrowRight") return;
2824
+ e.preventDefault();
2825
+ const step = e.shiftKey ? bigFrameStepMs(this.project) : frameStepMs(this.project);
2826
+ const dir = e.code === "ArrowLeft" ? -1 : 1;
2827
+ const dur = projectDuration(this.project);
2828
+ const next = Math.max(0, Math.min(dur, this.timeMs + dir * step));
2829
+ if (next === this.timeMs) return;
2830
+ this.timeMs = next;
2831
+ this.opts.onSeek?.(next);
2832
+ this.scheduleRender();
2833
+ });
2834
+ this.canvas.addEventListener("pointerdown", () => {
2835
+ if (document.activeElement !== this.canvas) this.canvas.focus();
2836
+ });
2837
+ }
2059
2838
  attachWheel() {
2060
2839
  this.canvas.addEventListener(
2061
2840
  "wheel",
@@ -2076,7 +2855,7 @@ var Timeline = class _Timeline {
2076
2855
  if (Math.abs(next - this.pxPerSec) < 0.01) return;
2077
2856
  this.pxPerSec = next;
2078
2857
  this.hasAutoFitted = true;
2079
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2858
+ const baseX = contentLeftX(this.showHeader);
2080
2859
  this.scrollLeft = anchorMs / 1e3 * this.pxPerSec - (cursorX - baseX);
2081
2860
  this.clampScroll();
2082
2861
  this.touchScrollbar("h");
@@ -2110,64 +2889,768 @@ var Timeline = class _Timeline {
2110
2889
  { passive: false }
2111
2890
  );
2112
2891
  }
2113
- attachResize() {
2114
- if (typeof ResizeObserver === "undefined") return;
2115
- this.resizeObs = new ResizeObserver(() => {
2116
- this.resizeCanvas();
2117
- if (!this.hasAutoFitted && this.autoFitEnabled) {
2118
- const fit = this.computeFitScale();
2119
- if (fit != null) {
2120
- this.pxPerSec = fit;
2121
- this.opts.onScaleChange?.(fit);
2122
- }
2123
- }
2124
- this.scheduleRender();
2892
+ attachResize() {
2893
+ if (typeof ResizeObserver === "undefined") return;
2894
+ this.resizeObs = new ResizeObserver(() => {
2895
+ this.resizeCanvas();
2896
+ if (!this.hasAutoFitted && this.autoFitEnabled) {
2897
+ const fit = this.computeFitScale();
2898
+ if (fit != null) {
2899
+ this.pxPerSec = fit;
2900
+ this.opts.onScaleChange?.(fit);
2901
+ }
2902
+ }
2903
+ this.scheduleRender();
2904
+ });
2905
+ this.resizeObs.observe(this.root);
2906
+ }
2907
+ // ---- helpers --------------------------------------------------------
2908
+ localCoords(e) {
2909
+ const rect = this.canvas.getBoundingClientRect();
2910
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
2911
+ }
2912
+ hitTarget(x, y) {
2913
+ return hitTest(x, y, {
2914
+ project: this.project,
2915
+ pxPerSec: this.pxPerSec,
2916
+ scrollLeft: this.scrollLeft,
2917
+ scrollTop: this.scrollTop,
2918
+ showHeader: this.showHeader,
2919
+ viewportWidth: this.viewportWidth,
2920
+ viewportHeight: this.viewportHeight,
2921
+ isDragging: this.drag?.kind === "move",
2922
+ keyframesEnabled: this.keyframesEnabled
2923
+ });
2924
+ }
2925
+ trackIndexAtY(y) {
2926
+ return trackIndexAt(y, this.project.tracks.length, this.scrollTop);
2927
+ }
2928
+ applySnap(ms, ignoreClipId) {
2929
+ if (!this.snapEnabled) {
2930
+ this.snapX = null;
2931
+ return ms;
2932
+ }
2933
+ const tolMs = Math.max(20, SNAP_PX / this.pxPerSec * 1e3);
2934
+ const targets = snapTargets(this.project, this.timeMs, ignoreClipId);
2935
+ let best = ms;
2936
+ let bestDist = tolMs;
2937
+ for (const t of targets) {
2938
+ const d = Math.abs(t - ms);
2939
+ if (d < bestDist) {
2940
+ bestDist = d;
2941
+ best = t;
2942
+ }
2943
+ }
2944
+ if (best !== ms) {
2945
+ const baseX = contentLeftX(this.showHeader);
2946
+ this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
2947
+ } else {
2948
+ this.snapX = null;
2949
+ }
2950
+ return best;
2951
+ }
2952
+ };
2953
+
2954
+ // src/ui/keyframe-overlay.ts
2955
+ var KeyframeOverlay = class _KeyframeOverlay {
2956
+ editor;
2957
+ host;
2958
+ root;
2959
+ frameBody;
2960
+ handles;
2961
+ rafHandle = null;
2962
+ destroyed = false;
2963
+ drag = null;
2964
+ capturedPointerId = null;
2965
+ /** Timer handle for the wheel-burst → interaction commit. */
2966
+ wheelInteractionTimer = null;
2967
+ /** Snap-target threshold in CSS px — the same feel as the timeline. */
2968
+ static SNAP_PX = 8;
2969
+ constructor(host, editor) {
2970
+ this.host = host;
2971
+ this.editor = editor;
2972
+ this.root = document.createElement("div");
2973
+ this.root.className = "aicut-keyframe-overlay";
2974
+ this.root.setAttribute("data-testid", "aicut-keyframe-overlay");
2975
+ this.root.style.display = "none";
2976
+ this.frameBody = document.createElement("div");
2977
+ this.frameBody.className = "aicut-keyframe-overlay__frame";
2978
+ this.frameBody.setAttribute("data-testid", "aicut-keyframe-frame");
2979
+ this.frameBody.addEventListener("pointerdown", (e) => this.onTransStart(e));
2980
+ this.frameBody.addEventListener(
2981
+ "wheel",
2982
+ (e) => this.onPinchScale(e),
2983
+ { passive: false }
2984
+ );
2985
+ this.root.appendChild(this.frameBody);
2986
+ this.handles = {
2987
+ tl: this.makeHandle("tl"),
2988
+ tr: this.makeHandle("tr"),
2989
+ bl: this.makeHandle("bl"),
2990
+ br: this.makeHandle("br")
2991
+ };
2992
+ host.appendChild(this.root);
2993
+ this.startTick();
2994
+ }
2995
+ destroy() {
2996
+ this.destroyed = true;
2997
+ if (this.rafHandle != null) cancelAnimationFrame(this.rafHandle);
2998
+ if (this.wheelInteractionTimer != null) {
2999
+ clearTimeout(this.wheelInteractionTimer);
3000
+ this.wheelInteractionTimer = null;
3001
+ this.editor.endInteraction();
3002
+ }
3003
+ this.root.remove();
3004
+ }
3005
+ // ---- frame body drag (translate) -------------------------------------
3006
+ onTransStart(e) {
3007
+ if (e.button !== 0) return;
3008
+ const ctx = this.ensureSelectedClip();
3009
+ if (!ctx) return;
3010
+ e.preventDefault();
3011
+ e.stopPropagation();
3012
+ this.frameBody.setPointerCapture(e.pointerId);
3013
+ this.capturedPointerId = e.pointerId;
3014
+ this.drag = {
3015
+ kind: "translate",
3016
+ clipId: ctx.clip.id,
3017
+ pointerStartX: e.clientX,
3018
+ pointerStartY: e.clientY,
3019
+ startPanX: ctx.transform.panX,
3020
+ startPanY: ctx.transform.panY
3021
+ };
3022
+ this.editor.beginInteraction();
3023
+ this.frameBody.addEventListener("pointermove", this.onPointerMove);
3024
+ this.frameBody.addEventListener("pointerup", this.onPointerUp);
3025
+ this.frameBody.addEventListener("pointercancel", this.onPointerUp);
3026
+ }
3027
+ // ---- pinch-to-scale --------------------------------------------------
3028
+ onPinchScale(e) {
3029
+ if (!e.ctrlKey) return;
3030
+ const ctx = this.ensureSelectedClip();
3031
+ if (!ctx) return;
3032
+ e.preventDefault();
3033
+ e.stopPropagation();
3034
+ const step = Math.max(-50, Math.min(50, -e.deltaY));
3035
+ const factor = Math.exp(step * 0.01);
3036
+ const next = Math.max(
3037
+ 0.05,
3038
+ Math.min(16, ctx.transform.scale * factor)
3039
+ );
3040
+ if (this.wheelInteractionTimer == null) {
3041
+ this.editor.beginInteraction();
3042
+ } else {
3043
+ clearTimeout(this.wheelInteractionTimer);
3044
+ }
3045
+ this.wheelInteractionTimer = window.setTimeout(() => {
3046
+ this.wheelInteractionTimer = null;
3047
+ this.editor.endInteraction();
3048
+ }, 200);
3049
+ this.editor.setValueAtPlayhead(
3050
+ ctx.clip.id,
3051
+ "scale",
3052
+ Math.round(next * 100) / 100
3053
+ );
3054
+ }
3055
+ // ---- corner-handle drag (scale) --------------------------------------
3056
+ onScaleStart(corner, e) {
3057
+ if (e.button !== 0) return;
3058
+ const ctx = this.ensureSelectedClip();
3059
+ if (!ctx) return;
3060
+ e.preventDefault();
3061
+ e.stopPropagation();
3062
+ const rect = this.editor.getActiveOutputFrameRect() ?? this.editor.getActiveFrameRect();
3063
+ if (!rect) return;
3064
+ const hostRect = this.host.getBoundingClientRect();
3065
+ const cx = hostRect.left + rect.x + rect.w / 2;
3066
+ const cy = hostRect.top + rect.y + rect.h / 2;
3067
+ const startDist = Math.hypot(e.clientX - cx, e.clientY - cy);
3068
+ if (startDist < 1) return;
3069
+ const target = this.handles[corner];
3070
+ target.setPointerCapture(e.pointerId);
3071
+ this.capturedPointerId = e.pointerId;
3072
+ this.drag = {
3073
+ kind: "scale",
3074
+ clipId: ctx.clip.id,
3075
+ centerX: cx,
3076
+ centerY: cy,
3077
+ startDistance: startDist,
3078
+ startScale: ctx.transform.scale
3079
+ };
3080
+ this.editor.beginInteraction();
3081
+ target.addEventListener("pointermove", this.onPointerMove);
3082
+ target.addEventListener("pointerup", this.onPointerUp);
3083
+ target.addEventListener("pointercancel", this.onPointerUp);
3084
+ }
3085
+ onPointerMove = (e) => {
3086
+ if (!this.drag) return;
3087
+ if (this.drag.kind === "translate") {
3088
+ const dx = e.clientX - this.drag.pointerStartX;
3089
+ const dy = e.clientY - this.drag.pointerStartY;
3090
+ const rawPanX = this.drag.startPanX + dx;
3091
+ const rawPanY = this.drag.startPanY + dy;
3092
+ const snapped = this.applySnap(this.drag.clipId, rawPanX, rawPanY);
3093
+ this.editor.setValueAtPlayhead(
3094
+ this.drag.clipId,
3095
+ "panX",
3096
+ Math.round(snapped.panX)
3097
+ );
3098
+ this.editor.setValueAtPlayhead(
3099
+ this.drag.clipId,
3100
+ "panY",
3101
+ Math.round(snapped.panY)
3102
+ );
3103
+ } else {
3104
+ const dist = Math.hypot(
3105
+ e.clientX - this.drag.centerX,
3106
+ e.clientY - this.drag.centerY
3107
+ );
3108
+ const ratio = dist / this.drag.startDistance;
3109
+ const next = Math.max(
3110
+ 0.05,
3111
+ Math.min(16, this.drag.startScale * ratio)
3112
+ );
3113
+ this.editor.setValueAtPlayhead(
3114
+ this.drag.clipId,
3115
+ "scale",
3116
+ Math.round(next * 100) / 100
3117
+ );
3118
+ }
3119
+ };
3120
+ /**
3121
+ * Snap raw pan to: centered (panX/Y = 0) and the four edge-alignment
3122
+ * stops (content's L/R/T/B edge flush with the output's matching
3123
+ * edge). When content is smaller than output, the edge stops collapse
3124
+ * to the same point as 0 — harmless dup. Threshold = 8 CSS px.
3125
+ */
3126
+ applySnap(clipId, rawPanX, rawPanY) {
3127
+ const out = this.editor.getActiveOutputFrameRect();
3128
+ if (!out) return { panX: rawPanX, panY: rawPanY };
3129
+ const clip = this.findClip(clipId);
3130
+ if (!clip) return { panX: rawPanX, panY: rawPanY };
3131
+ const t = (() => {
3132
+ try {
3133
+ const transformer = this.editor.getActiveFrameRect();
3134
+ if (!transformer) return null;
3135
+ return { w: transformer.w, h: transformer.h };
3136
+ } catch {
3137
+ return null;
3138
+ }
3139
+ })();
3140
+ const contentW = t?.w ?? out.w;
3141
+ const contentH = t?.h ?? out.h;
3142
+ const edgeX = (contentW - out.w) / 2;
3143
+ const edgeY = (contentH - out.h) / 2;
3144
+ const xTargets = [0, edgeX, -edgeX];
3145
+ const yTargets = [0, edgeY, -edgeY];
3146
+ const px = nearestSnap(rawPanX, xTargets, _KeyframeOverlay.SNAP_PX);
3147
+ const py = nearestSnap(rawPanY, yTargets, _KeyframeOverlay.SNAP_PX);
3148
+ return { panX: px, panY: py };
3149
+ }
3150
+ findClip(clipId) {
3151
+ const project = this.editor.getProject();
3152
+ for (const t of project.tracks) {
3153
+ const c = t.clips.find((cl) => cl.id === clipId);
3154
+ if (c) return c;
3155
+ }
3156
+ return null;
3157
+ }
3158
+ onPointerUp = (e) => {
3159
+ if (!this.drag) return;
3160
+ const targetEl = e.currentTarget;
3161
+ if (targetEl && this.capturedPointerId === e.pointerId) {
3162
+ try {
3163
+ targetEl.releasePointerCapture(e.pointerId);
3164
+ } catch {
3165
+ }
3166
+ }
3167
+ targetEl?.removeEventListener("pointermove", this.onPointerMove);
3168
+ targetEl?.removeEventListener("pointerup", this.onPointerUp);
3169
+ targetEl?.removeEventListener("pointercancel", this.onPointerUp);
3170
+ this.drag = null;
3171
+ this.capturedPointerId = null;
3172
+ this.editor.endInteraction();
3173
+ };
3174
+ // ---- per-frame layout ------------------------------------------------
3175
+ startTick() {
3176
+ const tick = () => {
3177
+ if (this.destroyed) return;
3178
+ this.layout();
3179
+ this.rafHandle = requestAnimationFrame(tick);
3180
+ };
3181
+ this.rafHandle = requestAnimationFrame(tick);
3182
+ }
3183
+ layout() {
3184
+ const enabled = this.editor.isKeyframesEnabled();
3185
+ if (!enabled) {
3186
+ this.root.style.display = "none";
3187
+ return;
3188
+ }
3189
+ const outRect = this.editor.getActiveOutputFrameRect();
3190
+ const contentRect = this.editor.getActiveFrameRect() ?? outRect;
3191
+ if (!outRect) {
3192
+ this.root.style.display = "none";
3193
+ return;
3194
+ }
3195
+ this.root.style.display = "block";
3196
+ Object.assign(this.frameBody.style, {
3197
+ left: `${outRect.x}px`,
3198
+ top: `${outRect.y}px`,
3199
+ width: `${outRect.w}px`,
3200
+ height: `${outRect.h}px`
3201
+ });
3202
+ 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;
3203
+ this.frameBody.classList.toggle(
3204
+ "aicut-keyframe-overlay__frame--warn",
3205
+ !fullyCovered
3206
+ );
3207
+ const halfHandle = 6;
3208
+ const r = contentRect ?? outRect;
3209
+ const fbLeft = r.x;
3210
+ const fbTop = r.y;
3211
+ const fbRight = r.x + r.w;
3212
+ const fbBottom = r.y + r.h;
3213
+ const place = (el, cx, cy) => {
3214
+ el.style.left = `${cx - halfHandle}px`;
3215
+ el.style.top = `${cy - halfHandle}px`;
3216
+ };
3217
+ place(this.handles.tl, fbLeft, fbTop);
3218
+ place(this.handles.tr, fbRight, fbTop);
3219
+ place(this.handles.bl, fbLeft, fbBottom);
3220
+ place(this.handles.br, fbRight, fbBottom);
3221
+ }
3222
+ // ---- helpers ---------------------------------------------------------
3223
+ makeHandle(name) {
3224
+ const el = document.createElement("div");
3225
+ el.className = `aicut-keyframe-overlay__handle aicut-keyframe-overlay__handle--${name}`;
3226
+ el.setAttribute("data-testid", `aicut-keyframe-handle-${name}`);
3227
+ el.addEventListener("pointerdown", (e) => this.onScaleStart(name, e));
3228
+ this.root.appendChild(el);
3229
+ return el;
3230
+ }
3231
+ /**
3232
+ * Resolve the currently selected clip + its current effective
3233
+ * transform (so drag baselines are correct). Returns null when no
3234
+ * clip is selected or the playhead isn't over it.
3235
+ */
3236
+ ensureSelectedClip() {
3237
+ const selectedClipId = this.editor.getSelection();
3238
+ if (!selectedClipId) return null;
3239
+ const project = this.editor.getProject();
3240
+ let clip = null;
3241
+ for (const t of project.tracks) {
3242
+ const c = t.clips.find((cl) => cl.id === selectedClipId);
3243
+ if (c) {
3244
+ clip = c;
3245
+ break;
3246
+ }
3247
+ }
3248
+ if (!clip) return null;
3249
+ const playheadLocal = this.editor.getTime() - clip.start;
3250
+ if (playheadLocal < 0 || playheadLocal > clip.out - clip.in) {
3251
+ return null;
3252
+ }
3253
+ const transform = getEffectiveTransform(clip, playheadLocal);
3254
+ return { clip, transform };
3255
+ }
3256
+ };
3257
+ function nearestSnap(raw, targets, threshold) {
3258
+ let best = raw;
3259
+ let bestDist = threshold;
3260
+ for (const t of targets) {
3261
+ const d = Math.abs(raw - t);
3262
+ if (d < bestDist) {
3263
+ bestDist = d;
3264
+ best = t;
3265
+ }
3266
+ }
3267
+ return best;
3268
+ }
3269
+
3270
+ // src/ui/keyframe-panel.ts
3271
+ var EASING_VALUES = [
3272
+ "linear",
3273
+ "easeIn",
3274
+ "easeOut",
3275
+ "easeInOut"
3276
+ ];
3277
+ function easingLabel(value, locale) {
3278
+ switch (value) {
3279
+ case "linear":
3280
+ return locale.keyframeEasingLinear;
3281
+ case "easeIn":
3282
+ return locale.keyframeEasingEaseIn;
3283
+ case "easeOut":
3284
+ return locale.keyframeEasingEaseOut;
3285
+ case "easeInOut":
3286
+ return locale.keyframeEasingEaseInOut;
3287
+ }
3288
+ }
3289
+ var TIME_EPS_MS = 16;
3290
+ var KeyframePanel = class {
3291
+ editor;
3292
+ locale;
3293
+ root;
3294
+ inputs;
3295
+ kfBadges;
3296
+ timeLabel;
3297
+ titleLabel;
3298
+ resetBtn;
3299
+ easingTrigger;
3300
+ easingTriggerLabel;
3301
+ easingMenu;
3302
+ easingItems;
3303
+ easingValue = "linear";
3304
+ easingDisabled = false;
3305
+ easingOpen = false;
3306
+ easingLabelEl;
3307
+ rowLabels;
3308
+ lastSyncKey = "";
3309
+ // Bound once so add/remove listener pairs reference the same fn.
3310
+ boundOutsideClick = null;
3311
+ boundDocKeydown = null;
3312
+ constructor(host, editor, locale) {
3313
+ this.editor = editor;
3314
+ this.locale = locale;
3315
+ this.root = document.createElement("div");
3316
+ this.root.className = "aicut-keyframe-panel";
3317
+ this.root.setAttribute("data-testid", "aicut-keyframe-panel");
3318
+ this.root.style.display = "none";
3319
+ this.root.addEventListener("pointerdown", (e) => e.stopPropagation());
3320
+ this.root.addEventListener("wheel", (e) => e.stopPropagation());
3321
+ const title = document.createElement("div");
3322
+ title.className = "aicut-keyframe-panel__title";
3323
+ this.titleLabel = document.createElement("span");
3324
+ this.timeLabel = document.createElement("span");
3325
+ this.timeLabel.className = "aicut-keyframe-panel__time";
3326
+ title.append(this.titleLabel, this.timeLabel);
3327
+ this.root.appendChild(title);
3328
+ const xRow = this.makeRow("kf-x", "panX", 1);
3329
+ const yRow = this.makeRow("kf-y", "panY", 1);
3330
+ const scaleRow = this.makeRow("kf-scale", "scale", 0.05);
3331
+ this.inputs = {
3332
+ panX: xRow.input,
3333
+ panY: yRow.input,
3334
+ scale: scaleRow.input
3335
+ };
3336
+ this.rowLabels = {
3337
+ panX: xRow.label,
3338
+ panY: yRow.label,
3339
+ scale: scaleRow.label
3340
+ };
3341
+ this.kfBadges = {
3342
+ panX: this.makeBadge(this.inputs.panX),
3343
+ panY: this.makeBadge(this.inputs.panY),
3344
+ scale: this.makeBadge(this.inputs.scale)
3345
+ };
3346
+ const easingRow = document.createElement("div");
3347
+ easingRow.className = "aicut-keyframe-panel__row aicut-keyframe-panel__row--easing";
3348
+ this.easingLabelEl = document.createElement("label");
3349
+ const dd = document.createElement("div");
3350
+ dd.className = "aicut-keyframe-panel__dropdown";
3351
+ dd.setAttribute("data-testid", "aicut-kf-easing");
3352
+ this.easingTrigger = document.createElement("button");
3353
+ this.easingTrigger.type = "button";
3354
+ this.easingTrigger.className = "aicut-keyframe-panel__dropdown-trigger";
3355
+ this.easingTrigger.setAttribute("aria-haspopup", "listbox");
3356
+ this.easingTrigger.setAttribute("aria-expanded", "false");
3357
+ this.easingTriggerLabel = document.createElement("span");
3358
+ this.easingTriggerLabel.className = "aicut-keyframe-panel__dropdown-trigger-label";
3359
+ const chevron = document.createElement("span");
3360
+ chevron.className = "aicut-keyframe-panel__dropdown-chevron";
3361
+ chevron.setAttribute("aria-hidden", "true");
3362
+ this.easingTrigger.append(this.easingTriggerLabel, chevron);
3363
+ this.easingTrigger.addEventListener("click", (e) => {
3364
+ e.stopPropagation();
3365
+ if (this.easingDisabled) return;
3366
+ this.toggleEasingMenu();
3367
+ });
3368
+ this.easingTrigger.addEventListener("keydown", (e) => {
3369
+ if (this.easingDisabled) return;
3370
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
3371
+ e.preventDefault();
3372
+ if (!this.easingOpen) this.openEasingMenu();
3373
+ this.easingItems.get(this.easingValue)?.focus();
3374
+ }
3375
+ });
3376
+ this.easingMenu = document.createElement("ul");
3377
+ this.easingMenu.className = "aicut-keyframe-panel__dropdown-menu";
3378
+ this.easingMenu.setAttribute("role", "listbox");
3379
+ this.easingMenu.style.display = "none";
3380
+ this.easingItems = /* @__PURE__ */ new Map();
3381
+ for (const value of EASING_VALUES) {
3382
+ const li = document.createElement("li");
3383
+ li.className = "aicut-keyframe-panel__dropdown-item";
3384
+ li.setAttribute("role", "option");
3385
+ li.setAttribute("data-value", value);
3386
+ li.setAttribute("tabindex", "-1");
3387
+ li.addEventListener("click", (e) => {
3388
+ e.stopPropagation();
3389
+ this.selectEasing(value);
3390
+ });
3391
+ li.addEventListener("keydown", (e) => this.onMenuKeydown(e, value));
3392
+ this.easingItems.set(value, li);
3393
+ this.easingMenu.appendChild(li);
3394
+ }
3395
+ dd.append(this.easingTrigger, this.easingMenu);
3396
+ easingRow.append(this.easingLabelEl, dd);
3397
+ this.root.appendChild(easingRow);
3398
+ const actions = document.createElement("div");
3399
+ actions.className = "aicut-keyframe-panel__actions";
3400
+ this.resetBtn = document.createElement("button");
3401
+ this.resetBtn.type = "button";
3402
+ this.resetBtn.className = "aicut-keyframe-panel__reset";
3403
+ this.resetBtn.setAttribute("data-testid", "aicut-keyframe-reset");
3404
+ this.resetBtn.addEventListener("click", () => this.onReset());
3405
+ actions.appendChild(this.resetBtn);
3406
+ this.root.appendChild(actions);
3407
+ this.applyLocaleText();
3408
+ host.appendChild(this.root);
3409
+ }
3410
+ setLocale(locale) {
3411
+ this.locale = locale;
3412
+ this.applyLocaleText();
3413
+ this.lastSyncKey = "";
3414
+ this.render();
3415
+ }
3416
+ applyLocaleText() {
3417
+ this.titleLabel.textContent = this.locale.keyframePanelTitle;
3418
+ this.rowLabels.panX.textContent = this.locale.keyframePanelLabelX;
3419
+ this.rowLabels.panY.textContent = this.locale.keyframePanelLabelY;
3420
+ this.rowLabels.scale.textContent = this.locale.keyframePanelLabelScale;
3421
+ this.easingLabelEl.textContent = this.locale.keyframePanelLabelEasing;
3422
+ this.resetBtn.textContent = this.locale.keyframePanelReset;
3423
+ this.resetBtn.title = this.locale.keyframePanelResetTitle;
3424
+ for (const [value, li] of this.easingItems) {
3425
+ li.textContent = easingLabel(value, this.locale);
3426
+ }
3427
+ this.easingTriggerLabel.textContent = easingLabel(
3428
+ this.easingValue,
3429
+ this.locale
3430
+ );
3431
+ }
3432
+ destroy() {
3433
+ this.closeEasingMenu();
3434
+ this.root.remove();
3435
+ }
3436
+ render() {
3437
+ const enabled = this.editor.isKeyframesEnabled();
3438
+ const sel = this.editor.getSelectedKeyframe();
3439
+ if (!enabled || !sel) {
3440
+ this.root.style.display = "none";
3441
+ this.lastSyncKey = "";
3442
+ return;
3443
+ }
3444
+ const clip = this.findClip(sel.clipId);
3445
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3446
+ if (!clip || !anchorKf) {
3447
+ this.root.style.display = "none";
3448
+ this.lastSyncKey = "";
3449
+ return;
3450
+ }
3451
+ const time = anchorKf.time;
3452
+ const moment = (clip.keyframes ?? []).filter(
3453
+ (k) => Math.abs(k.time - time) < TIME_EPS_MS
3454
+ );
3455
+ const interp = getEffectiveTransform(clip, time);
3456
+ const valueOf = (prop) => {
3457
+ const m = moment.find((k) => k.prop === prop);
3458
+ if (m) return m.value;
3459
+ return interp[prop];
3460
+ };
3461
+ const v = {
3462
+ panX: valueOf("panX"),
3463
+ panY: valueOf("panY"),
3464
+ scale: valueOf("scale")
3465
+ };
3466
+ const sharedEasing = (() => {
3467
+ if (moment.length === 0) return "linear";
3468
+ const anchor = moment.find((k) => k.id === sel.keyframeId) ?? moment[0];
3469
+ return anchor.easing ?? "linear";
3470
+ })();
3471
+ const syncKey = `${clip.id}|${time}|${v.panX.toFixed(2)}|${v.panY.toFixed(2)}|${v.scale.toFixed(4)}|${moment.map((m) => m.prop).join(",")}|${sharedEasing}`;
3472
+ this.root.style.display = "flex";
3473
+ if (syncKey === this.lastSyncKey) return;
3474
+ this.lastSyncKey = syncKey;
3475
+ this.setIfBlur(this.inputs.panX, String(Math.round(v.panX)));
3476
+ this.setIfBlur(this.inputs.panY, String(Math.round(v.panY)));
3477
+ this.setIfBlur(this.inputs.scale, v.scale.toFixed(2));
3478
+ this.timeLabel.textContent = `${(time / 1e3).toFixed(2)}${this.locale.keyframePanelTimeSuffix}`;
3479
+ this.setEasingValue(sharedEasing);
3480
+ this.setEasingDisabled(moment.length === 0);
3481
+ for (const p of ["panX", "panY", "scale"]) {
3482
+ const animated = moment.some((k) => k.prop === p) || hasKeyframesForProp(clip, p);
3483
+ const pinned = moment.some((k) => k.prop === p);
3484
+ this.kfBadges[p].classList.toggle(
3485
+ "aicut-keyframe-panel__badge--on",
3486
+ pinned
3487
+ );
3488
+ this.kfBadges[p].title = pinned ? this.locale.keyframePanelBadgePinned : animated ? this.locale.keyframePanelBadgeAnimated : this.locale.keyframePanelBadgeStatic;
3489
+ }
3490
+ this.resetBtn.disabled = false;
3491
+ }
3492
+ // ---- internals ------------------------------------------------------
3493
+ makeRow(testId, prop, step) {
3494
+ const row = document.createElement("div");
3495
+ row.className = "aicut-keyframe-panel__row";
3496
+ const lab = document.createElement("label");
3497
+ const input = document.createElement("input");
3498
+ input.type = "number";
3499
+ input.step = String(step);
3500
+ input.setAttribute("data-testid", `aicut-${testId}`);
3501
+ input.addEventListener("blur", () => this.commit(prop, input.value));
3502
+ input.addEventListener("keydown", (e) => {
3503
+ if (e.key === "Enter") input.blur();
2125
3504
  });
2126
- this.resizeObs.observe(this.root);
3505
+ row.append(lab, input);
3506
+ this.root.appendChild(row);
3507
+ return { input, label: lab };
3508
+ }
3509
+ makeBadge(input) {
3510
+ const dot = document.createElement("span");
3511
+ dot.className = "aicut-keyframe-panel__badge";
3512
+ input.parentElement?.appendChild(dot);
3513
+ return dot;
3514
+ }
3515
+ commit(prop, raw) {
3516
+ const num = Number(raw);
3517
+ if (!Number.isFinite(num)) return;
3518
+ const sel = this.editor.getSelectedKeyframe();
3519
+ if (!sel) return;
3520
+ const clip = this.findClip(sel.clipId);
3521
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3522
+ if (!clip || !anchorKf) return;
3523
+ this.editor.addKeyframe(sel.clipId, prop, {
3524
+ time: anchorKf.time,
3525
+ value: num
3526
+ });
3527
+ if (anchorKf.prop !== prop) {
3528
+ const refreshedClip = this.findClip(sel.clipId);
3529
+ const created = (refreshedClip?.keyframes ?? []).find(
3530
+ (k) => k.prop === prop && Math.abs(k.time - anchorKf.time) < TIME_EPS_MS
3531
+ );
3532
+ if (created) {
3533
+ this.editor.setSelectedKeyframe({
3534
+ clipId: sel.clipId,
3535
+ keyframeId: created.id
3536
+ });
3537
+ }
3538
+ }
2127
3539
  }
2128
- // ---- helpers --------------------------------------------------------
2129
- localCoords(e) {
2130
- const rect = this.canvas.getBoundingClientRect();
2131
- return { x: e.clientX - rect.left, y: e.clientY - rect.top };
3540
+ // ---- custom dropdown -------------------------------------------------
3541
+ setEasingValue(value) {
3542
+ if (this.easingValue === value) return;
3543
+ this.easingValue = value;
3544
+ this.easingTriggerLabel.textContent = easingLabel(value, this.locale);
3545
+ for (const [v, li] of this.easingItems) {
3546
+ li.classList.toggle(
3547
+ "aicut-keyframe-panel__dropdown-item--selected",
3548
+ v === value
3549
+ );
3550
+ li.setAttribute("aria-selected", v === value ? "true" : "false");
3551
+ }
2132
3552
  }
2133
- hitTarget(x, y) {
2134
- return hitTest(x, y, {
2135
- project: this.project,
2136
- pxPerSec: this.pxPerSec,
2137
- scrollLeft: this.scrollLeft,
2138
- scrollTop: this.scrollTop,
2139
- showHeader: this.showHeader,
2140
- viewportWidth: this.viewportWidth,
2141
- viewportHeight: this.viewportHeight,
2142
- isDragging: this.drag?.kind === "move"
3553
+ setEasingDisabled(disabled) {
3554
+ if (this.easingDisabled === disabled) return;
3555
+ this.easingDisabled = disabled;
3556
+ this.easingTrigger.disabled = disabled;
3557
+ this.easingTrigger.classList.toggle(
3558
+ "aicut-keyframe-panel__dropdown-trigger--disabled",
3559
+ disabled
3560
+ );
3561
+ if (disabled && this.easingOpen) this.closeEasingMenu();
3562
+ }
3563
+ toggleEasingMenu() {
3564
+ if (this.easingOpen) this.closeEasingMenu();
3565
+ else this.openEasingMenu();
3566
+ }
3567
+ openEasingMenu() {
3568
+ if (this.easingOpen || this.easingDisabled) return;
3569
+ this.easingOpen = true;
3570
+ this.easingMenu.style.display = "";
3571
+ this.easingTrigger.setAttribute("aria-expanded", "true");
3572
+ this.easingTrigger.classList.add(
3573
+ "aicut-keyframe-panel__dropdown-trigger--open"
3574
+ );
3575
+ requestAnimationFrame(() => {
3576
+ if (!this.easingOpen) return;
3577
+ this.boundOutsideClick = (e) => {
3578
+ if (!this.easingMenu.contains(e.target) && !this.easingTrigger.contains(e.target)) {
3579
+ this.closeEasingMenu();
3580
+ }
3581
+ };
3582
+ this.boundDocKeydown = (e) => {
3583
+ if (e.key === "Escape") {
3584
+ e.stopPropagation();
3585
+ this.closeEasingMenu();
3586
+ this.easingTrigger.focus();
3587
+ } else if (e.key === "Tab") {
3588
+ this.closeEasingMenu();
3589
+ }
3590
+ };
3591
+ document.addEventListener("click", this.boundOutsideClick, true);
3592
+ document.addEventListener("keydown", this.boundDocKeydown);
2143
3593
  });
2144
3594
  }
2145
- trackIndexAtY(y) {
2146
- return trackIndexAt(y, this.project.tracks.length, this.scrollTop);
3595
+ closeEasingMenu() {
3596
+ if (!this.easingOpen) return;
3597
+ this.easingOpen = false;
3598
+ this.easingMenu.style.display = "none";
3599
+ this.easingTrigger.setAttribute("aria-expanded", "false");
3600
+ this.easingTrigger.classList.remove(
3601
+ "aicut-keyframe-panel__dropdown-trigger--open"
3602
+ );
3603
+ if (this.boundOutsideClick) {
3604
+ document.removeEventListener("click", this.boundOutsideClick, true);
3605
+ this.boundOutsideClick = null;
3606
+ }
3607
+ if (this.boundDocKeydown) {
3608
+ document.removeEventListener("keydown", this.boundDocKeydown);
3609
+ this.boundDocKeydown = null;
3610
+ }
2147
3611
  }
2148
- applySnap(ms, ignoreClipId) {
2149
- if (!this.snapEnabled) {
2150
- this.snapX = null;
2151
- return ms;
3612
+ selectEasing(value) {
3613
+ this.closeEasingMenu();
3614
+ this.easingTrigger.focus();
3615
+ const sel = this.editor.getSelectedKeyframe();
3616
+ if (!sel) return;
3617
+ const clip = this.findClip(sel.clipId);
3618
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3619
+ if (!clip || !anchorKf) return;
3620
+ this.editor.setKeyframesEasingAtTime(sel.clipId, anchorKf.time, value);
3621
+ }
3622
+ onMenuKeydown(e, value) {
3623
+ if (e.key === "Enter" || e.key === " ") {
3624
+ e.preventDefault();
3625
+ this.selectEasing(value);
3626
+ return;
2152
3627
  }
2153
- const tolMs = Math.max(20, SNAP_PX / this.pxPerSec * 1e3);
2154
- const targets = snapTargets(this.project, this.timeMs, ignoreClipId);
2155
- let best = ms;
2156
- let bestDist = tolMs;
2157
- for (const t of targets) {
2158
- const d = Math.abs(t - ms);
2159
- if (d < bestDist) {
2160
- bestDist = d;
2161
- best = t;
2162
- }
3628
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
3629
+ e.preventDefault();
3630
+ const idx = EASING_VALUES.indexOf(value);
3631
+ const next = e.key === "ArrowDown" ? EASING_VALUES[(idx + 1) % EASING_VALUES.length] : EASING_VALUES[(idx - 1 + EASING_VALUES.length) % EASING_VALUES.length];
3632
+ this.easingItems.get(next)?.focus();
2163
3633
  }
2164
- if (best !== ms) {
2165
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2166
- this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
2167
- } else {
2168
- this.snapX = null;
3634
+ }
3635
+ onReset() {
3636
+ const sel = this.editor.getSelectedKeyframe();
3637
+ if (!sel) return;
3638
+ const clip = this.findClip(sel.clipId);
3639
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3640
+ if (!clip || !anchorKf) return;
3641
+ this.editor.resetKeyframesAtTime(sel.clipId, anchorKf.time);
3642
+ }
3643
+ setIfBlur(input, value) {
3644
+ if (document.activeElement === input) return;
3645
+ if (input.value !== value) input.value = value;
3646
+ }
3647
+ findClip(clipId) {
3648
+ const project = this.editor.getProject();
3649
+ for (const t of project.tracks) {
3650
+ const c = t.clips.find((cl) => cl.id === clipId);
3651
+ if (c) return c;
2169
3652
  }
2170
- return best;
3653
+ return null;
2171
3654
  }
2172
3655
  };
2173
3656
 
@@ -2214,6 +3697,24 @@ var ICONS = {
2214
3697
  trash: wrap(
2215
3698
  `<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>`
2216
3699
  ),
3700
+ /** "Skip to start" — vertical bar + left-pointing triangle. Sits to
3701
+ * the left of the keyframe diamond so the clip-edge nav cluster
3702
+ * reads as [|◀ ◇ ▶|] = "go to clip start / add kf / go to clip end". */
3703
+ seekClipStart: wrap(
3704
+ `<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>`
3705
+ ),
3706
+ /** "Skip to end" — mirror of seekClipStart. */
3707
+ seekClipEnd: wrap(
3708
+ `<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>`
3709
+ ),
3710
+ /** Outlined diamond (rotated square) — "add keyframe" affordance. */
3711
+ keyframeOutline: wrap(
3712
+ `<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>`
3713
+ ),
3714
+ /** Filled diamond — shown when a keyframe already exists at playhead. */
3715
+ keyframeFilled: wrap(
3716
+ `<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>`
3717
+ ),
2217
3718
  /** Counter-clockwise circular arrow — "reset to initial layout". */
2218
3719
  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>`
2219
3720
  };
@@ -2240,6 +3741,9 @@ var Toolbar = class {
2240
3741
  splitBtn;
2241
3742
  trimLeftBtn;
2242
3743
  trimRightBtn;
3744
+ seekClipStartBtn;
3745
+ seekClipEndBtn;
3746
+ keyframeBtn;
2243
3747
  playBtn;
2244
3748
  playIcon;
2245
3749
  timeLabel;
@@ -2265,9 +3769,37 @@ var Toolbar = class {
2265
3769
  this.splitBtn = mkIconButton("split", locale.split, () => cb.onSplit(), "aicut-split");
2266
3770
  this.trimLeftBtn = mkIconButton("trimLeft", locale.trimLeft, () => cb.onTrimLeft(), "aicut-trim-left");
2267
3771
  this.trimRightBtn = mkIconButton("trimRight", locale.trimRight, () => cb.onTrimRight(), "aicut-trim-right");
2268
- const speedBtn = mkIconButton("speed", locale.speedComingSoon, () => void 0, "aicut-speed");
2269
- speedBtn.disabled = true;
2270
- left.append(this.undoBtn, this.redoBtn, this.splitBtn, this.trimLeftBtn, this.trimRightBtn, speedBtn);
3772
+ this.seekClipStartBtn = mkIconButton(
3773
+ "seekClipStart",
3774
+ locale.seekClipStart,
3775
+ () => cb.onSeekClipStart(),
3776
+ "aicut-seek-clip-start"
3777
+ );
3778
+ this.seekClipStartBtn.style.display = "none";
3779
+ this.keyframeBtn = mkIconButton(
3780
+ "keyframeOutline",
3781
+ locale.keyframeAdd,
3782
+ () => cb.onKeyframeToggle(),
3783
+ "aicut-keyframe"
3784
+ );
3785
+ this.keyframeBtn.style.display = "none";
3786
+ this.seekClipEndBtn = mkIconButton(
3787
+ "seekClipEnd",
3788
+ locale.seekClipEnd,
3789
+ () => cb.onSeekClipEnd(),
3790
+ "aicut-seek-clip-end"
3791
+ );
3792
+ this.seekClipEndBtn.style.display = "none";
3793
+ left.append(
3794
+ this.undoBtn,
3795
+ this.redoBtn,
3796
+ this.splitBtn,
3797
+ this.trimLeftBtn,
3798
+ this.trimRightBtn,
3799
+ this.seekClipStartBtn,
3800
+ this.keyframeBtn,
3801
+ this.seekClipEndBtn
3802
+ );
2271
3803
  const center = mkGroup("aicut-toolbar-center");
2272
3804
  this.timeLabel = mkSpan("aicut-time-current", "00:00", "aicut-time-current");
2273
3805
  this.playBtn = document.createElement("button");
@@ -2344,11 +3876,38 @@ var Toolbar = class {
2344
3876
  this.trimLeftBtn.disabled = !state.canTrim;
2345
3877
  this.trimRightBtn.disabled = !state.canTrim;
2346
3878
  }
3879
+ if (!this.lastState || this.lastState.clipEdgeNavEnabled !== state.clipEdgeNavEnabled) {
3880
+ const display = state.clipEdgeNavEnabled ? "" : "none";
3881
+ this.seekClipStartBtn.style.display = display;
3882
+ this.seekClipEndBtn.style.display = display;
3883
+ }
3884
+ if (!this.lastState || this.lastState.canSeekClipEdge !== state.canSeekClipEdge) {
3885
+ this.seekClipStartBtn.disabled = !state.canSeekClipEdge;
3886
+ this.seekClipEndBtn.disabled = !state.canSeekClipEdge;
3887
+ }
2347
3888
  if (!this.lastState || this.lastState.snap !== state.snap) {
2348
3889
  this.snapBtn.setAttribute("aria-pressed", state.snap ? "true" : "false");
2349
3890
  this.snapBtn.classList.toggle("aicut-toggle-on", state.snap);
2350
3891
  this.snapBtn.title = state.snap ? this.locale.snapOnTitle : this.locale.snapOffTitle;
2351
3892
  }
3893
+ if (!this.lastState || this.lastState.keyframesEnabled !== state.keyframesEnabled) {
3894
+ this.keyframeBtn.style.display = state.keyframesEnabled ? "" : "none";
3895
+ }
3896
+ if (state.keyframesEnabled) {
3897
+ if (!this.lastState || this.lastState.hasKeyframeAtPlayhead !== state.hasKeyframeAtPlayhead) {
3898
+ this.keyframeBtn.innerHTML = state.hasKeyframeAtPlayhead ? ICONS.keyframeFilled : ICONS.keyframeOutline;
3899
+ const title = state.hasKeyframeAtPlayhead ? this.locale.keyframeRemove : this.locale.keyframeAdd;
3900
+ this.keyframeBtn.title = title;
3901
+ this.keyframeBtn.setAttribute("aria-label", title);
3902
+ this.keyframeBtn.setAttribute(
3903
+ "data-state",
3904
+ state.hasKeyframeAtPlayhead ? "on" : "off"
3905
+ );
3906
+ }
3907
+ if (!this.lastState || this.lastState.canKeyframe !== state.canKeyframe) {
3908
+ this.keyframeBtn.disabled = !state.canKeyframe;
3909
+ }
3910
+ }
2352
3911
  if (!this.lastState || this.lastState.pxPerSec !== state.pxPerSec) {
2353
3912
  const ratio = scaleToSlider(state.pxPerSec);
2354
3913
  const nextVal = String(Math.round(ratio * 100));
@@ -2381,12 +3940,20 @@ var Toolbar = class {
2381
3940
  applyTitle(this.splitBtn, locale.split);
2382
3941
  applyTitle(this.trimLeftBtn, locale.trimLeft);
2383
3942
  applyTitle(this.trimRightBtn, locale.trimRight);
3943
+ applyTitle(this.seekClipStartBtn, locale.seekClipStart);
3944
+ applyTitle(this.seekClipEndBtn, locale.seekClipEnd);
2384
3945
  applyTitle(this.playBtn, locale.playPause);
2385
3946
  applyTitle(this.fullscreenBtn, locale.fullscreen);
2386
3947
  applyTitle(this.snapBtn, locale.snap);
2387
3948
  applyTitle(this.zoomOutBtn, locale.zoomOut);
2388
3949
  applyTitle(this.zoomInBtn, locale.zoomIn);
2389
3950
  applyTitle(this.resetBtn, locale.reset);
3951
+ if (this.keyframeBtn) {
3952
+ const hasKf = this.lastState?.hasKeyframeAtPlayhead === true;
3953
+ const t = hasKf ? locale.keyframeRemove : locale.keyframeAdd;
3954
+ this.keyframeBtn.title = t;
3955
+ this.keyframeBtn.setAttribute("aria-label", t);
3956
+ }
2390
3957
  if (this.lastState) {
2391
3958
  this.snapBtn.title = this.lastState.snap ? locale.snapOnTitle : locale.snapOffTitle;
2392
3959
  }
@@ -2446,6 +4013,8 @@ var EditorUI = class {
2446
4013
  toolbar;
2447
4014
  timelineHost;
2448
4015
  timeline;
4016
+ keyframePanel;
4017
+ keyframeOverlay;
2449
4018
  fullscreen = false;
2450
4019
  onDocKeydown = null;
2451
4020
  constructor(root, editor, cb) {
@@ -2492,10 +4061,14 @@ var EditorUI = class {
2492
4061
  snap: editor.getSnap(),
2493
4062
  autoFit: true,
2494
4063
  locale,
4064
+ keyframesEnabled: editor.isKeyframesEnabled(),
4065
+ selectedKeyframe: editor.getSelectedKeyframe(),
2495
4066
  onSeek: cb.onSeek,
2496
4067
  onSelectClip: cb.onSelectClip,
2497
4068
  onMoveClip: cb.onMoveClip,
2498
4069
  onResizeClip: cb.onResizeClip,
4070
+ onSelectKeyframe: cb.onSelectKeyframe,
4071
+ onMoveKeyframe: cb.onMoveKeyframe,
2499
4072
  onScaleChange: cb.onScaleChange,
2500
4073
  onDeleteTrack: (trackId) => editor.removeTrack(trackId),
2501
4074
  // Mirror the editor's smart routing into the drag preview so
@@ -2520,6 +4093,8 @@ var EditorUI = class {
2520
4093
  };
2521
4094
  }
2522
4095
  });
4096
+ this.keyframePanel = new KeyframePanel(this.preview, editor, locale);
4097
+ this.keyframeOverlay = new KeyframeOverlay(this.preview, editor);
2523
4098
  this.attachKeyboard(cb);
2524
4099
  }
2525
4100
  // ---- fullscreen -----------------------------------------------------
@@ -2569,6 +4144,13 @@ var EditorUI = class {
2569
4144
  const selectedClipId = this.editor.getSelection();
2570
4145
  const pxPerSec = this.editor.getScale();
2571
4146
  const snap = this.editor.getSnap();
4147
+ const kfEnabled = this.editor.isKeyframesEnabled();
4148
+ const kfState = this.computeKeyframeToolbarState(
4149
+ project,
4150
+ selectedClipId,
4151
+ time,
4152
+ kfEnabled
4153
+ );
2572
4154
  this.toolbar.render({
2573
4155
  playing: this.editor.isPlaying(),
2574
4156
  time,
@@ -2577,18 +4159,34 @@ var EditorUI = class {
2577
4159
  canRedo: this.editor.canRedo(),
2578
4160
  canSplit: this.canSplitAt(time),
2579
4161
  canTrim: this.canTrimAt(time, selectedClipId),
4162
+ canSeekClipEdge: selectedClipId != null,
4163
+ clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
2580
4164
  snap,
2581
- pxPerSec
4165
+ pxPerSec,
4166
+ ...kfState
2582
4167
  });
2583
4168
  this.timeline.setProject(project);
2584
4169
  this.timeline.setTime(time);
2585
4170
  this.timeline.setScale(pxPerSec);
2586
4171
  this.timeline.setSelection(selectedClipId);
2587
4172
  this.timeline.setSnap(snap);
4173
+ this.timeline.setKeyframeState({
4174
+ enabled: this.editor.isKeyframesEnabled(),
4175
+ selected: this.editor.getSelectedKeyframe()
4176
+ });
4177
+ this.keyframePanel.render();
2588
4178
  }
2589
4179
  /** Playback-fast path: nudge playhead + toolbar time label only. */
2590
4180
  onTimeTick(timeMs) {
2591
4181
  this.timeline.setTime(timeMs);
4182
+ const selectedClipId = this.editor.getSelection();
4183
+ const kfEnabled = this.editor.isKeyframesEnabled();
4184
+ const kfState = this.computeKeyframeToolbarState(
4185
+ this.editor.getProject(),
4186
+ selectedClipId,
4187
+ timeMs,
4188
+ kfEnabled
4189
+ );
2592
4190
  this.toolbar.render({
2593
4191
  playing: this.editor.isPlaying(),
2594
4192
  time: timeMs,
@@ -2596,9 +4194,12 @@ var EditorUI = class {
2596
4194
  canUndo: this.editor.canUndo(),
2597
4195
  canRedo: this.editor.canRedo(),
2598
4196
  canSplit: this.canSplitAt(timeMs),
2599
- canTrim: this.canTrimAt(timeMs, this.editor.getSelection()),
4197
+ canTrim: this.canTrimAt(timeMs, selectedClipId),
4198
+ canSeekClipEdge: selectedClipId != null,
4199
+ clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
2600
4200
  snap: this.editor.getSnap(),
2601
- pxPerSec: this.editor.getScale()
4201
+ pxPerSec: this.editor.getScale(),
4202
+ ...kfState
2602
4203
  });
2603
4204
  }
2604
4205
  /** Explicit re-fit — Editor calls this when a brand-new project replaces the current one. */
@@ -2610,6 +4211,7 @@ var EditorUI = class {
2610
4211
  this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
2611
4212
  this.fullscreenExitBtn.textContent = locale.exitFullscreen;
2612
4213
  this.timeline.setLocale(locale);
4214
+ this.keyframePanel.setLocale(locale);
2613
4215
  this.render();
2614
4216
  }
2615
4217
  destroy() {
@@ -2619,10 +4221,56 @@ var EditorUI = class {
2619
4221
  }
2620
4222
  this.toolbar.destroy();
2621
4223
  this.timeline.destroy();
4224
+ this.keyframePanel.destroy();
4225
+ this.keyframeOverlay.destroy();
2622
4226
  this.root.innerHTML = "";
2623
4227
  this.root.classList.remove("aicut-root", "aicut-fullscreen");
2624
4228
  }
2625
4229
  // ---- helpers --------------------------------------------------------
4230
+ /** Walk the selected clip + playhead state to figure out (a) whether
4231
+ * the keyframe button should be enabled, and (b) whether a keyframe
4232
+ * already exists at the playhead's clip-local time (so the button
4233
+ * swaps to "remove" mode). */
4234
+ computeKeyframeToolbarState(project, selectedClipId, time, keyframesEnabled) {
4235
+ if (!keyframesEnabled || !selectedClipId) {
4236
+ return {
4237
+ canKeyframe: false,
4238
+ hasKeyframeAtPlayhead: false,
4239
+ keyframesEnabled
4240
+ };
4241
+ }
4242
+ let clip = null;
4243
+ for (const t of project.tracks) {
4244
+ const c = t.clips.find((cl) => cl.id === selectedClipId);
4245
+ if (c) {
4246
+ clip = c;
4247
+ break;
4248
+ }
4249
+ }
4250
+ if (!clip) {
4251
+ return {
4252
+ canKeyframe: false,
4253
+ hasKeyframeAtPlayhead: false,
4254
+ keyframesEnabled
4255
+ };
4256
+ }
4257
+ const localMs = time - clip.start;
4258
+ const duration = clipDuration(clip);
4259
+ if (localMs < 0 || localMs > duration) {
4260
+ return {
4261
+ canKeyframe: false,
4262
+ hasKeyframeAtPlayhead: false,
4263
+ keyframesEnabled
4264
+ };
4265
+ }
4266
+ const roundedLocal = Math.round(localMs);
4267
+ const hasKf = clip.keyframes?.some((k) => k.time === roundedLocal) ?? false;
4268
+ return {
4269
+ canKeyframe: true,
4270
+ hasKeyframeAtPlayhead: hasKf,
4271
+ keyframesEnabled
4272
+ };
4273
+ }
2626
4274
  canSplitAt(timeMs) {
2627
4275
  const project = this.editor.getProject();
2628
4276
  for (const t of project.tracks) {
@@ -2664,6 +4312,22 @@ var EditorUI = class {
2664
4312
  } else if (e.code === "KeyW") {
2665
4313
  e.preventDefault();
2666
4314
  cb.onTrimRight();
4315
+ } else if (e.code === "KeyI" && this.editor.isClipEdgeNavEnabled()) {
4316
+ e.preventDefault();
4317
+ cb.onSeekClipStart();
4318
+ } else if (e.code === "KeyO" && this.editor.isClipEdgeNavEnabled()) {
4319
+ e.preventDefault();
4320
+ cb.onSeekClipEnd();
4321
+ } else if (e.code === "ArrowLeft" || e.code === "ArrowRight") {
4322
+ e.preventDefault();
4323
+ const project = this.editor.getProject();
4324
+ const step = e.shiftKey ? bigFrameStepMs(project) : frameStepMs(project);
4325
+ const dir = e.code === "ArrowLeft" ? -1 : 1;
4326
+ const next = Math.max(
4327
+ 0,
4328
+ Math.min(this.editor.getDuration(), this.editor.getTime() + dir * step)
4329
+ );
4330
+ cb.onSeek(next);
2667
4331
  } else if ((e.metaKey || e.ctrlKey) && e.code === "KeyZ") {
2668
4332
  e.preventDefault();
2669
4333
  if (e.shiftKey) cb.onRedo();
@@ -2692,6 +4356,13 @@ var Editor = class _Editor {
2692
4356
  bus = new EventBus();
2693
4357
  history = new HistoryStack();
2694
4358
  selectedClipId = null;
4359
+ selectedKeyframe = null;
4360
+ keyframesEnabled;
4361
+ clipEdgeNavEnabled;
4362
+ /** Drag-session bookkeeping for ripple-merge undo. See
4363
+ * beginInteraction / endInteraction docs on EditorApi. */
4364
+ interactionDepth = 0;
4365
+ interactionStartSnapshot = null;
2695
4366
  pxPerSec;
2696
4367
  snap;
2697
4368
  locale;
@@ -2702,7 +4373,21 @@ var Editor = class _Editor {
2702
4373
  this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
2703
4374
  this.snap = opts.initialSnap !== false;
2704
4375
  this.locale = mergeLocale(opts.locale);
4376
+ this.keyframesEnabled = opts.keyframes?.enabled === true;
4377
+ this.clipEdgeNavEnabled = opts.clipEdgeNav?.enabled === true;
4378
+ if (opts.trackHeight != null || opts.rulerHeight != null) {
4379
+ setTimelineMetrics({
4380
+ ...opts.trackHeight != null ? { trackHeight: opts.trackHeight } : {},
4381
+ ...opts.rulerHeight != null ? { rulerHeight: opts.rulerHeight } : {}
4382
+ });
4383
+ }
2705
4384
  applyTheme(this.container, opts.theme);
4385
+ if (opts.timelineHeight != null && opts.timelineHeight > 0) {
4386
+ this.container.style.setProperty(
4387
+ "--aicut-timeline-height",
4388
+ `${Math.round(opts.timelineHeight)}px`
4389
+ );
4390
+ }
2706
4391
  this.ui = new EditorUI(this.container, this, {
2707
4392
  onPlayToggle: () => this.togglePlay(),
2708
4393
  onSplit: () => this.split(),
@@ -2718,9 +4403,18 @@ var Editor = class _Editor {
2718
4403
  onSelectClip: (id) => this.setSelection(id),
2719
4404
  onDeleteClip: (id) => this.removeClip(id),
2720
4405
  onMoveClip: (id, opts2) => this.moveClip(id, opts2),
2721
- onResizeClip: (id, edits) => this.resizeClip(id, edits)
4406
+ onResizeClip: (id, edits) => this.resizeClip(id, edits),
4407
+ onSelectKeyframe: (target) => this.setSelectedKeyframe(target),
4408
+ onMoveKeyframe: (clipId, keyframeId, timeMs) => this.moveKeyframe(clipId, keyframeId, timeMs),
4409
+ onKeyframeToggle: () => this.toggleKeyframeAtPlayhead(),
4410
+ onSeekClipStart: () => this.seekToSelectedClipEdge("start"),
4411
+ onSeekClipEnd: () => this.seekToSelectedClipEdge("end")
4412
+ });
4413
+ const engineFactory = opts.playbackEngine ?? ((o) => new HtmlVideoEngine(o));
4414
+ this.engine = engineFactory({
4415
+ host: this.ui.previewHost,
4416
+ project: this.project
2722
4417
  });
2723
- this.engine = new PlaybackEngine(this.ui.previewHost, this.project);
2724
4418
  this.engine.onTimeUpdate = (ms) => {
2725
4419
  this.bus.emit("time", { timeMs: ms });
2726
4420
  this.ui.onTimeTick(ms);
@@ -3138,6 +4832,9 @@ var Editor = class _Editor {
3138
4832
  for (const c of t.clips) {
3139
4833
  if (c.id === ignoreClipId) continue;
3140
4834
  targets.push(c.start, clipEnd(c));
4835
+ if (c.keyframes) {
4836
+ for (const kf of c.keyframes) targets.push(c.start + kf.time);
4837
+ }
3141
4838
  }
3142
4839
  }
3143
4840
  let best = timeMs;
@@ -3159,6 +4856,304 @@ var Editor = class _Editor {
3159
4856
  if (clipId === this.selectedClipId) return;
3160
4857
  this.selectedClipId = clipId;
3161
4858
  this.bus.emit("selectionChange", { clipId });
4859
+ if (this.selectedKeyframe && this.selectedKeyframe.clipId !== clipId) {
4860
+ this.selectedKeyframe = null;
4861
+ this.bus.emit("keyframeSelectionChange", { target: null });
4862
+ }
4863
+ this.ui.render();
4864
+ }
4865
+ // ---- keyframes ------------------------------------------------------
4866
+ isKeyframesEnabled() {
4867
+ return this.keyframesEnabled;
4868
+ }
4869
+ /**
4870
+ * Screen-space CSS-pixel rect of the actively painted frame
4871
+ * (post-transform), relative to the editor's preview element.
4872
+ * Null when no clip is active, the engine doesn't expose
4873
+ * `getFrameRect`, or the rect isn't computed yet. Used by the
4874
+ * library's keyframe-editing overlay.
4875
+ */
4876
+ getActiveFrameRect() {
4877
+ return this.engine.getFrameRect?.() ?? null;
4878
+ }
4879
+ /**
4880
+ * Screen-space CSS-pixel rect of the OUTPUT FRAME (the fixed
4881
+ * stage that clips the rendered video). Different from
4882
+ * `getActiveFrameRect` which includes the keyframe transform —
4883
+ * this one stays put as the user drags / scales the content.
4884
+ * Used by the overlay to anchor the dashed border + drag body.
4885
+ */
4886
+ getActiveOutputFrameRect() {
4887
+ return this.engine.getOutputFrameRect?.() ?? null;
4888
+ }
4889
+ setKeyframesEnabled(enabled) {
4890
+ if (enabled === this.keyframesEnabled) return;
4891
+ this.keyframesEnabled = enabled;
4892
+ if (!enabled && this.selectedKeyframe) {
4893
+ this.selectedKeyframe = null;
4894
+ this.bus.emit("keyframeSelectionChange", { target: null });
4895
+ }
4896
+ this.bus.emit("keyframesEnabledChange", { enabled });
4897
+ this.ui.render();
4898
+ }
4899
+ isClipEdgeNavEnabled() {
4900
+ return this.clipEdgeNavEnabled;
4901
+ }
4902
+ setClipEdgeNavEnabled(enabled) {
4903
+ if (enabled === this.clipEdgeNavEnabled) return;
4904
+ this.clipEdgeNavEnabled = enabled;
4905
+ this.bus.emit("clipEdgeNavEnabledChange", { enabled });
4906
+ this.ui.render();
4907
+ }
4908
+ addKeyframe(clipId, prop, opts = {}) {
4909
+ const trk = findTrackOfClip(this.project, clipId);
4910
+ const cl = trk?.clips.find((c) => c.id === clipId);
4911
+ if (!trk || !cl) return null;
4912
+ const duration = clipDuration(cl);
4913
+ const playheadLocal = this.engine.getTime() - cl.start;
4914
+ const rawTime = opts.time ?? playheadLocal;
4915
+ const time = Math.max(0, Math.min(duration, Math.round(rawTime)));
4916
+ const value = opts.value ?? interpolateProp(cl, prop, time);
4917
+ this.pushHistory();
4918
+ cl.keyframes = upsertKeyframe(
4919
+ cl.keyframes,
4920
+ prop,
4921
+ time,
4922
+ value,
4923
+ () => createId("kf")
4924
+ );
4925
+ cl.keyframes.sort((a, b) => {
4926
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
4927
+ return a.time - b.time;
4928
+ });
4929
+ this.afterMutation();
4930
+ const created = cl.keyframes.find(
4931
+ (k) => k.prop === prop && Math.abs(k.time - time) < 16
4932
+ );
4933
+ return created?.id ?? null;
4934
+ }
4935
+ removeKeyframe(clipId, keyframeId) {
4936
+ const trk = findTrackOfClip(this.project, clipId);
4937
+ const cl = trk?.clips.find((c) => c.id === clipId);
4938
+ if (!trk || !cl || !cl.keyframes) return false;
4939
+ const idx = cl.keyframes.findIndex((k) => k.id === keyframeId);
4940
+ if (idx < 0) return false;
4941
+ this.pushHistory();
4942
+ const next = cl.keyframes.slice();
4943
+ next.splice(idx, 1);
4944
+ cl.keyframes = next.length > 0 ? next : void 0;
4945
+ if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId && this.selectedKeyframe.keyframeId === keyframeId) {
4946
+ this.selectedKeyframe = null;
4947
+ this.bus.emit("keyframeSelectionChange", { target: null });
4948
+ }
4949
+ this.afterMutation();
4950
+ return true;
4951
+ }
4952
+ moveKeyframe(clipId, keyframeId, timeMs) {
4953
+ const trk = findTrackOfClip(this.project, clipId);
4954
+ const cl = trk?.clips.find((c) => c.id === clipId);
4955
+ const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
4956
+ if (!trk || !cl || !kf || !cl.keyframes) return false;
4957
+ const duration = clipDuration(cl);
4958
+ const clamped = Math.max(0, Math.min(duration, Math.round(timeMs)));
4959
+ if (clamped === kf.time) return false;
4960
+ if (cl.keyframes.some(
4961
+ (k) => k.id !== keyframeId && k.prop === kf.prop && k.time === clamped
4962
+ )) {
4963
+ return false;
4964
+ }
4965
+ this.pushHistory();
4966
+ kf.time = clamped;
4967
+ cl.keyframes.sort((a, b) => {
4968
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
4969
+ return a.time - b.time;
4970
+ });
4971
+ this.afterMutation();
4972
+ return true;
4973
+ }
4974
+ setKeyframeValue(clipId, keyframeId, value) {
4975
+ const trk = findTrackOfClip(this.project, clipId);
4976
+ const cl = trk?.clips.find((c) => c.id === clipId);
4977
+ const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
4978
+ if (!trk || !cl || !kf) return false;
4979
+ if (Math.abs(kf.value - value) < 1e-9) return false;
4980
+ this.pushHistory();
4981
+ kf.value = value;
4982
+ this.afterMutation();
4983
+ return true;
4984
+ }
4985
+ setKeyframeEasing(clipId, keyframeId, easing) {
4986
+ const trk = findTrackOfClip(this.project, clipId);
4987
+ const cl = trk?.clips.find((c) => c.id === clipId);
4988
+ const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
4989
+ if (!trk || !cl || !kf) return false;
4990
+ const current = kf.easing ?? "linear";
4991
+ if (current === easing) return false;
4992
+ this.pushHistory();
4993
+ if (easing === "linear") {
4994
+ delete kf.easing;
4995
+ } else {
4996
+ kf.easing = easing;
4997
+ }
4998
+ this.afterMutation();
4999
+ return true;
5000
+ }
5001
+ setKeyframesEasingAtTime(clipId, timeMs, easing) {
5002
+ const trk = findTrackOfClip(this.project, clipId);
5003
+ const cl = trk?.clips.find((c) => c.id === clipId);
5004
+ if (!trk || !cl || !cl.keyframes) return false;
5005
+ const t = Math.round(timeMs);
5006
+ const matches = cl.keyframes.filter((k) => Math.abs(k.time - t) < 16);
5007
+ if (matches.length === 0) return false;
5008
+ const anyChange = matches.some((k) => (k.easing ?? "linear") !== easing);
5009
+ if (!anyChange) return false;
5010
+ this.pushHistory();
5011
+ for (const kf of matches) {
5012
+ if (easing === "linear") delete kf.easing;
5013
+ else kf.easing = easing;
5014
+ }
5015
+ this.afterMutation();
5016
+ return true;
5017
+ }
5018
+ setValueAtPlayhead(clipId, prop, value) {
5019
+ const trk = findTrackOfClip(this.project, clipId);
5020
+ const cl = trk?.clips.find((c) => c.id === clipId);
5021
+ if (!trk || !cl) return false;
5022
+ const duration = clipDuration(cl);
5023
+ const playheadLocal = this.engine.getTime() - cl.start;
5024
+ const time = Math.max(0, Math.min(duration, Math.round(playheadLocal)));
5025
+ const hasKf = cl.keyframes?.some((k) => k.prop === prop) ?? false;
5026
+ if (hasKf) {
5027
+ this.pushHistory();
5028
+ cl.keyframes = upsertKeyframe(
5029
+ cl.keyframes,
5030
+ prop,
5031
+ time,
5032
+ value,
5033
+ () => createId("kf")
5034
+ );
5035
+ cl.keyframes.sort((a, b) => {
5036
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5037
+ return a.time - b.time;
5038
+ });
5039
+ this.afterMutation();
5040
+ return true;
5041
+ }
5042
+ if ((cl[prop] ?? (prop === "scale" ? 1 : 0)) === value) return false;
5043
+ this.pushHistory();
5044
+ cl[prop] = value;
5045
+ this.afterMutation();
5046
+ return true;
5047
+ }
5048
+ getSelectedKeyframe() {
5049
+ return this.selectedKeyframe;
5050
+ }
5051
+ resetClipTransform(clipId) {
5052
+ const trk = findTrackOfClip(this.project, clipId);
5053
+ const cl = trk?.clips.find((c) => c.id === clipId);
5054
+ if (!trk || !cl) return false;
5055
+ const dirty = cl.keyframes && cl.keyframes.length > 0 || cl.panX !== void 0 || cl.panY !== void 0 || cl.scale !== void 0;
5056
+ if (!dirty) return false;
5057
+ this.pushHistory();
5058
+ delete cl.panX;
5059
+ delete cl.panY;
5060
+ delete cl.scale;
5061
+ cl.keyframes = void 0;
5062
+ if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId) {
5063
+ this.selectedKeyframe = null;
5064
+ this.bus.emit("keyframeSelectionChange", { target: null });
5065
+ }
5066
+ this.afterMutation();
5067
+ return true;
5068
+ }
5069
+ resetKeyframesAtTime(clipId, timeMs) {
5070
+ const trk = findTrackOfClip(this.project, clipId);
5071
+ const cl = trk?.clips.find((c) => c.id === clipId);
5072
+ if (!trk || !cl) return false;
5073
+ const duration = clipDuration(cl);
5074
+ const t = Math.max(0, Math.min(duration, Math.round(timeMs)));
5075
+ this.pushHistory();
5076
+ let kfs = cl.keyframes ?? [];
5077
+ kfs = upsertKeyframe(kfs, "panX", t, 0, () => createId("kf"));
5078
+ kfs = upsertKeyframe(kfs, "panY", t, 0, () => createId("kf"));
5079
+ kfs = upsertKeyframe(kfs, "scale", t, 1, () => createId("kf"));
5080
+ kfs.sort((a, b) => {
5081
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5082
+ return a.time - b.time;
5083
+ });
5084
+ cl.keyframes = kfs;
5085
+ this.afterMutation();
5086
+ return true;
5087
+ }
5088
+ seekToClipEdge(clipId, edge) {
5089
+ const trk = findTrackOfClip(this.project, clipId);
5090
+ const cl = trk?.clips.find((c) => c.id === clipId);
5091
+ if (!trk || !cl) return false;
5092
+ const target = edge === "start" ? cl.start : Math.max(cl.start, clipEnd(cl) - 1);
5093
+ if (this.engine.getTime() === target) return false;
5094
+ this.seek(target);
5095
+ return true;
5096
+ }
5097
+ seekToSelectedClipEdge(edge) {
5098
+ if (!this.selectedClipId) return false;
5099
+ return this.seekToClipEdge(this.selectedClipId, edge);
5100
+ }
5101
+ toggleKeyframeAtPlayhead() {
5102
+ const clipId = this.selectedClipId;
5103
+ if (!clipId) return false;
5104
+ const trk = findTrackOfClip(this.project, clipId);
5105
+ const cl = trk?.clips.find((c) => c.id === clipId);
5106
+ if (!trk || !cl) return false;
5107
+ const localMs = this.engine.getTime() - cl.start;
5108
+ const duration = clipDuration(cl);
5109
+ if (localMs < 0 || localMs > duration) return false;
5110
+ const t = Math.round(localMs);
5111
+ const existing = cl.keyframes?.filter((k) => Math.abs(k.time - t) < 16);
5112
+ if (existing && existing.length > 0) {
5113
+ this.pushHistory();
5114
+ const ids = new Set(existing.map((k) => k.id));
5115
+ const next = cl.keyframes.filter((k) => !ids.has(k.id));
5116
+ cl.keyframes = next.length > 0 ? next : void 0;
5117
+ if (this.selectedKeyframe && ids.has(this.selectedKeyframe.keyframeId)) {
5118
+ this.selectedKeyframe = null;
5119
+ this.bus.emit("keyframeSelectionChange", { target: null });
5120
+ }
5121
+ this.afterMutation();
5122
+ return true;
5123
+ }
5124
+ const current = getEffectiveTransform(cl, t);
5125
+ this.pushHistory();
5126
+ let kfs = cl.keyframes ?? [];
5127
+ kfs = upsertKeyframe(kfs, "panX", t, current.panX, () => createId("kf"));
5128
+ kfs = upsertKeyframe(kfs, "panY", t, current.panY, () => createId("kf"));
5129
+ kfs = upsertKeyframe(kfs, "scale", t, current.scale, () => createId("kf"));
5130
+ kfs.sort((a, b) => {
5131
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5132
+ return a.time - b.time;
5133
+ });
5134
+ cl.keyframes = kfs;
5135
+ const anchor = kfs.find(
5136
+ (k) => k.prop === "panX" && Math.abs(k.time - t) < 16
5137
+ );
5138
+ if (anchor) {
5139
+ this.selectedKeyframe = { clipId, keyframeId: anchor.id };
5140
+ this.bus.emit("keyframeSelectionChange", {
5141
+ target: this.selectedKeyframe
5142
+ });
5143
+ }
5144
+ this.afterMutation();
5145
+ return true;
5146
+ }
5147
+ setSelectedKeyframe(target) {
5148
+ if (target?.clipId === this.selectedKeyframe?.clipId && target?.keyframeId === this.selectedKeyframe?.keyframeId) {
5149
+ return;
5150
+ }
5151
+ this.selectedKeyframe = target;
5152
+ if (target && target.clipId !== this.selectedClipId) {
5153
+ this.selectedClipId = target.clipId;
5154
+ this.bus.emit("selectionChange", { clipId: target.clipId });
5155
+ }
5156
+ this.bus.emit("keyframeSelectionChange", { target });
3162
5157
  this.ui.render();
3163
5158
  }
3164
5159
  // ---- history --------------------------------------------------------
@@ -3172,6 +5167,7 @@ var Editor = class _Editor {
3172
5167
  const prev = this.history.undo(this.project);
3173
5168
  if (!prev) return false;
3174
5169
  this.project = prev;
5170
+ this.reconcileSelectionsWithProject();
3175
5171
  this.engine.setProject(this.project);
3176
5172
  this.bus.emit("change", { project: this.getProject() });
3177
5173
  this.emitHistory();
@@ -3182,12 +5178,57 @@ var Editor = class _Editor {
3182
5178
  const next = this.history.redo(this.project);
3183
5179
  if (!next) return false;
3184
5180
  this.project = next;
5181
+ this.reconcileSelectionsWithProject();
3185
5182
  this.engine.setProject(this.project);
3186
5183
  this.bus.emit("change", { project: this.getProject() });
3187
5184
  this.emitHistory();
3188
5185
  this.ui.render();
3189
5186
  return true;
3190
5187
  }
5188
+ beginInteraction() {
5189
+ this.interactionDepth += 1;
5190
+ }
5191
+ endInteraction() {
5192
+ if (this.interactionDepth === 0) return;
5193
+ this.interactionDepth -= 1;
5194
+ if (this.interactionDepth > 0) return;
5195
+ const snapshot = this.interactionStartSnapshot;
5196
+ this.interactionStartSnapshot = null;
5197
+ if (snapshot == null) return;
5198
+ const now = JSON.stringify(this.project);
5199
+ if (now === snapshot) return;
5200
+ this.history.push(JSON.parse(snapshot));
5201
+ this.emitHistory();
5202
+ }
5203
+ /**
5204
+ * Selections (clipId + selectedKeyframe) live OUTSIDE the project
5205
+ * snapshot, so undo / redo can leave them pointing at ids that no
5206
+ * longer exist. Defend against dangling refs by clearing anything
5207
+ * the restored project doesn't actually contain — and emit the
5208
+ * paired change events so panels / overlays hide cleanly instead
5209
+ * of holding zombie references.
5210
+ */
5211
+ reconcileSelectionsWithProject() {
5212
+ if (this.selectedKeyframe) {
5213
+ const trk = findTrackOfClip(this.project, this.selectedKeyframe.clipId);
5214
+ const cl = trk?.clips.find((c) => c.id === this.selectedKeyframe.clipId);
5215
+ const kf = cl?.keyframes?.find(
5216
+ (k) => k.id === this.selectedKeyframe.keyframeId
5217
+ );
5218
+ if (!kf) {
5219
+ this.selectedKeyframe = null;
5220
+ this.bus.emit("keyframeSelectionChange", { target: null });
5221
+ }
5222
+ }
5223
+ if (this.selectedClipId) {
5224
+ const trk = findTrackOfClip(this.project, this.selectedClipId);
5225
+ const cl = trk?.clips.find((c) => c.id === this.selectedClipId);
5226
+ if (!cl) {
5227
+ this.selectedClipId = null;
5228
+ this.bus.emit("selectionChange", { clipId: null });
5229
+ }
5230
+ }
5231
+ }
3191
5232
  // ---- events ---------------------------------------------------------
3192
5233
  on(event, handler) {
3193
5234
  return this.bus.on(event, handler);
@@ -3224,6 +5265,12 @@ var Editor = class _Editor {
3224
5265
  return null;
3225
5266
  }
3226
5267
  pushHistory() {
5268
+ if (this.interactionDepth > 0) {
5269
+ if (this.interactionStartSnapshot == null) {
5270
+ this.interactionStartSnapshot = JSON.stringify(this.project);
5271
+ }
5272
+ return;
5273
+ }
3227
5274
  this.history.push(this.project);
3228
5275
  this.emitHistory();
3229
5276
  }
@@ -3265,6 +5312,6 @@ function clampScale2(s) {
3265
5312
  return Math.max(MIN_PX_PER_SEC, Math.min(MAX_PX_PER_SEC, s));
3266
5313
  }
3267
5314
 
3268
- export { Editor, HEADER_WIDTH, RULER_HEIGHT, TRACK_HEIGHT, Timeline, createEmptyProject, createId, normalizeProject };
5315
+ export { CanvasCompositorEngine, Editor, HEADER_WIDTH, HtmlVideoEngine, RULER_HEIGHT, TRACK_HEIGHT, Timeline, canvasCompositorEngineFactory, createEmptyProject, createId, htmlVideoEngineFactory, normalizeProject, setTimelineMetrics };
3269
5316
  //# sourceMappingURL=index.js.map
3270
5317
  //# sourceMappingURL=index.js.map