@aicut/core 0.5.0 → 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,11 @@ 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
+ }
155
263
  function setTimelineMetrics(opts) {
156
264
  if (opts.trackHeight != null && opts.trackHeight > 0) {
157
265
  TRACK_HEIGHT = Math.round(opts.trackHeight);
@@ -187,8 +295,10 @@ function trackIndexAt(y, trackCount, scrollTop = 0) {
187
295
  return idx;
188
296
  }
189
297
  function xToMs(x, pxPerSec, scrollLeft, showHeader) {
190
- const base = showHeader ? HEADER_WIDTH : 0;
191
- return Math.max(0, (x - base + scrollLeft) / pxPerSec * 1e3);
298
+ return Math.max(
299
+ 0,
300
+ (x - contentLeftX(showHeader) + scrollLeft) / pxPerSec * 1e3
301
+ );
192
302
  }
193
303
  function niceTickSeconds(targetSec) {
194
304
  if (targetSec <= 0) return 1;
@@ -216,6 +326,9 @@ function snapTargets(project, playheadMs, ignoreClipId) {
216
326
  for (const c of t.clips) {
217
327
  if (c.id === ignoreClipId) continue;
218
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
+ }
219
332
  }
220
333
  }
221
334
  return arr;
@@ -266,13 +379,16 @@ function uncoveredIntervals(project) {
266
379
  var HtmlVideoEngine = class {
267
380
  host;
268
381
  mount;
269
- videos = /* @__PURE__ */ new Map();
382
+ sources = /* @__PURE__ */ new Map();
270
383
  project;
271
384
  currentClipId = null;
272
385
  playing = false;
273
386
  timeMs = 0;
274
387
  rafHandle = null;
275
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;
276
392
  /** Public event hooks — set by Editor. */
277
393
  onTimeUpdate;
278
394
  onEnded;
@@ -284,8 +400,15 @@ var HtmlVideoEngine = class {
284
400
  this.project = opts.project;
285
401
  this.mount = document.createElement("div");
286
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
+ });
287
409
  this.host.appendChild(this.mount);
288
410
  this.syncSources();
411
+ this.startTransformLoop();
289
412
  }
290
413
  setProject(next) {
291
414
  this.project = next;
@@ -308,9 +431,9 @@ var HtmlVideoEngine = class {
308
431
  if (this.timeMs < clip.start) this.timeMs = clip.start;
309
432
  this.activate(clip);
310
433
  this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
311
- const v = this.videos.get(clip.sourceId);
312
- if (!v) return;
313
- 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));
314
437
  this.playing = true;
315
438
  this.startTickLoop();
316
439
  }
@@ -321,8 +444,7 @@ var HtmlVideoEngine = class {
321
444
  if (this.currentClipId) {
322
445
  const clip = this.clipById(this.currentClipId);
323
446
  if (clip) {
324
- const v = this.videos.get(clip.sourceId);
325
- v?.pause();
447
+ this.sources.get(clip.sourceId)?.video.pause();
326
448
  }
327
449
  }
328
450
  }
@@ -349,41 +471,137 @@ var HtmlVideoEngine = class {
349
471
  }
350
472
  this.onTimeUpdate?.(clamped);
351
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
+ }
352
550
  destroy() {
353
551
  this.stopTickLoop();
354
- for (const v of this.videos.values()) {
355
- v.pause();
356
- v.removeAttribute("src");
357
- v.load();
358
- v.remove();
552
+ if (this.transformRaf != null) {
553
+ cancelAnimationFrame(this.transformRaf);
554
+ this.transformRaf = null;
359
555
  }
360
- this.videos.clear();
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();
361
563
  this.mount.remove();
362
564
  }
363
565
  // --- internals -------------------------------------------------------
364
566
  syncSources() {
365
567
  const wanted = new Set(this.project.sources.map((s) => s.id));
366
- for (const [id, v] of this.videos) {
568
+ for (const [id, s] of this.sources) {
367
569
  if (!wanted.has(id)) {
368
- v.pause();
369
- v.remove();
370
- this.videos.delete(id);
570
+ s.video.pause();
571
+ s.wrapper.remove();
572
+ this.sources.delete(id);
371
573
  }
372
574
  }
373
575
  for (const src of this.project.sources) {
374
576
  if (src.kind !== "video") continue;
375
- if (this.videos.has(src.id)) 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
+ });
376
590
  const v = document.createElement("video");
377
591
  v.preload = "auto";
378
592
  v.playsInline = true;
379
593
  v.muted = false;
380
594
  v.src = src.url;
381
- v.style.position = "absolute";
382
- v.style.inset = "0";
383
- v.style.width = "100%";
384
- v.style.height = "100%";
385
- v.style.objectFit = "contain";
386
- v.style.visibility = "hidden";
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
+ });
387
605
  const sourceId = src.id;
388
606
  v.addEventListener(
389
607
  "error",
@@ -396,8 +614,9 @@ var HtmlVideoEngine = class {
396
614
  this.onSourceMetadata?.(sourceId, durMs);
397
615
  }
398
616
  });
399
- this.mount.appendChild(v);
400
- this.videos.set(src.id, v);
617
+ wrapper.appendChild(v);
618
+ this.mount.appendChild(wrapper);
619
+ this.sources.set(src.id, { wrapper, video: v });
401
620
  }
402
621
  }
403
622
  activate(clip) {
@@ -405,25 +624,25 @@ var HtmlVideoEngine = class {
405
624
  if (this.currentClipId) {
406
625
  const prev = this.clipById(this.currentClipId);
407
626
  if (prev) {
408
- const v = this.videos.get(prev.sourceId);
409
- if (v) {
410
- v.pause();
411
- v.style.visibility = "hidden";
627
+ const s = this.sources.get(prev.sourceId);
628
+ if (s) {
629
+ s.video.pause();
630
+ s.wrapper.style.visibility = "hidden";
412
631
  }
413
632
  }
414
633
  }
415
634
  this.currentClipId = clip ? clip.id : null;
416
635
  if (clip) {
417
- const v = this.videos.get(clip.sourceId);
418
- if (v) v.style.visibility = "visible";
636
+ const s = this.sources.get(clip.sourceId);
637
+ if (s) s.wrapper.style.visibility = "visible";
419
638
  }
420
639
  }
421
640
  seekVideoToClipOffset(clip, offsetMs) {
422
- const v = this.videos.get(clip.sourceId);
423
- if (!v) return;
641
+ const s = this.sources.get(clip.sourceId);
642
+ if (!s) return;
424
643
  const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
425
- if (Math.abs(v.currentTime - target) > 0.05) {
426
- v.currentTime = target;
644
+ if (Math.abs(s.video.currentTime - target) > 0.05) {
645
+ s.video.currentTime = target;
427
646
  }
428
647
  }
429
648
  clipById(id) {
@@ -435,10 +654,7 @@ var HtmlVideoEngine = class {
435
654
  /**
436
655
  * Find the clip whose timeline range contains `timeMs`, searching
437
656
  * across ALL video tracks. If multiple tracks have a clip at this
438
- * moment, the lowest-index track wins (matches the "Track 1 is
439
- * background" convention used in the auto-split UX — overlapping
440
- * placements would have created a new track on top, but here we
441
- * fall back to the underlying clip).
657
+ * moment, the lowest-index track wins.
442
658
  */
443
659
  clipAtTime(timeMs) {
444
660
  for (const t of this.project.tracks) {
@@ -507,8 +723,8 @@ var HtmlVideoEngine = class {
507
723
  this.timeMs = next.start;
508
724
  this.activate(next);
509
725
  this.seekVideoToClipOffset(next, 0);
510
- const v = this.videos.get(next.sourceId);
511
- if (v) void v.play().catch((err) => this.onError?.(err));
726
+ const s = this.sources.get(next.sourceId);
727
+ if (s) void s.video.play().catch((err) => this.onError?.(err));
512
728
  } else {
513
729
  this.pause();
514
730
  this.onEnded?.();
@@ -517,8 +733,8 @@ var HtmlVideoEngine = class {
517
733
  } else if (clip.id !== this.currentClipId) {
518
734
  this.activate(clip);
519
735
  this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
520
- const v = this.videos.get(clip.sourceId);
521
- if (v) void v.play().catch((err) => this.onError?.(err));
736
+ const s = this.sources.get(clip.sourceId);
737
+ if (s) void s.video.play().catch((err) => this.onError?.(err));
522
738
  }
523
739
  this.onTimeUpdate?.(this.timeMs);
524
740
  }
@@ -541,6 +757,10 @@ var CanvasCompositorEngine = class {
541
757
  rafHandle = null;
542
758
  lastFrameTs = 0;
543
759
  paintedFrames = 0;
760
+ /** Output frame rect (no transform) — fixed bounds. */
761
+ lastOutputRect = null;
762
+ /** Post-transform content rect. */
763
+ lastFrameRect = null;
544
764
  onTimeUpdate;
545
765
  onEnded;
546
766
  onError;
@@ -825,19 +1045,55 @@ var CanvasCompositorEngine = class {
825
1045
  this.ctx.clearRect(0, 0, cw, ch);
826
1046
  const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
827
1047
  const v = clip ? this.videos.get(clip.sourceId) : null;
828
- if (v && v.videoWidth > 0 && v.videoHeight > 0) {
1048
+ if (v && v.videoWidth > 0 && v.videoHeight > 0 && clip) {
829
1049
  const vw = v.videoWidth;
830
1050
  const vh = v.videoHeight;
831
- const scale = Math.min(cw / vw, ch / vh);
832
- const dw = vw * scale;
833
- const dh = vh * scale;
834
- const dx = (cw - dw) / 2;
835
- const dy = (ch - dh) / 2;
836
- this.ctx.drawImage(v, dx, dy, dw, dh);
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();
837
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;
838
1088
  }
839
1089
  this.updateBadge();
840
1090
  }
1091
+ getOutputFrameRect() {
1092
+ return this.lastOutputRect;
1093
+ }
1094
+ getFrameRect() {
1095
+ return this.lastFrameRect;
1096
+ }
841
1097
  updateBadge() {
842
1098
  if (!this.badge) return;
843
1099
  const sec = (this.timeMs / 1e3).toFixed(2);
@@ -1023,7 +1279,7 @@ function drawAll(ctx, state, style, thumbs) {
1023
1279
  const { viewportWidth: W, viewportHeight: H } = state;
1024
1280
  ctx.fillStyle = style.bg;
1025
1281
  ctx.fillRect(0, 0, W, H);
1026
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1282
+ const baseX = contentLeftX(state.showHeader);
1027
1283
  const trackAreaW = W - baseX - SCROLLBAR_THICKNESS;
1028
1284
  const trackAreaH = H - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1029
1285
  ctx.save();
@@ -1063,15 +1319,21 @@ function drawAll(ctx, state, style, thumbs) {
1063
1319
  ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
1064
1320
  ctx.clip();
1065
1321
  drawSnapGuide(ctx, state, style);
1066
- drawPlayhead(ctx, state, style);
1067
1322
  ctx.restore();
1068
1323
  drawScrollbarV(ctx, state, style);
1069
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();
1070
1332
  }
1071
1333
  function drawCoverageGaps(ctx, state, style) {
1072
1334
  const gaps = uncoveredIntervals(state.project);
1073
1335
  if (gaps.length === 0) return;
1074
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1336
+ const baseX = contentLeftX(state.showHeader);
1075
1337
  const trackStackH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1076
1338
  for (const [s, e] of gaps) {
1077
1339
  const x1 = Math.max(
@@ -1110,7 +1372,7 @@ function drawDragGhost(ctx, state, style, thumbs) {
1110
1372
  }
1111
1373
  }
1112
1374
  if (!real) return;
1113
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1375
+ const baseX = contentLeftX(state.showHeader);
1114
1376
  const widthPx = Math.max(2, (real.out - real.in) / 1e3 * state.pxPerSec);
1115
1377
  const startX = baseX + ghost.ghostStart / 1e3 * state.pxPerSec - state.scrollLeft;
1116
1378
  const overlap = ghost.wouldOverlap;
@@ -1180,7 +1442,7 @@ function drawPhantomRow(ctx, trackIndex, baseX, state, style) {
1180
1442
  }
1181
1443
  function drawRuler(ctx, state, style) {
1182
1444
  const { pxPerSec, scrollLeft, viewportWidth: W } = state;
1183
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1445
+ const baseX = contentLeftX(state.showHeader);
1184
1446
  const rulerW = W - baseX;
1185
1447
  ctx.fillStyle = style.bg;
1186
1448
  ctx.fillRect(baseX, 0, rulerW, RULER_HEIGHT);
@@ -1226,7 +1488,7 @@ function drawTracks(ctx, state, style, thumbs) {
1226
1488
  }
1227
1489
  function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
1228
1490
  const { viewportWidth: W } = state;
1229
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1491
+ const baseX = contentLeftX(state.showHeader);
1230
1492
  const y = trackY(trackIndex);
1231
1493
  ctx.fillStyle = style.trackBg;
1232
1494
  ctx.fillRect(baseX, y, W - baseX, TRACK_HEIGHT);
@@ -1264,7 +1526,7 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
1264
1526
  }
1265
1527
  function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumbs, dim, warn) {
1266
1528
  const { pxPerSec, scrollLeft } = state;
1267
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1529
+ const baseX = contentLeftX(state.showHeader);
1268
1530
  const startX = baseX + startMs / 1e3 * pxPerSec - scrollLeft;
1269
1531
  const widthPx = Math.max(2, (clip.out - clip.in) / 1e3 * pxPerSec);
1270
1532
  const y = trackY(trackIndex) + CLIP_INSET;
@@ -1313,6 +1575,34 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
1313
1575
  ctx.fillRect(startX + 2, y + 12, 2, h - 24);
1314
1576
  ctx.fillRect(startX + widthPx - 4, y + 12, 2, h - 24);
1315
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
+ }
1316
1606
  ctx.restore();
1317
1607
  }
1318
1608
  function drawHeaders(ctx, state, style) {
@@ -1363,7 +1653,7 @@ function drawHeaders(ctx, state, style) {
1363
1653
  }
1364
1654
  }
1365
1655
  function drawPlayhead(ctx, state, style) {
1366
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1656
+ const baseX = contentLeftX(state.showHeader);
1367
1657
  const x = baseX + state.timeMs / 1e3 * state.pxPerSec - state.scrollLeft;
1368
1658
  if (x < baseX - 2 || x > state.viewportWidth + 2) return;
1369
1659
  ctx.strokeStyle = style.playhead;
@@ -1377,7 +1667,9 @@ function drawPlayhead(ctx, state, style) {
1377
1667
  const padX = 6;
1378
1668
  const w = ctx.measureText(label).width + padX * 2;
1379
1669
  const h = 14;
1380
- 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));
1381
1673
  const by = 2;
1382
1674
  ctx.fillStyle = style.playhead;
1383
1675
  roundRect(ctx, bx, by, w, h, 4);
@@ -1433,7 +1725,7 @@ function drawScrollbarV(ctx, state, style) {
1433
1725
  }
1434
1726
  function drawScrollbarH(ctx, state, style) {
1435
1727
  if (state.scrollbarOpacityX <= 0.01) return;
1436
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1728
+ const baseX = contentLeftX(state.showHeader);
1437
1729
  const visibleW = state.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1438
1730
  const contentW = contentWidth(state.project, state.pxPerSec);
1439
1731
  if (contentW <= visibleW) return;
@@ -1516,11 +1808,25 @@ function parseColor(s) {
1516
1808
  if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
1517
1809
  return null;
1518
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
+ }
1519
1824
 
1520
1825
  // src/timeline/hit.ts
1826
+ var KEYFRAME_HIT_RADIUS = 8;
1521
1827
  function hitTest(x, y, ctx) {
1522
1828
  if (y < 0 || x < 0) return { kind: "outside" };
1523
- const baseX = ctx.showHeader ? HEADER_WIDTH : 0;
1829
+ const baseX = contentLeftX(ctx.showHeader);
1524
1830
  const visibleH = ctx.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1525
1831
  const contentH = contentHeight(ctx.project.tracks, ctx.isDragging);
1526
1832
  if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
@@ -1572,6 +1878,23 @@ function hitTest(x, y, ctx) {
1572
1878
  if (ti < 0) return { kind: "outside" };
1573
1879
  const track = ctx.project.tracks[ti];
1574
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
+ }
1575
1898
  for (const clip of track.clips) {
1576
1899
  const start = clip.start;
1577
1900
  const end = clip.start + (clip.out - clip.in);
@@ -1590,8 +1913,7 @@ function hitTest(x, y, ctx) {
1590
1913
  return { kind: "track-empty", trackIndex: ti };
1591
1914
  }
1592
1915
  function msToXLocal(ms, ctx) {
1593
- const base = ctx.showHeader ? HEADER_WIDTH : 0;
1594
- return base + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
1916
+ return contentLeftX(ctx.showHeader) + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
1595
1917
  }
1596
1918
 
1597
1919
  // src/timeline/index.ts
@@ -1625,6 +1947,8 @@ var Timeline = class _Timeline {
1625
1947
  readOnly;
1626
1948
  autoFitEnabled;
1627
1949
  locale;
1950
+ keyframesEnabled = false;
1951
+ selectedKeyframe = null;
1628
1952
  scrollLeft = 0;
1629
1953
  scrollTop = 0;
1630
1954
  viewportWidth = 0;
@@ -1643,6 +1967,7 @@ var Timeline = class _Timeline {
1643
1967
  scrollbarDrag = null;
1644
1968
  hoveredClipId = null;
1645
1969
  hoveredTrackIndex = null;
1970
+ hoveredKeyframe = null;
1646
1971
  hoverCursor = "default";
1647
1972
  dropTargetTrackIndex = null;
1648
1973
  snapX = null;
@@ -1681,6 +2006,8 @@ var Timeline = class _Timeline {
1681
2006
  this.readOnly = opts.readOnly === true;
1682
2007
  this.autoFitEnabled = opts.autoFit !== false;
1683
2008
  this.locale = mergeLocale(opts.locale);
2009
+ this.keyframesEnabled = opts.keyframesEnabled === true;
2010
+ this.selectedKeyframe = opts.selectedKeyframe ?? null;
1684
2011
  this.root.classList.add("aicut-timeline-canvas");
1685
2012
  this.root.innerHTML = "";
1686
2013
  this.root.style.position = this.root.style.position || "relative";
@@ -1728,6 +2055,7 @@ var Timeline = class _Timeline {
1728
2055
  this.thumbs.syncSources(this.project.sources);
1729
2056
  this.attachPointer();
1730
2057
  this.attachWheel();
2058
+ this.attachKeyboard();
1731
2059
  this.attachResize();
1732
2060
  this.resizeCanvas();
1733
2061
  this.scheduleRender();
@@ -1808,7 +2136,7 @@ var Timeline = class _Timeline {
1808
2136
  * Exposed publicly so React/Vue wrappers can forward it to a ref.
1809
2137
  */
1810
2138
  getDebugInfo() {
1811
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2139
+ const baseX = contentLeftX(this.showHeader);
1812
2140
  const clips = [];
1813
2141
  for (let ti = 0; ti < this.project.tracks.length; ti++) {
1814
2142
  const t = this.project.tracks[ti];
@@ -1861,17 +2189,17 @@ var Timeline = class _Timeline {
1861
2189
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1862
2190
  }
1863
2191
  computeFitScale() {
1864
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2192
+ const baseX = contentLeftX(this.showHeader);
1865
2193
  const w = this.viewportWidth - baseX - 24;
1866
2194
  const dur = projectDuration(this.project);
1867
2195
  if (w <= 0 || dur <= 0) return null;
1868
2196
  return clampScale(w / (dur / 1e3));
1869
2197
  }
1870
2198
  maxScrollLeft() {
1871
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2199
+ const baseX = contentLeftX(this.showHeader);
1872
2200
  const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1873
2201
  const cw = contentWidth(this.project, this.pxPerSec);
1874
- return Math.max(0, cw - visibleW + 24);
2202
+ return Math.max(0, cw - visibleW + TIMELINE_PAD_RIGHT);
1875
2203
  }
1876
2204
  maxScrollTop() {
1877
2205
  const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
@@ -1973,9 +2301,36 @@ var Timeline = class _Timeline {
1973
2301
  scrollbarOpacityX: this.scrollbarOpacity("h"),
1974
2302
  scrollbarActiveY: this.scrollbarDrag?.axis === "v",
1975
2303
  scrollbarActiveX: this.scrollbarDrag?.axis === "h",
1976
- 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
1977
2313
  };
1978
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
+ }
1979
2334
  readStyle() {
1980
2335
  const cs = getComputedStyle(this.root);
1981
2336
  const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback;
@@ -2004,6 +2359,7 @@ var Timeline = class _Timeline {
2004
2359
  this.canvas.addEventListener("pointerleave", () => {
2005
2360
  if (!this.drag && !this.scrollbarDrag) {
2006
2361
  this.hoveredClipId = null;
2362
+ this.hoveredKeyframe = null;
2007
2363
  this.hoverCursor = "default";
2008
2364
  this.hoverScrollbarY = false;
2009
2365
  this.hoverScrollbarX = false;
@@ -2044,7 +2400,7 @@ var Timeline = class _Timeline {
2044
2400
  return;
2045
2401
  }
2046
2402
  if (target.kind === "scrollbar-track-h") {
2047
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2403
+ const baseX = contentLeftX(this.showHeader);
2048
2404
  const page = Math.max(
2049
2405
  80,
2050
2406
  this.viewportWidth - baseX - SCROLLBAR_THICKNESS
@@ -2084,6 +2440,33 @@ var Timeline = class _Timeline {
2084
2440
  this.scheduleRender();
2085
2441
  return;
2086
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
+ }
2087
2470
  if (target.kind === "clip") {
2088
2471
  const found = findClip(this.project, target.clipId);
2089
2472
  if (!found) return;
@@ -2150,7 +2533,7 @@ var Timeline = class _Timeline {
2150
2533
  const ratio = maxScroll / free;
2151
2534
  this.scrollTop = this.scrollbarDrag.scrollStart + (y - this.scrollbarDrag.pointerStart) * ratio;
2152
2535
  } else {
2153
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2536
+ const baseX = contentLeftX(this.showHeader);
2154
2537
  const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
2155
2538
  const contentW = contentWidth(this.project, this.pxPerSec);
2156
2539
  const trackLen = visibleW - SCROLLBAR_INSET * 2;
@@ -2174,10 +2557,19 @@ var Timeline = class _Timeline {
2174
2557
  let cursor = "default";
2175
2558
  let onScrollbarV = false;
2176
2559
  let onScrollbarH = false;
2560
+ let nextHoverKeyframe = null;
2177
2561
  if (target.kind === "clip") {
2178
2562
  nextHover = target.clipId;
2179
2563
  nextHoverTrack = target.trackIndex;
2180
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";
2181
2573
  } else if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
2182
2574
  nextHover = target.clipId;
2183
2575
  nextHoverTrack = target.trackIndex;
@@ -2201,12 +2593,14 @@ var Timeline = class _Timeline {
2201
2593
  cursor = "default";
2202
2594
  }
2203
2595
  const hoverChanged = onScrollbarV !== this.hoverScrollbarY || onScrollbarH !== this.hoverScrollbarX;
2204
- 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) {
2205
2598
  this.hoveredClipId = nextHover;
2206
2599
  this.hoveredTrackIndex = nextHoverTrack;
2207
2600
  this.hoverCursor = cursor;
2208
2601
  this.hoverScrollbarY = onScrollbarV;
2209
2602
  this.hoverScrollbarX = onScrollbarH;
2603
+ this.hoveredKeyframe = nextHoverKeyframe;
2210
2604
  this.scheduleRender();
2211
2605
  }
2212
2606
  return;
@@ -2229,6 +2623,26 @@ var Timeline = class _Timeline {
2229
2623
  this.maybeStartDragAutoScroll();
2230
2624
  return;
2231
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
+ }
2232
2646
  if (this.drag.kind === "trim-left" || this.drag.kind === "trim-right") {
2233
2647
  const dxPx = x - this.drag.pointerStartX;
2234
2648
  const dxMs = dxPx / this.pxPerSec * 1e3;
@@ -2381,6 +2795,14 @@ var Timeline = class _Timeline {
2381
2795
  });
2382
2796
  this.opts.onChange?.(this.getProject());
2383
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
+ }
2384
2806
  } else if (drag.kind === "trim-left" || drag.kind === "trim-right") {
2385
2807
  const found = findClip(this.project, drag.clipId);
2386
2808
  if (found) {
@@ -2394,6 +2816,25 @@ var Timeline = class _Timeline {
2394
2816
  }
2395
2817
  this.scheduleRender();
2396
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
+ }
2397
2838
  attachWheel() {
2398
2839
  this.canvas.addEventListener(
2399
2840
  "wheel",
@@ -2414,7 +2855,7 @@ var Timeline = class _Timeline {
2414
2855
  if (Math.abs(next - this.pxPerSec) < 0.01) return;
2415
2856
  this.pxPerSec = next;
2416
2857
  this.hasAutoFitted = true;
2417
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2858
+ const baseX = contentLeftX(this.showHeader);
2418
2859
  this.scrollLeft = anchorMs / 1e3 * this.pxPerSec - (cursorX - baseX);
2419
2860
  this.clampScroll();
2420
2861
  this.touchScrollbar("h");
@@ -2477,7 +2918,8 @@ var Timeline = class _Timeline {
2477
2918
  showHeader: this.showHeader,
2478
2919
  viewportWidth: this.viewportWidth,
2479
2920
  viewportHeight: this.viewportHeight,
2480
- isDragging: this.drag?.kind === "move"
2921
+ isDragging: this.drag?.kind === "move",
2922
+ keyframesEnabled: this.keyframesEnabled
2481
2923
  });
2482
2924
  }
2483
2925
  trackIndexAtY(y) {
@@ -2500,7 +2942,7 @@ var Timeline = class _Timeline {
2500
2942
  }
2501
2943
  }
2502
2944
  if (best !== ms) {
2503
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2945
+ const baseX = contentLeftX(this.showHeader);
2504
2946
  this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
2505
2947
  } else {
2506
2948
  this.snapX = null;
@@ -2509,6 +2951,709 @@ var Timeline = class _Timeline {
2509
2951
  }
2510
2952
  };
2511
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();
3504
+ });
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
+ }
3539
+ }
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
+ }
3552
+ }
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);
3593
+ });
3594
+ }
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
+ }
3611
+ }
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;
3627
+ }
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();
3633
+ }
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;
3652
+ }
3653
+ return null;
3654
+ }
3655
+ };
3656
+
2512
3657
  // src/ui/icons.ts
2513
3658
  var wrap = (path) => `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">${path}</svg>`;
2514
3659
  var ICONS = {
@@ -2552,6 +3697,24 @@ var ICONS = {
2552
3697
  trash: wrap(
2553
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>`
2554
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
+ ),
2555
3718
  /** Counter-clockwise circular arrow — "reset to initial layout". */
2556
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>`
2557
3720
  };
@@ -2578,6 +3741,9 @@ var Toolbar = class {
2578
3741
  splitBtn;
2579
3742
  trimLeftBtn;
2580
3743
  trimRightBtn;
3744
+ seekClipStartBtn;
3745
+ seekClipEndBtn;
3746
+ keyframeBtn;
2581
3747
  playBtn;
2582
3748
  playIcon;
2583
3749
  timeLabel;
@@ -2603,9 +3769,37 @@ var Toolbar = class {
2603
3769
  this.splitBtn = mkIconButton("split", locale.split, () => cb.onSplit(), "aicut-split");
2604
3770
  this.trimLeftBtn = mkIconButton("trimLeft", locale.trimLeft, () => cb.onTrimLeft(), "aicut-trim-left");
2605
3771
  this.trimRightBtn = mkIconButton("trimRight", locale.trimRight, () => cb.onTrimRight(), "aicut-trim-right");
2606
- const speedBtn = mkIconButton("speed", locale.speedComingSoon, () => void 0, "aicut-speed");
2607
- speedBtn.disabled = true;
2608
- 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
+ );
2609
3803
  const center = mkGroup("aicut-toolbar-center");
2610
3804
  this.timeLabel = mkSpan("aicut-time-current", "00:00", "aicut-time-current");
2611
3805
  this.playBtn = document.createElement("button");
@@ -2682,11 +3876,38 @@ var Toolbar = class {
2682
3876
  this.trimLeftBtn.disabled = !state.canTrim;
2683
3877
  this.trimRightBtn.disabled = !state.canTrim;
2684
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
+ }
2685
3888
  if (!this.lastState || this.lastState.snap !== state.snap) {
2686
3889
  this.snapBtn.setAttribute("aria-pressed", state.snap ? "true" : "false");
2687
3890
  this.snapBtn.classList.toggle("aicut-toggle-on", state.snap);
2688
3891
  this.snapBtn.title = state.snap ? this.locale.snapOnTitle : this.locale.snapOffTitle;
2689
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
+ }
2690
3911
  if (!this.lastState || this.lastState.pxPerSec !== state.pxPerSec) {
2691
3912
  const ratio = scaleToSlider(state.pxPerSec);
2692
3913
  const nextVal = String(Math.round(ratio * 100));
@@ -2719,12 +3940,20 @@ var Toolbar = class {
2719
3940
  applyTitle(this.splitBtn, locale.split);
2720
3941
  applyTitle(this.trimLeftBtn, locale.trimLeft);
2721
3942
  applyTitle(this.trimRightBtn, locale.trimRight);
3943
+ applyTitle(this.seekClipStartBtn, locale.seekClipStart);
3944
+ applyTitle(this.seekClipEndBtn, locale.seekClipEnd);
2722
3945
  applyTitle(this.playBtn, locale.playPause);
2723
3946
  applyTitle(this.fullscreenBtn, locale.fullscreen);
2724
3947
  applyTitle(this.snapBtn, locale.snap);
2725
3948
  applyTitle(this.zoomOutBtn, locale.zoomOut);
2726
3949
  applyTitle(this.zoomInBtn, locale.zoomIn);
2727
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
+ }
2728
3957
  if (this.lastState) {
2729
3958
  this.snapBtn.title = this.lastState.snap ? locale.snapOnTitle : locale.snapOffTitle;
2730
3959
  }
@@ -2784,6 +4013,8 @@ var EditorUI = class {
2784
4013
  toolbar;
2785
4014
  timelineHost;
2786
4015
  timeline;
4016
+ keyframePanel;
4017
+ keyframeOverlay;
2787
4018
  fullscreen = false;
2788
4019
  onDocKeydown = null;
2789
4020
  constructor(root, editor, cb) {
@@ -2830,10 +4061,14 @@ var EditorUI = class {
2830
4061
  snap: editor.getSnap(),
2831
4062
  autoFit: true,
2832
4063
  locale,
4064
+ keyframesEnabled: editor.isKeyframesEnabled(),
4065
+ selectedKeyframe: editor.getSelectedKeyframe(),
2833
4066
  onSeek: cb.onSeek,
2834
4067
  onSelectClip: cb.onSelectClip,
2835
4068
  onMoveClip: cb.onMoveClip,
2836
4069
  onResizeClip: cb.onResizeClip,
4070
+ onSelectKeyframe: cb.onSelectKeyframe,
4071
+ onMoveKeyframe: cb.onMoveKeyframe,
2837
4072
  onScaleChange: cb.onScaleChange,
2838
4073
  onDeleteTrack: (trackId) => editor.removeTrack(trackId),
2839
4074
  // Mirror the editor's smart routing into the drag preview so
@@ -2858,6 +4093,8 @@ var EditorUI = class {
2858
4093
  };
2859
4094
  }
2860
4095
  });
4096
+ this.keyframePanel = new KeyframePanel(this.preview, editor, locale);
4097
+ this.keyframeOverlay = new KeyframeOverlay(this.preview, editor);
2861
4098
  this.attachKeyboard(cb);
2862
4099
  }
2863
4100
  // ---- fullscreen -----------------------------------------------------
@@ -2907,6 +4144,13 @@ var EditorUI = class {
2907
4144
  const selectedClipId = this.editor.getSelection();
2908
4145
  const pxPerSec = this.editor.getScale();
2909
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
+ );
2910
4154
  this.toolbar.render({
2911
4155
  playing: this.editor.isPlaying(),
2912
4156
  time,
@@ -2915,18 +4159,34 @@ var EditorUI = class {
2915
4159
  canRedo: this.editor.canRedo(),
2916
4160
  canSplit: this.canSplitAt(time),
2917
4161
  canTrim: this.canTrimAt(time, selectedClipId),
4162
+ canSeekClipEdge: selectedClipId != null,
4163
+ clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
2918
4164
  snap,
2919
- pxPerSec
4165
+ pxPerSec,
4166
+ ...kfState
2920
4167
  });
2921
4168
  this.timeline.setProject(project);
2922
4169
  this.timeline.setTime(time);
2923
4170
  this.timeline.setScale(pxPerSec);
2924
4171
  this.timeline.setSelection(selectedClipId);
2925
4172
  this.timeline.setSnap(snap);
4173
+ this.timeline.setKeyframeState({
4174
+ enabled: this.editor.isKeyframesEnabled(),
4175
+ selected: this.editor.getSelectedKeyframe()
4176
+ });
4177
+ this.keyframePanel.render();
2926
4178
  }
2927
4179
  /** Playback-fast path: nudge playhead + toolbar time label only. */
2928
4180
  onTimeTick(timeMs) {
2929
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
+ );
2930
4190
  this.toolbar.render({
2931
4191
  playing: this.editor.isPlaying(),
2932
4192
  time: timeMs,
@@ -2934,9 +4194,12 @@ var EditorUI = class {
2934
4194
  canUndo: this.editor.canUndo(),
2935
4195
  canRedo: this.editor.canRedo(),
2936
4196
  canSplit: this.canSplitAt(timeMs),
2937
- canTrim: this.canTrimAt(timeMs, this.editor.getSelection()),
4197
+ canTrim: this.canTrimAt(timeMs, selectedClipId),
4198
+ canSeekClipEdge: selectedClipId != null,
4199
+ clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
2938
4200
  snap: this.editor.getSnap(),
2939
- pxPerSec: this.editor.getScale()
4201
+ pxPerSec: this.editor.getScale(),
4202
+ ...kfState
2940
4203
  });
2941
4204
  }
2942
4205
  /** Explicit re-fit — Editor calls this when a brand-new project replaces the current one. */
@@ -2948,6 +4211,7 @@ var EditorUI = class {
2948
4211
  this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
2949
4212
  this.fullscreenExitBtn.textContent = locale.exitFullscreen;
2950
4213
  this.timeline.setLocale(locale);
4214
+ this.keyframePanel.setLocale(locale);
2951
4215
  this.render();
2952
4216
  }
2953
4217
  destroy() {
@@ -2957,10 +4221,56 @@ var EditorUI = class {
2957
4221
  }
2958
4222
  this.toolbar.destroy();
2959
4223
  this.timeline.destroy();
4224
+ this.keyframePanel.destroy();
4225
+ this.keyframeOverlay.destroy();
2960
4226
  this.root.innerHTML = "";
2961
4227
  this.root.classList.remove("aicut-root", "aicut-fullscreen");
2962
4228
  }
2963
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
+ }
2964
4274
  canSplitAt(timeMs) {
2965
4275
  const project = this.editor.getProject();
2966
4276
  for (const t of project.tracks) {
@@ -3002,6 +4312,22 @@ var EditorUI = class {
3002
4312
  } else if (e.code === "KeyW") {
3003
4313
  e.preventDefault();
3004
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);
3005
4331
  } else if ((e.metaKey || e.ctrlKey) && e.code === "KeyZ") {
3006
4332
  e.preventDefault();
3007
4333
  if (e.shiftKey) cb.onRedo();
@@ -3030,6 +4356,13 @@ var Editor = class _Editor {
3030
4356
  bus = new EventBus();
3031
4357
  history = new HistoryStack();
3032
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;
3033
4366
  pxPerSec;
3034
4367
  snap;
3035
4368
  locale;
@@ -3040,6 +4373,8 @@ var Editor = class _Editor {
3040
4373
  this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
3041
4374
  this.snap = opts.initialSnap !== false;
3042
4375
  this.locale = mergeLocale(opts.locale);
4376
+ this.keyframesEnabled = opts.keyframes?.enabled === true;
4377
+ this.clipEdgeNavEnabled = opts.clipEdgeNav?.enabled === true;
3043
4378
  if (opts.trackHeight != null || opts.rulerHeight != null) {
3044
4379
  setTimelineMetrics({
3045
4380
  ...opts.trackHeight != null ? { trackHeight: opts.trackHeight } : {},
@@ -3068,7 +4403,12 @@ var Editor = class _Editor {
3068
4403
  onSelectClip: (id) => this.setSelection(id),
3069
4404
  onDeleteClip: (id) => this.removeClip(id),
3070
4405
  onMoveClip: (id, opts2) => this.moveClip(id, opts2),
3071
- 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")
3072
4412
  });
3073
4413
  const engineFactory = opts.playbackEngine ?? ((o) => new HtmlVideoEngine(o));
3074
4414
  this.engine = engineFactory({
@@ -3492,6 +4832,9 @@ var Editor = class _Editor {
3492
4832
  for (const c of t.clips) {
3493
4833
  if (c.id === ignoreClipId) continue;
3494
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
+ }
3495
4838
  }
3496
4839
  }
3497
4840
  let best = timeMs;
@@ -3513,6 +4856,304 @@ var Editor = class _Editor {
3513
4856
  if (clipId === this.selectedClipId) return;
3514
4857
  this.selectedClipId = clipId;
3515
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 });
3516
5157
  this.ui.render();
3517
5158
  }
3518
5159
  // ---- history --------------------------------------------------------
@@ -3526,6 +5167,7 @@ var Editor = class _Editor {
3526
5167
  const prev = this.history.undo(this.project);
3527
5168
  if (!prev) return false;
3528
5169
  this.project = prev;
5170
+ this.reconcileSelectionsWithProject();
3529
5171
  this.engine.setProject(this.project);
3530
5172
  this.bus.emit("change", { project: this.getProject() });
3531
5173
  this.emitHistory();
@@ -3536,12 +5178,57 @@ var Editor = class _Editor {
3536
5178
  const next = this.history.redo(this.project);
3537
5179
  if (!next) return false;
3538
5180
  this.project = next;
5181
+ this.reconcileSelectionsWithProject();
3539
5182
  this.engine.setProject(this.project);
3540
5183
  this.bus.emit("change", { project: this.getProject() });
3541
5184
  this.emitHistory();
3542
5185
  this.ui.render();
3543
5186
  return true;
3544
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
+ }
3545
5232
  // ---- events ---------------------------------------------------------
3546
5233
  on(event, handler) {
3547
5234
  return this.bus.on(event, handler);
@@ -3578,6 +5265,12 @@ var Editor = class _Editor {
3578
5265
  return null;
3579
5266
  }
3580
5267
  pushHistory() {
5268
+ if (this.interactionDepth > 0) {
5269
+ if (this.interactionStartSnapshot == null) {
5270
+ this.interactionStartSnapshot = JSON.stringify(this.project);
5271
+ }
5272
+ return;
5273
+ }
3581
5274
  this.history.push(this.project);
3582
5275
  this.emitHistory();
3583
5276
  }