@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.cjs CHANGED
@@ -80,7 +80,110 @@ function createId(prefix = "id") {
80
80
  return `${prefix}_${t}${rand}`;
81
81
  }
82
82
 
83
+ // src/keyframes/types.ts
84
+ var IDENTITY_TRANSFORM = {
85
+ panX: 0,
86
+ panY: 0,
87
+ scale: 1
88
+ };
89
+ function isIdentityTransform(t) {
90
+ return Math.abs(t.panX) < 1e-3 && Math.abs(t.panY) < 1e-3 && Math.abs(t.scale - 1) < 1e-4;
91
+ }
92
+
93
+ // src/keyframes/interpolate.ts
94
+ function applyEasing(t, easing) {
95
+ switch (easing) {
96
+ case "linear":
97
+ return t;
98
+ case "easeIn":
99
+ return t * t * t;
100
+ case "easeOut": {
101
+ const u = 1 - t;
102
+ return 1 - u * u * u;
103
+ }
104
+ case "easeInOut":
105
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
106
+ }
107
+ }
108
+ function defaultFor(prop) {
109
+ return prop === "scale" ? 1 : 0;
110
+ }
111
+ function staticValue(clip, prop) {
112
+ const v = clip[prop];
113
+ return v ?? defaultFor(prop);
114
+ }
115
+ function keyframesForProp(kfs, prop) {
116
+ const out = [];
117
+ for (const k of kfs) if (k.prop === prop) out.push(k);
118
+ out.sort((a, b) => a.time - b.time);
119
+ return out;
120
+ }
121
+ function hasKeyframesForProp(clip, prop) {
122
+ return clip.keyframes?.some((k) => k.prop === prop) ?? false;
123
+ }
124
+ function interpolateProp(clip, prop, localMs) {
125
+ if (!clip.keyframes || clip.keyframes.length === 0) {
126
+ return staticValue(clip, prop);
127
+ }
128
+ const arr = keyframesForProp(clip.keyframes, prop);
129
+ if (arr.length === 0) return staticValue(clip, prop);
130
+ if (arr.length === 1) return arr[0].value;
131
+ const first = arr[0];
132
+ const last = arr[arr.length - 1];
133
+ if (localMs <= first.time) return first.value;
134
+ if (localMs >= last.time) return last.value;
135
+ for (let i = 0; i < arr.length - 1; i += 1) {
136
+ const a = arr[i];
137
+ const b = arr[i + 1];
138
+ if (localMs >= a.time && localMs <= b.time) {
139
+ if (b.time === a.time) return a.value;
140
+ const rawT = (localMs - a.time) / (b.time - a.time);
141
+ const eased = applyEasing(rawT, a.easing ?? "linear");
142
+ return a.value + (b.value - a.value) * eased;
143
+ }
144
+ }
145
+ return last.value;
146
+ }
147
+ function getEffectiveTransform(clip, localMs) {
148
+ if ((!clip.keyframes || clip.keyframes.length === 0) && clip.panX === void 0 && clip.panY === void 0 && clip.scale === void 0) {
149
+ return IDENTITY_TRANSFORM;
150
+ }
151
+ return {
152
+ panX: interpolateProp(clip, "panX", localMs),
153
+ panY: interpolateProp(clip, "panY", localMs),
154
+ scale: interpolateProp(clip, "scale", localMs)
155
+ };
156
+ }
157
+ function getTransformAtTimelineTime(clip, timelineMs) {
158
+ return getEffectiveTransform(clip, timelineMs - clip.start);
159
+ }
160
+ function upsertKeyframe(kfs, prop, time, value, idFactory) {
161
+ const existing = kfs ?? [];
162
+ const idx = existing.findIndex(
163
+ (k) => k.prop === prop && Math.abs(k.time - time) < 16
164
+ );
165
+ if (idx >= 0) {
166
+ const next = existing.slice();
167
+ next[idx] = { ...next[idx], value };
168
+ return next;
169
+ }
170
+ return [...existing, { id: idFactory(), prop, time, value }];
171
+ }
172
+
83
173
  // src/model.ts
174
+ var KEYFRAME_PROPS = ["panX", "panY", "scale"];
175
+ var DEFAULT_FPS = 30;
176
+ var BIG_FRAME_STEP = 10;
177
+ function projectFps(project) {
178
+ const f = project.fps;
179
+ return f != null && f > 0 ? f : DEFAULT_FPS;
180
+ }
181
+ function frameStepMs(project) {
182
+ return Math.max(1, Math.round(1e3 / projectFps(project)));
183
+ }
184
+ function bigFrameStepMs(project) {
185
+ return Math.max(1, Math.round(BIG_FRAME_STEP * 1e3 / projectFps(project)));
186
+ }
84
187
  function createEmptyProject() {
85
188
  return {
86
189
  version: 1,
@@ -117,11 +220,58 @@ function findTrackOfClip(project, clipId) {
117
220
  function normalizeProject(project) {
118
221
  const sources = project.sources.map((s) => ({ ...s }));
119
222
  const tracks = project.tracks.map((t) => {
120
- const clips = t.clips.filter((c) => c.out > c.in).map((c) => ({ ...c, id: c.id || createId("clip") })).sort((a, b) => a.start - b.start);
223
+ const clips = t.clips.filter((c) => c.out > c.in).map((c) => {
224
+ const out = { ...c, id: c.id || createId("clip") };
225
+ if (c.keyframes && c.keyframes.length > 0) {
226
+ const duration = c.out - c.in;
227
+ out.keyframes = migrateKeyframes(c.keyframes).filter((kf) => kf.time >= 0 && kf.time <= duration).map((kf) => ({ ...kf, id: kf.id || createId("kf") })).sort((a, b) => {
228
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
229
+ return a.time - b.time;
230
+ });
231
+ }
232
+ return out;
233
+ }).sort((a, b) => a.start - b.start);
121
234
  return { ...t, id: t.id || createId("track"), clips };
122
235
  });
123
236
  return { version: 1, sources, tracks };
124
237
  }
238
+ function migrateKeyframes(raw) {
239
+ const out = [];
240
+ for (const kf of raw) {
241
+ if ("prop" in kf && "value" in kf) {
242
+ out.push(kf);
243
+ continue;
244
+ }
245
+ const tuple = kf;
246
+ const id = tuple.id;
247
+ const t = tuple.time;
248
+ if (typeof tuple.x === "number") {
249
+ out.push({
250
+ id: id ? `${id}-px` : createId("kf"),
251
+ prop: "panX",
252
+ time: t,
253
+ value: tuple.x
254
+ });
255
+ }
256
+ if (typeof tuple.y === "number") {
257
+ out.push({
258
+ id: id ? `${id}-py` : createId("kf"),
259
+ prop: "panY",
260
+ time: t,
261
+ value: tuple.y
262
+ });
263
+ }
264
+ if (typeof tuple.scale === "number") {
265
+ out.push({
266
+ id: id ? `${id}-s` : createId("kf"),
267
+ prop: "scale",
268
+ time: t,
269
+ value: tuple.scale
270
+ });
271
+ }
272
+ }
273
+ return out;
274
+ }
125
275
  function splitClipAt(clip, localOffset) {
126
276
  const sourceLen = clip.out - clip.in;
127
277
  if (localOffset <= 0 || localOffset >= sourceLen) return null;
@@ -132,6 +282,47 @@ function splitClipAt(clip, localOffset) {
132
282
  in: clip.in + localOffset,
133
283
  start: clip.start + localOffset
134
284
  };
285
+ if (clip.keyframes && clip.keyframes.length > 0) {
286
+ const leftKf = [];
287
+ const rightKf = [];
288
+ for (const prop of KEYFRAME_PROPS) {
289
+ const propKfs = clip.keyframes.filter((k) => k.prop === prop);
290
+ if (propKfs.length === 0) continue;
291
+ const boundaryValue = interpolateProp(clip, prop, localOffset);
292
+ let leftSeamPresent = false;
293
+ let rightSeamPresent = false;
294
+ for (const kf of propKfs) {
295
+ if (kf.time < localOffset) {
296
+ leftKf.push(kf);
297
+ } else if (kf.time > localOffset) {
298
+ rightKf.push({ ...kf, id: createId("kf"), time: kf.time - localOffset });
299
+ } else {
300
+ leftKf.push(kf);
301
+ leftSeamPresent = true;
302
+ rightKf.push({ ...kf, id: createId("kf"), time: 0 });
303
+ rightSeamPresent = true;
304
+ }
305
+ }
306
+ if (!leftSeamPresent) {
307
+ leftKf.push({
308
+ id: createId("kf"),
309
+ prop,
310
+ time: localOffset,
311
+ value: boundaryValue
312
+ });
313
+ }
314
+ if (!rightSeamPresent) {
315
+ rightKf.push({
316
+ id: createId("kf"),
317
+ prop,
318
+ time: 0,
319
+ value: boundaryValue
320
+ });
321
+ }
322
+ }
323
+ left.keyframes = leftKf.length > 0 ? leftKf : void 0;
324
+ right.keyframes = rightKf.length > 0 ? rightKf : void 0;
325
+ }
135
326
  return [left, right];
136
327
  }
137
328
  function projectDuration(project) {
@@ -151,6 +342,11 @@ var HANDLE_PX = 8;
151
342
  var CLIP_INSET = 6;
152
343
  var SCALE_MIN = 10;
153
344
  var SCALE_MAX = 400;
345
+ var TIMELINE_PAD_LEFT = 12;
346
+ var TIMELINE_PAD_RIGHT = 12;
347
+ function contentLeftX(showHeader) {
348
+ return (showHeader ? HEADER_WIDTH : 0) + TIMELINE_PAD_LEFT;
349
+ }
154
350
  function setTimelineMetrics(opts) {
155
351
  if (opts.trackHeight != null && opts.trackHeight > 0) {
156
352
  exports.TRACK_HEIGHT = Math.round(opts.trackHeight);
@@ -186,8 +382,10 @@ function trackIndexAt(y, trackCount, scrollTop = 0) {
186
382
  return idx;
187
383
  }
188
384
  function xToMs(x, pxPerSec, scrollLeft, showHeader) {
189
- const base = showHeader ? HEADER_WIDTH : 0;
190
- return Math.max(0, (x - base + scrollLeft) / pxPerSec * 1e3);
385
+ return Math.max(
386
+ 0,
387
+ (x - contentLeftX(showHeader) + scrollLeft) / pxPerSec * 1e3
388
+ );
191
389
  }
192
390
  function niceTickSeconds(targetSec) {
193
391
  if (targetSec <= 0) return 1;
@@ -215,6 +413,9 @@ function snapTargets(project, playheadMs, ignoreClipId) {
215
413
  for (const c of t.clips) {
216
414
  if (c.id === ignoreClipId) continue;
217
415
  arr.push(c.start, c.start + (c.out - c.in));
416
+ if (c.keyframes) {
417
+ for (const kf of c.keyframes) arr.push(c.start + kf.time);
418
+ }
218
419
  }
219
420
  }
220
421
  return arr;
@@ -265,13 +466,16 @@ function uncoveredIntervals(project) {
265
466
  var HtmlVideoEngine = class {
266
467
  host;
267
468
  mount;
268
- videos = /* @__PURE__ */ new Map();
469
+ sources = /* @__PURE__ */ new Map();
269
470
  project;
270
471
  currentClipId = null;
271
472
  playing = false;
272
473
  timeMs = 0;
273
474
  rafHandle = null;
274
475
  lastFrameTs = 0;
476
+ /** Permanent rAF that positions the active wrapper at the output
477
+ * rect + pushes keyframe transform onto the inner video via CSS. */
478
+ transformRaf = null;
275
479
  /** Public event hooks — set by Editor. */
276
480
  onTimeUpdate;
277
481
  onEnded;
@@ -283,8 +487,15 @@ var HtmlVideoEngine = class {
283
487
  this.project = opts.project;
284
488
  this.mount = document.createElement("div");
285
489
  this.mount.className = "aicut-preview";
490
+ Object.assign(this.mount.style, {
491
+ position: "absolute",
492
+ inset: "0",
493
+ width: "100%",
494
+ height: "100%"
495
+ });
286
496
  this.host.appendChild(this.mount);
287
497
  this.syncSources();
498
+ this.startTransformLoop();
288
499
  }
289
500
  setProject(next) {
290
501
  this.project = next;
@@ -307,9 +518,9 @@ var HtmlVideoEngine = class {
307
518
  if (this.timeMs < clip.start) this.timeMs = clip.start;
308
519
  this.activate(clip);
309
520
  this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
310
- const v = this.videos.get(clip.sourceId);
311
- if (!v) return;
312
- void v.play().catch((err) => this.onError?.(err));
521
+ const s = this.sources.get(clip.sourceId);
522
+ if (!s) return;
523
+ void s.video.play().catch((err) => this.onError?.(err));
313
524
  this.playing = true;
314
525
  this.startTickLoop();
315
526
  }
@@ -320,8 +531,7 @@ var HtmlVideoEngine = class {
320
531
  if (this.currentClipId) {
321
532
  const clip = this.clipById(this.currentClipId);
322
533
  if (clip) {
323
- const v = this.videos.get(clip.sourceId);
324
- v?.pause();
534
+ this.sources.get(clip.sourceId)?.video.pause();
325
535
  }
326
536
  }
327
537
  }
@@ -348,41 +558,137 @@ var HtmlVideoEngine = class {
348
558
  }
349
559
  this.onTimeUpdate?.(clamped);
350
560
  }
561
+ /**
562
+ * The OUTPUT frame — the fixed stage the rendered video is clipped
563
+ * to. Independent of the keyframe transform. Used by the overlay to
564
+ * draw the dashed border at a stable position.
565
+ */
566
+ getOutputFrameRect() {
567
+ return this.baseFrameRect();
568
+ }
569
+ /**
570
+ * The CONTENT frame — where the transformed video pixels actually
571
+ * land. Equal to the output frame when transform is identity; may
572
+ * extend outside (zoom in) or fit inside (zoom out) when not.
573
+ */
574
+ getFrameRect() {
575
+ const base = this.baseFrameRect();
576
+ if (!base) return null;
577
+ const clip = this.clipById(this.currentClipId);
578
+ if (!clip) return base;
579
+ const t = getEffectiveTransform(clip, this.timeMs - clip.start);
580
+ const cx = base.x + base.w / 2 + t.panX;
581
+ const cy = base.y + base.h / 2 + t.panY;
582
+ const w = base.w * t.scale;
583
+ const h = base.h * t.scale;
584
+ return { x: cx - w / 2, y: cy - h / 2, w, h };
585
+ }
586
+ /** Untransformed contain-letterbox rect — the OUTPUT frame. */
587
+ baseFrameRect() {
588
+ if (!this.currentClipId) return null;
589
+ const clip = this.clipById(this.currentClipId);
590
+ if (!clip) return null;
591
+ const s = this.sources.get(clip.sourceId);
592
+ if (!s) return null;
593
+ const v = s.video;
594
+ if (v.videoWidth === 0 || v.videoHeight === 0) return null;
595
+ const hostRect = this.host.getBoundingClientRect();
596
+ const cw = hostRect.width;
597
+ const ch = hostRect.height;
598
+ if (cw === 0 || ch === 0) return null;
599
+ const scale = Math.min(cw / v.videoWidth, ch / v.videoHeight);
600
+ const w = v.videoWidth * scale;
601
+ const h = v.videoHeight * scale;
602
+ return { x: (cw - w) / 2, y: (ch - h) / 2, w, h };
603
+ }
604
+ /**
605
+ * Permanent rAF that (a) sizes + positions the active wrapper to
606
+ * the output frame, and (b) writes the keyframe transform onto the
607
+ * inner video. Negligible cost — three style writes per frame max.
608
+ */
609
+ startTransformLoop() {
610
+ const tick = () => {
611
+ this.applyTransforms();
612
+ this.transformRaf = requestAnimationFrame(tick);
613
+ };
614
+ this.transformRaf = requestAnimationFrame(tick);
615
+ }
616
+ applyTransforms() {
617
+ const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
618
+ const outRect = this.baseFrameRect();
619
+ if (clip && outRect) {
620
+ const s = this.sources.get(clip.sourceId);
621
+ if (s) {
622
+ Object.assign(s.wrapper.style, {
623
+ left: `${outRect.x}px`,
624
+ top: `${outRect.y}px`,
625
+ width: `${outRect.w}px`,
626
+ height: `${outRect.h}px`
627
+ });
628
+ const t = getEffectiveTransform(clip, this.timeMs - clip.start);
629
+ s.video.style.transform = `translate(${t.panX.toFixed(2)}px, ${t.panY.toFixed(2)}px) scale(${t.scale.toFixed(4)})`;
630
+ }
631
+ }
632
+ for (const [id, s] of this.sources) {
633
+ if (clip && id === clip.sourceId) continue;
634
+ if (s.video.style.transform) s.video.style.transform = "";
635
+ }
636
+ }
351
637
  destroy() {
352
638
  this.stopTickLoop();
353
- for (const v of this.videos.values()) {
354
- v.pause();
355
- v.removeAttribute("src");
356
- v.load();
357
- v.remove();
639
+ if (this.transformRaf != null) {
640
+ cancelAnimationFrame(this.transformRaf);
641
+ this.transformRaf = null;
358
642
  }
359
- this.videos.clear();
643
+ for (const s of this.sources.values()) {
644
+ s.video.pause();
645
+ s.video.removeAttribute("src");
646
+ s.video.load();
647
+ s.wrapper.remove();
648
+ }
649
+ this.sources.clear();
360
650
  this.mount.remove();
361
651
  }
362
652
  // --- internals -------------------------------------------------------
363
653
  syncSources() {
364
654
  const wanted = new Set(this.project.sources.map((s) => s.id));
365
- for (const [id, v] of this.videos) {
655
+ for (const [id, s] of this.sources) {
366
656
  if (!wanted.has(id)) {
367
- v.pause();
368
- v.remove();
369
- this.videos.delete(id);
657
+ s.video.pause();
658
+ s.wrapper.remove();
659
+ this.sources.delete(id);
370
660
  }
371
661
  }
372
662
  for (const src of this.project.sources) {
373
663
  if (src.kind !== "video") continue;
374
- if (this.videos.has(src.id)) continue;
664
+ if (this.sources.has(src.id)) continue;
665
+ const wrapper = document.createElement("div");
666
+ wrapper.className = "aicut-preview-clip";
667
+ Object.assign(wrapper.style, {
668
+ position: "absolute",
669
+ overflow: "hidden",
670
+ visibility: "hidden",
671
+ // Initial bounds — applyTransforms overrides each frame.
672
+ left: "0",
673
+ top: "0",
674
+ width: "0",
675
+ height: "0"
676
+ });
375
677
  const v = document.createElement("video");
376
678
  v.preload = "auto";
377
679
  v.playsInline = true;
378
680
  v.muted = false;
379
681
  v.src = src.url;
380
- v.style.position = "absolute";
381
- v.style.inset = "0";
382
- v.style.width = "100%";
383
- v.style.height = "100%";
384
- v.style.objectFit = "contain";
385
- v.style.visibility = "hidden";
682
+ Object.assign(v.style, {
683
+ position: "absolute",
684
+ inset: "0",
685
+ width: "100%",
686
+ height: "100%",
687
+ objectFit: "fill",
688
+ // Transform origin at center so scale() scales around the
689
+ // video's centroid, not its top-left corner.
690
+ transformOrigin: "50% 50%"
691
+ });
386
692
  const sourceId = src.id;
387
693
  v.addEventListener(
388
694
  "error",
@@ -395,8 +701,9 @@ var HtmlVideoEngine = class {
395
701
  this.onSourceMetadata?.(sourceId, durMs);
396
702
  }
397
703
  });
398
- this.mount.appendChild(v);
399
- this.videos.set(src.id, v);
704
+ wrapper.appendChild(v);
705
+ this.mount.appendChild(wrapper);
706
+ this.sources.set(src.id, { wrapper, video: v });
400
707
  }
401
708
  }
402
709
  activate(clip) {
@@ -404,25 +711,25 @@ var HtmlVideoEngine = class {
404
711
  if (this.currentClipId) {
405
712
  const prev = this.clipById(this.currentClipId);
406
713
  if (prev) {
407
- const v = this.videos.get(prev.sourceId);
408
- if (v) {
409
- v.pause();
410
- v.style.visibility = "hidden";
714
+ const s = this.sources.get(prev.sourceId);
715
+ if (s) {
716
+ s.video.pause();
717
+ s.wrapper.style.visibility = "hidden";
411
718
  }
412
719
  }
413
720
  }
414
721
  this.currentClipId = clip ? clip.id : null;
415
722
  if (clip) {
416
- const v = this.videos.get(clip.sourceId);
417
- if (v) v.style.visibility = "visible";
723
+ const s = this.sources.get(clip.sourceId);
724
+ if (s) s.wrapper.style.visibility = "visible";
418
725
  }
419
726
  }
420
727
  seekVideoToClipOffset(clip, offsetMs) {
421
- const v = this.videos.get(clip.sourceId);
422
- if (!v) return;
728
+ const s = this.sources.get(clip.sourceId);
729
+ if (!s) return;
423
730
  const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
424
- if (Math.abs(v.currentTime - target) > 0.05) {
425
- v.currentTime = target;
731
+ if (Math.abs(s.video.currentTime - target) > 0.05) {
732
+ s.video.currentTime = target;
426
733
  }
427
734
  }
428
735
  clipById(id) {
@@ -434,10 +741,7 @@ var HtmlVideoEngine = class {
434
741
  /**
435
742
  * Find the clip whose timeline range contains `timeMs`, searching
436
743
  * across ALL video tracks. If multiple tracks have a clip at this
437
- * moment, the lowest-index track wins (matches the "Track 1 is
438
- * background" convention used in the auto-split UX — overlapping
439
- * placements would have created a new track on top, but here we
440
- * fall back to the underlying clip).
744
+ * moment, the lowest-index track wins.
441
745
  */
442
746
  clipAtTime(timeMs) {
443
747
  for (const t of this.project.tracks) {
@@ -506,8 +810,8 @@ var HtmlVideoEngine = class {
506
810
  this.timeMs = next.start;
507
811
  this.activate(next);
508
812
  this.seekVideoToClipOffset(next, 0);
509
- const v = this.videos.get(next.sourceId);
510
- if (v) void v.play().catch((err) => this.onError?.(err));
813
+ const s = this.sources.get(next.sourceId);
814
+ if (s) void s.video.play().catch((err) => this.onError?.(err));
511
815
  } else {
512
816
  this.pause();
513
817
  this.onEnded?.();
@@ -516,8 +820,8 @@ var HtmlVideoEngine = class {
516
820
  } else if (clip.id !== this.currentClipId) {
517
821
  this.activate(clip);
518
822
  this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
519
- const v = this.videos.get(clip.sourceId);
520
- if (v) void v.play().catch((err) => this.onError?.(err));
823
+ const s = this.sources.get(clip.sourceId);
824
+ if (s) void s.video.play().catch((err) => this.onError?.(err));
521
825
  }
522
826
  this.onTimeUpdate?.(this.timeMs);
523
827
  }
@@ -540,6 +844,10 @@ var CanvasCompositorEngine = class {
540
844
  rafHandle = null;
541
845
  lastFrameTs = 0;
542
846
  paintedFrames = 0;
847
+ /** Output frame rect (no transform) — fixed bounds. */
848
+ lastOutputRect = null;
849
+ /** Post-transform content rect. */
850
+ lastFrameRect = null;
543
851
  onTimeUpdate;
544
852
  onEnded;
545
853
  onError;
@@ -824,19 +1132,55 @@ var CanvasCompositorEngine = class {
824
1132
  this.ctx.clearRect(0, 0, cw, ch);
825
1133
  const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
826
1134
  const v = clip ? this.videos.get(clip.sourceId) : null;
827
- if (v && v.videoWidth > 0 && v.videoHeight > 0) {
1135
+ if (v && v.videoWidth > 0 && v.videoHeight > 0 && clip) {
828
1136
  const vw = v.videoWidth;
829
1137
  const vh = v.videoHeight;
830
- const scale = Math.min(cw / vw, ch / vh);
831
- const dw = vw * scale;
832
- const dh = vh * scale;
833
- const dx = (cw - dw) / 2;
834
- const dy = (ch - dh) / 2;
835
- this.ctx.drawImage(v, dx, dy, dw, dh);
1138
+ const baseScale = Math.min(cw / vw, ch / vh);
1139
+ const dw = vw * baseScale;
1140
+ const dh = vh * baseScale;
1141
+ const cx = cw / 2;
1142
+ const cy = ch / 2;
1143
+ const dpr = window.devicePixelRatio || 1;
1144
+ const t = getEffectiveTransform(clip, this.timeMs - clip.start);
1145
+ const outX = (cw - dw) / 2;
1146
+ const outY = (ch - dh) / 2;
1147
+ this.ctx.save();
1148
+ this.ctx.beginPath();
1149
+ this.ctx.rect(outX, outY, dw, dh);
1150
+ this.ctx.clip();
1151
+ this.ctx.translate(cx + t.panX * dpr, cy + t.panY * dpr);
1152
+ this.ctx.scale(t.scale, t.scale);
1153
+ this.ctx.drawImage(v, -dw / 2, -dh / 2, dw, dh);
1154
+ this.ctx.restore();
836
1155
  this.paintedFrames += 1;
1156
+ this.lastOutputRect = {
1157
+ x: outX / dpr,
1158
+ y: outY / dpr,
1159
+ w: dw / dpr,
1160
+ h: dh / dpr
1161
+ };
1162
+ const cssCx = cw / (2 * dpr) + t.panX;
1163
+ const cssCy = ch / (2 * dpr) + t.panY;
1164
+ const cssW = dw * t.scale / dpr;
1165
+ const cssH = dh * t.scale / dpr;
1166
+ this.lastFrameRect = {
1167
+ x: cssCx - cssW / 2,
1168
+ y: cssCy - cssH / 2,
1169
+ w: cssW,
1170
+ h: cssH
1171
+ };
1172
+ } else {
1173
+ this.lastFrameRect = null;
1174
+ this.lastOutputRect = null;
837
1175
  }
838
1176
  this.updateBadge();
839
1177
  }
1178
+ getOutputFrameRect() {
1179
+ return this.lastOutputRect;
1180
+ }
1181
+ getFrameRect() {
1182
+ return this.lastFrameRect;
1183
+ }
840
1184
  updateBadge() {
841
1185
  if (!this.badge) return;
842
1186
  const sec = (this.timeMs / 1e3).toFixed(2);
@@ -878,12 +1222,11 @@ function applyTheme(root, theme) {
878
1222
 
879
1223
  // src/i18n.ts
880
1224
  var localeEn = {
881
- undo: "Undo",
882
- redo: "Redo",
883
- split: "Split",
884
- trimLeft: "Trim left edge",
885
- trimRight: "Trim right edge",
886
- speedComingSoon: "Speed (coming soon)",
1225
+ undo: "Undo (\u2318Z)",
1226
+ redo: "Redo (\u21E7\u2318Z)",
1227
+ split: "Split (K)",
1228
+ trimLeft: "Trim left edge (Q)",
1229
+ trimRight: "Trim right edge (W)",
887
1230
  playPause: "Play / Pause (Space)",
888
1231
  fullscreen: "Fullscreen preview",
889
1232
  snap: "Snap",
@@ -892,6 +1235,25 @@ var localeEn = {
892
1235
  zoomOut: "Zoom out",
893
1236
  zoomIn: "Zoom in",
894
1237
  reset: "Reset edits (keep sources)",
1238
+ keyframeAdd: "Add keyframe at playhead",
1239
+ keyframeRemove: "Remove keyframe at playhead",
1240
+ seekClipStart: "Jump to clip start (I)",
1241
+ seekClipEnd: "Jump to clip end (O)",
1242
+ keyframePanelTitle: "Keyframe",
1243
+ keyframePanelLabelX: "X",
1244
+ keyframePanelLabelY: "Y",
1245
+ keyframePanelLabelScale: "Scale",
1246
+ keyframePanelLabelEasing: "Easing",
1247
+ keyframePanelReset: "Reset to 0 0 1",
1248
+ keyframePanelResetTitle: "Pin this keyframe to identity (panX=0, panY=0, scale=1)",
1249
+ keyframePanelBadgePinned: "Pinned at this moment",
1250
+ keyframePanelBadgeAnimated: "Animated \u2014 but not pinned at this exact moment",
1251
+ keyframePanelBadgeStatic: "Static value",
1252
+ keyframePanelTimeSuffix: "s",
1253
+ keyframeEasingLinear: "Linear",
1254
+ keyframeEasingEaseIn: "Ease in",
1255
+ keyframeEasingEaseOut: "Ease out",
1256
+ keyframeEasingEaseInOut: "Ease in-out",
895
1257
  exitFullscreen: "Exit fullscreen",
896
1258
  exitFullscreenTitle: "Exit fullscreen (Esc)",
897
1259
  newTrack: "+ New track",
@@ -899,12 +1261,11 @@ var localeEn = {
899
1261
  audioTrackLabel: "Audio {n}"
900
1262
  };
901
1263
  var localeZh = {
902
- undo: "\u64A4\u9500",
903
- redo: "\u91CD\u505A",
904
- split: "\u5206\u5272",
905
- trimLeft: "\u5411\u5DE6\u88C1\u526A",
906
- trimRight: "\u5411\u53F3\u88C1\u526A",
907
- speedComingSoon: "\u53D8\u901F\uFF08\u5373\u5C06\u5230\u6765\uFF09",
1264
+ undo: "\u64A4\u9500 (\u2318Z)",
1265
+ redo: "\u91CD\u505A (\u21E7\u2318Z)",
1266
+ split: "\u5206\u5272 (K)",
1267
+ trimLeft: "\u5411\u5DE6\u88C1\u526A (Q)",
1268
+ trimRight: "\u5411\u53F3\u88C1\u526A (W)",
908
1269
  playPause: "\u64AD\u653E / \u6682\u505C (Space)",
909
1270
  fullscreen: "\u5168\u5C4F\u9884\u89C8",
910
1271
  snap: "\u5438\u9644",
@@ -913,6 +1274,25 @@ var localeZh = {
913
1274
  zoomOut: "\u7F29\u5C0F",
914
1275
  zoomIn: "\u653E\u5927",
915
1276
  reset: "\u91CD\u7F6E\u7F16\u8F91\uFF08\u4FDD\u7559\u89C6\u9891\u6E90\uFF09",
1277
+ keyframeAdd: "\u6DFB\u52A0\u5173\u952E\u5E27",
1278
+ keyframeRemove: "\u5220\u9664\u5F53\u524D\u5173\u952E\u5E27",
1279
+ seekClipStart: "\u8DF3\u5230\u7247\u6BB5\u8D77\u70B9 (I)",
1280
+ seekClipEnd: "\u8DF3\u5230\u7247\u6BB5\u672B\u5C3E (O)",
1281
+ keyframePanelTitle: "\u5173\u952E\u5E27",
1282
+ keyframePanelLabelX: "X \u4F4D\u79FB",
1283
+ keyframePanelLabelY: "Y \u4F4D\u79FB",
1284
+ keyframePanelLabelScale: "\u7F29\u653E",
1285
+ keyframePanelLabelEasing: "\u7F13\u52A8",
1286
+ keyframePanelReset: "\u91CD\u7F6E\u4E3A 0 0 1",
1287
+ keyframePanelResetTitle: "\u5C06\u8BE5\u5173\u952E\u5E27\u91CD\u7F6E\u4E3A\u521D\u59CB\u59FF\u6001\uFF08panX=0, panY=0, scale=1\uFF09",
1288
+ keyframePanelBadgePinned: "\u5DF2\u5728\u8BE5\u65F6\u523B\u56FA\u5B9A",
1289
+ keyframePanelBadgeAnimated: "\u6574\u6BB5\u6709\u52A8\u753B\uFF0C\u4F46\u5F53\u524D\u65F6\u523B\u6CA1\u6709\u9501\u70B9",
1290
+ keyframePanelBadgeStatic: "\u672A\u52A8\u753B\uFF08\u6CBF\u7528\u9759\u6001\u503C\uFF09",
1291
+ keyframePanelTimeSuffix: "\u79D2",
1292
+ keyframeEasingLinear: "\u7EBF\u6027",
1293
+ keyframeEasingEaseIn: "\u7F13\u5165",
1294
+ keyframeEasingEaseOut: "\u7F13\u51FA",
1295
+ keyframeEasingEaseInOut: "\u7F13\u5165\u7F13\u51FA",
916
1296
  exitFullscreen: "\u9000\u51FA\u5168\u5C4F",
917
1297
  exitFullscreenTitle: "\u9000\u51FA\u5168\u5C4F (Esc)",
918
1298
  newTrack: "+ \u65B0\u8F68\u9053",
@@ -1106,7 +1486,7 @@ function drawAll(ctx, state, style, thumbs) {
1106
1486
  const { viewportWidth: W, viewportHeight: H } = state;
1107
1487
  ctx.fillStyle = style.bg;
1108
1488
  ctx.fillRect(0, 0, W, H);
1109
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1489
+ const baseX = contentLeftX(state.showHeader);
1110
1490
  const trackAreaW = W - baseX - SCROLLBAR_THICKNESS;
1111
1491
  const trackAreaH = H - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
1112
1492
  ctx.save();
@@ -1146,15 +1526,21 @@ function drawAll(ctx, state, style, thumbs) {
1146
1526
  ctx.rect(baseX, 0, trackAreaW, H - SCROLLBAR_THICKNESS);
1147
1527
  ctx.clip();
1148
1528
  drawSnapGuide(ctx, state, style);
1149
- drawPlayhead(ctx, state, style);
1150
1529
  ctx.restore();
1151
1530
  drawScrollbarV(ctx, state, style);
1152
1531
  drawScrollbarH(ctx, state, style);
1532
+ const playheadLeft = state.showHeader ? HEADER_WIDTH : 0;
1533
+ ctx.save();
1534
+ ctx.beginPath();
1535
+ ctx.rect(playheadLeft, 0, W - playheadLeft, H);
1536
+ ctx.clip();
1537
+ drawPlayhead(ctx, state, style);
1538
+ ctx.restore();
1153
1539
  }
1154
1540
  function drawCoverageGaps(ctx, state, style) {
1155
1541
  const gaps = uncoveredIntervals(state.project);
1156
1542
  if (gaps.length === 0) return;
1157
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1543
+ const baseX = contentLeftX(state.showHeader);
1158
1544
  const trackStackH = state.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
1159
1545
  for (const [s, e] of gaps) {
1160
1546
  const x1 = Math.max(
@@ -1193,7 +1579,7 @@ function drawDragGhost(ctx, state, style, thumbs) {
1193
1579
  }
1194
1580
  }
1195
1581
  if (!real) return;
1196
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1582
+ const baseX = contentLeftX(state.showHeader);
1197
1583
  const widthPx = Math.max(2, (real.out - real.in) / 1e3 * state.pxPerSec);
1198
1584
  const startX = baseX + ghost.ghostStart / 1e3 * state.pxPerSec - state.scrollLeft;
1199
1585
  const overlap = ghost.wouldOverlap;
@@ -1263,7 +1649,7 @@ function drawPhantomRow(ctx, trackIndex, baseX, state, style) {
1263
1649
  }
1264
1650
  function drawRuler(ctx, state, style) {
1265
1651
  const { pxPerSec, scrollLeft, viewportWidth: W } = state;
1266
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1652
+ const baseX = contentLeftX(state.showHeader);
1267
1653
  const rulerW = W - baseX;
1268
1654
  ctx.fillStyle = style.bg;
1269
1655
  ctx.fillRect(baseX, 0, rulerW, exports.RULER_HEIGHT);
@@ -1309,7 +1695,7 @@ function drawTracks(ctx, state, style, thumbs) {
1309
1695
  }
1310
1696
  function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
1311
1697
  const { viewportWidth: W } = state;
1312
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1698
+ const baseX = contentLeftX(state.showHeader);
1313
1699
  const y = trackY(trackIndex);
1314
1700
  ctx.fillStyle = style.trackBg;
1315
1701
  ctx.fillRect(baseX, y, W - baseX, exports.TRACK_HEIGHT);
@@ -1347,7 +1733,7 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
1347
1733
  }
1348
1734
  function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumbs, dim, warn) {
1349
1735
  const { pxPerSec, scrollLeft } = state;
1350
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1736
+ const baseX = contentLeftX(state.showHeader);
1351
1737
  const startX = baseX + startMs / 1e3 * pxPerSec - scrollLeft;
1352
1738
  const widthPx = Math.max(2, (clip.out - clip.in) / 1e3 * pxPerSec);
1353
1739
  const y = trackY(trackIndex) + CLIP_INSET;
@@ -1396,6 +1782,34 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
1396
1782
  ctx.fillRect(startX + 2, y + 12, 2, h - 24);
1397
1783
  ctx.fillRect(startX + widthPx - 4, y + 12, 2, h - 24);
1398
1784
  }
1785
+ if (!dim && state.keyframesEnabled && clip.keyframes && clip.keyframes.length > 0) {
1786
+ const diamondY = y + h / 2;
1787
+ const halfSize = 5;
1788
+ const moments = groupKeyframesByTime(clip.keyframes, 16);
1789
+ const ghost = state.keyframeDragGhost;
1790
+ for (const moment of moments) {
1791
+ const draggedHere = ghost ? moment.kfs.find(
1792
+ (k) => ghost.clipId === clip.id && ghost.keyframeId === k.id
1793
+ ) : void 0;
1794
+ const effectiveTime = draggedHere ? ghost.ghostTimeMs : moment.time;
1795
+ const kfX = startX + effectiveTime / 1e3 * pxPerSec;
1796
+ if (kfX < baseX - halfSize || kfX > state.viewportWidth + halfSize) continue;
1797
+ const isSelected = state.selectedKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.selectedKeyframe?.keyframeId);
1798
+ const isHovered = state.hoveredKeyframe?.clipId === clip.id && moment.kfs.some((k) => k.id === state.hoveredKeyframe?.keyframeId);
1799
+ const drawSize = isHovered ? halfSize + 1.5 : halfSize;
1800
+ ctx.beginPath();
1801
+ ctx.moveTo(kfX, diamondY - drawSize);
1802
+ ctx.lineTo(kfX + drawSize, diamondY);
1803
+ ctx.lineTo(kfX, diamondY + drawSize);
1804
+ ctx.lineTo(kfX - drawSize, diamondY);
1805
+ ctx.closePath();
1806
+ ctx.fillStyle = isSelected ? style.selectedRing : isHovered ? "#ffffff" : withAlpha(style.text, 0.85);
1807
+ ctx.fill();
1808
+ ctx.strokeStyle = isHovered ? style.selectedRing : "rgba(0, 0, 0, 0.65)";
1809
+ ctx.lineWidth = isHovered ? 1.5 : 1;
1810
+ ctx.stroke();
1811
+ }
1812
+ }
1399
1813
  ctx.restore();
1400
1814
  }
1401
1815
  function drawHeaders(ctx, state, style) {
@@ -1446,7 +1860,7 @@ function drawHeaders(ctx, state, style) {
1446
1860
  }
1447
1861
  }
1448
1862
  function drawPlayhead(ctx, state, style) {
1449
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1863
+ const baseX = contentLeftX(state.showHeader);
1450
1864
  const x = baseX + state.timeMs / 1e3 * state.pxPerSec - state.scrollLeft;
1451
1865
  if (x < baseX - 2 || x > state.viewportWidth + 2) return;
1452
1866
  ctx.strokeStyle = style.playhead;
@@ -1460,7 +1874,9 @@ function drawPlayhead(ctx, state, style) {
1460
1874
  const padX = 6;
1461
1875
  const w = ctx.measureText(label).width + padX * 2;
1462
1876
  const h = 14;
1463
- const bx = x - w / 2;
1877
+ const contentRight = state.viewportWidth - SCROLLBAR_THICKNESS;
1878
+ const rawBx = x - w / 2;
1879
+ const bx = Math.max(baseX, Math.min(contentRight - w, rawBx));
1464
1880
  const by = 2;
1465
1881
  ctx.fillStyle = style.playhead;
1466
1882
  roundRect(ctx, bx, by, w, h, 4);
@@ -1516,7 +1932,7 @@ function drawScrollbarV(ctx, state, style) {
1516
1932
  }
1517
1933
  function drawScrollbarH(ctx, state, style) {
1518
1934
  if (state.scrollbarOpacityX <= 0.01) return;
1519
- const baseX = state.showHeader ? HEADER_WIDTH : 0;
1935
+ const baseX = contentLeftX(state.showHeader);
1520
1936
  const visibleW = state.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1521
1937
  const contentW = contentWidth(state.project, state.pxPerSec);
1522
1938
  if (contentW <= visibleW) return;
@@ -1599,11 +2015,25 @@ function parseColor(s) {
1599
2015
  if (m) return [Number(m[1]), Number(m[2]), Number(m[3])];
1600
2016
  return null;
1601
2017
  }
2018
+ function groupKeyframesByTime(kfs, epsilonMs) {
2019
+ const sorted = [...kfs].sort((a, b) => a.time - b.time);
2020
+ const out = [];
2021
+ for (const k of sorted) {
2022
+ const last = out[out.length - 1];
2023
+ if (last && Math.abs(k.time - last.time) < epsilonMs) {
2024
+ last.kfs.push(k);
2025
+ } else {
2026
+ out.push({ time: k.time, kfs: [k] });
2027
+ }
2028
+ }
2029
+ return out;
2030
+ }
1602
2031
 
1603
2032
  // src/timeline/hit.ts
2033
+ var KEYFRAME_HIT_RADIUS = 8;
1604
2034
  function hitTest(x, y, ctx) {
1605
2035
  if (y < 0 || x < 0) return { kind: "outside" };
1606
- const baseX = ctx.showHeader ? HEADER_WIDTH : 0;
2036
+ const baseX = contentLeftX(ctx.showHeader);
1607
2037
  const visibleH = ctx.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
1608
2038
  const contentH = contentHeight(ctx.project.tracks, ctx.isDragging);
1609
2039
  if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= exports.RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
@@ -1655,6 +2085,23 @@ function hitTest(x, y, ctx) {
1655
2085
  if (ti < 0) return { kind: "outside" };
1656
2086
  const track = ctx.project.tracks[ti];
1657
2087
  const ms = xToMs(x, ctx.pxPerSec, ctx.scrollLeft, ctx.showHeader);
2088
+ if (ctx.keyframesEnabled) {
2089
+ for (const clip of track.clips) {
2090
+ if (!clip.keyframes || clip.keyframes.length === 0) continue;
2091
+ const startX = msToXLocal(clip.start, ctx);
2092
+ for (const kf of clip.keyframes) {
2093
+ const kfX = startX + kf.time / 1e3 * ctx.pxPerSec;
2094
+ if (Math.abs(x - kfX) <= KEYFRAME_HIT_RADIUS) {
2095
+ return {
2096
+ kind: "keyframe",
2097
+ trackIndex: ti,
2098
+ clipId: clip.id,
2099
+ keyframeId: kf.id
2100
+ };
2101
+ }
2102
+ }
2103
+ }
2104
+ }
1658
2105
  for (const clip of track.clips) {
1659
2106
  const start = clip.start;
1660
2107
  const end = clip.start + (clip.out - clip.in);
@@ -1673,8 +2120,7 @@ function hitTest(x, y, ctx) {
1673
2120
  return { kind: "track-empty", trackIndex: ti };
1674
2121
  }
1675
2122
  function msToXLocal(ms, ctx) {
1676
- const base = ctx.showHeader ? HEADER_WIDTH : 0;
1677
- return base + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
2123
+ return contentLeftX(ctx.showHeader) + ms / 1e3 * ctx.pxPerSec - ctx.scrollLeft;
1678
2124
  }
1679
2125
 
1680
2126
  // src/timeline/index.ts
@@ -1708,6 +2154,8 @@ var Timeline = class _Timeline {
1708
2154
  readOnly;
1709
2155
  autoFitEnabled;
1710
2156
  locale;
2157
+ keyframesEnabled = false;
2158
+ selectedKeyframe = null;
1711
2159
  scrollLeft = 0;
1712
2160
  scrollTop = 0;
1713
2161
  viewportWidth = 0;
@@ -1726,6 +2174,7 @@ var Timeline = class _Timeline {
1726
2174
  scrollbarDrag = null;
1727
2175
  hoveredClipId = null;
1728
2176
  hoveredTrackIndex = null;
2177
+ hoveredKeyframe = null;
1729
2178
  hoverCursor = "default";
1730
2179
  dropTargetTrackIndex = null;
1731
2180
  snapX = null;
@@ -1764,6 +2213,8 @@ var Timeline = class _Timeline {
1764
2213
  this.readOnly = opts.readOnly === true;
1765
2214
  this.autoFitEnabled = opts.autoFit !== false;
1766
2215
  this.locale = mergeLocale(opts.locale);
2216
+ this.keyframesEnabled = opts.keyframesEnabled === true;
2217
+ this.selectedKeyframe = opts.selectedKeyframe ?? null;
1767
2218
  this.root.classList.add("aicut-timeline-canvas");
1768
2219
  this.root.innerHTML = "";
1769
2220
  this.root.style.position = this.root.style.position || "relative";
@@ -1811,6 +2262,7 @@ var Timeline = class _Timeline {
1811
2262
  this.thumbs.syncSources(this.project.sources);
1812
2263
  this.attachPointer();
1813
2264
  this.attachWheel();
2265
+ this.attachKeyboard();
1814
2266
  this.attachResize();
1815
2267
  this.resizeCanvas();
1816
2268
  this.scheduleRender();
@@ -1891,7 +2343,7 @@ var Timeline = class _Timeline {
1891
2343
  * Exposed publicly so React/Vue wrappers can forward it to a ref.
1892
2344
  */
1893
2345
  getDebugInfo() {
1894
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2346
+ const baseX = contentLeftX(this.showHeader);
1895
2347
  const clips = [];
1896
2348
  for (let ti = 0; ti < this.project.tracks.length; ti++) {
1897
2349
  const t = this.project.tracks[ti];
@@ -1944,17 +2396,17 @@ var Timeline = class _Timeline {
1944
2396
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1945
2397
  }
1946
2398
  computeFitScale() {
1947
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2399
+ const baseX = contentLeftX(this.showHeader);
1948
2400
  const w = this.viewportWidth - baseX - 24;
1949
2401
  const dur = projectDuration(this.project);
1950
2402
  if (w <= 0 || dur <= 0) return null;
1951
2403
  return clampScale(w / (dur / 1e3));
1952
2404
  }
1953
2405
  maxScrollLeft() {
1954
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2406
+ const baseX = contentLeftX(this.showHeader);
1955
2407
  const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
1956
2408
  const cw = contentWidth(this.project, this.pxPerSec);
1957
- return Math.max(0, cw - visibleW + 24);
2409
+ return Math.max(0, cw - visibleW + TIMELINE_PAD_RIGHT);
1958
2410
  }
1959
2411
  maxScrollTop() {
1960
2412
  const visibleH = this.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
@@ -2056,9 +2508,36 @@ var Timeline = class _Timeline {
2056
2508
  scrollbarOpacityX: this.scrollbarOpacity("h"),
2057
2509
  scrollbarActiveY: this.scrollbarDrag?.axis === "v",
2058
2510
  scrollbarActiveX: this.scrollbarDrag?.axis === "h",
2059
- locale: this.locale
2511
+ locale: this.locale,
2512
+ keyframesEnabled: this.keyframesEnabled,
2513
+ selectedKeyframe: this.selectedKeyframe,
2514
+ hoveredKeyframe: this.hoveredKeyframe,
2515
+ keyframeDragGhost: this.drag?.kind === "keyframe-drag" ? {
2516
+ clipId: this.drag.clipId,
2517
+ keyframeId: this.drag.keyframeId,
2518
+ ghostTimeMs: this.drag.ghostTimeMs
2519
+ } : null
2060
2520
  };
2061
2521
  }
2522
+ /** Host-pushed state — Editor calls this when its keyframe mode
2523
+ * changes or when a keyframe is selected/deselected externally. */
2524
+ setKeyframeState(state) {
2525
+ let dirty = false;
2526
+ if (state.enabled !== void 0 && state.enabled !== this.keyframesEnabled) {
2527
+ this.keyframesEnabled = state.enabled;
2528
+ dirty = true;
2529
+ }
2530
+ if (state.selected !== void 0) {
2531
+ const a = this.selectedKeyframe;
2532
+ const b = state.selected;
2533
+ const same = a?.clipId === b?.clipId && a?.keyframeId === b?.keyframeId;
2534
+ if (!same) {
2535
+ this.selectedKeyframe = b;
2536
+ dirty = true;
2537
+ }
2538
+ }
2539
+ if (dirty) this.scheduleRender();
2540
+ }
2062
2541
  readStyle() {
2063
2542
  const cs = getComputedStyle(this.root);
2064
2543
  const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback;
@@ -2087,6 +2566,7 @@ var Timeline = class _Timeline {
2087
2566
  this.canvas.addEventListener("pointerleave", () => {
2088
2567
  if (!this.drag && !this.scrollbarDrag) {
2089
2568
  this.hoveredClipId = null;
2569
+ this.hoveredKeyframe = null;
2090
2570
  this.hoverCursor = "default";
2091
2571
  this.hoverScrollbarY = false;
2092
2572
  this.hoverScrollbarX = false;
@@ -2127,7 +2607,7 @@ var Timeline = class _Timeline {
2127
2607
  return;
2128
2608
  }
2129
2609
  if (target.kind === "scrollbar-track-h") {
2130
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2610
+ const baseX = contentLeftX(this.showHeader);
2131
2611
  const page = Math.max(
2132
2612
  80,
2133
2613
  this.viewportWidth - baseX - SCROLLBAR_THICKNESS
@@ -2167,6 +2647,33 @@ var Timeline = class _Timeline {
2167
2647
  this.scheduleRender();
2168
2648
  return;
2169
2649
  }
2650
+ if (target.kind === "keyframe") {
2651
+ const found = findClip(this.project, target.clipId);
2652
+ const kf = found?.clip.keyframes?.find((k) => k.id === target.keyframeId);
2653
+ if (!found || !kf) return;
2654
+ this.selectedKeyframe = {
2655
+ clipId: target.clipId,
2656
+ keyframeId: target.keyframeId
2657
+ };
2658
+ this.opts.onSelectKeyframe?.({
2659
+ clipId: target.clipId,
2660
+ keyframeId: target.keyframeId
2661
+ });
2662
+ const absMs = found.clip.start + kf.time;
2663
+ this.timeMs = absMs;
2664
+ this.opts.onSeek?.(absMs);
2665
+ this.drag = {
2666
+ kind: "keyframe-drag",
2667
+ clipId: target.clipId,
2668
+ keyframeId: target.keyframeId,
2669
+ trackIndex: target.trackIndex,
2670
+ pointerStartX: x,
2671
+ originalTimeMs: kf.time,
2672
+ ghostTimeMs: kf.time
2673
+ };
2674
+ this.scheduleRender();
2675
+ return;
2676
+ }
2170
2677
  if (target.kind === "clip") {
2171
2678
  const found = findClip(this.project, target.clipId);
2172
2679
  if (!found) return;
@@ -2233,7 +2740,7 @@ var Timeline = class _Timeline {
2233
2740
  const ratio = maxScroll / free;
2234
2741
  this.scrollTop = this.scrollbarDrag.scrollStart + (y - this.scrollbarDrag.pointerStart) * ratio;
2235
2742
  } else {
2236
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
2743
+ const baseX = contentLeftX(this.showHeader);
2237
2744
  const visibleW = this.viewportWidth - baseX - SCROLLBAR_THICKNESS;
2238
2745
  const contentW = contentWidth(this.project, this.pxPerSec);
2239
2746
  const trackLen = visibleW - SCROLLBAR_INSET * 2;
@@ -2257,10 +2764,19 @@ var Timeline = class _Timeline {
2257
2764
  let cursor = "default";
2258
2765
  let onScrollbarV = false;
2259
2766
  let onScrollbarH = false;
2767
+ let nextHoverKeyframe = null;
2260
2768
  if (target.kind === "clip") {
2261
2769
  nextHover = target.clipId;
2262
2770
  nextHoverTrack = target.trackIndex;
2263
2771
  cursor = this.readOnly ? "pointer" : "grab";
2772
+ } else if (target.kind === "keyframe") {
2773
+ nextHover = target.clipId;
2774
+ nextHoverTrack = target.trackIndex;
2775
+ nextHoverKeyframe = {
2776
+ clipId: target.clipId,
2777
+ keyframeId: target.keyframeId
2778
+ };
2779
+ cursor = "pointer";
2264
2780
  } else if (target.kind === "clip-handle-left" || target.kind === "clip-handle-right") {
2265
2781
  nextHover = target.clipId;
2266
2782
  nextHoverTrack = target.trackIndex;
@@ -2284,12 +2800,14 @@ var Timeline = class _Timeline {
2284
2800
  cursor = "default";
2285
2801
  }
2286
2802
  const hoverChanged = onScrollbarV !== this.hoverScrollbarY || onScrollbarH !== this.hoverScrollbarX;
2287
- if (nextHover !== this.hoveredClipId || nextHoverTrack !== this.hoveredTrackIndex || cursor !== this.hoverCursor || hoverChanged) {
2803
+ const kfHoverChanged = (nextHoverKeyframe?.clipId ?? null) !== (this.hoveredKeyframe?.clipId ?? null) || (nextHoverKeyframe?.keyframeId ?? null) !== (this.hoveredKeyframe?.keyframeId ?? null);
2804
+ if (nextHover !== this.hoveredClipId || nextHoverTrack !== this.hoveredTrackIndex || cursor !== this.hoverCursor || hoverChanged || kfHoverChanged) {
2288
2805
  this.hoveredClipId = nextHover;
2289
2806
  this.hoveredTrackIndex = nextHoverTrack;
2290
2807
  this.hoverCursor = cursor;
2291
2808
  this.hoverScrollbarY = onScrollbarV;
2292
2809
  this.hoverScrollbarX = onScrollbarH;
2810
+ this.hoveredKeyframe = nextHoverKeyframe;
2293
2811
  this.scheduleRender();
2294
2812
  }
2295
2813
  return;
@@ -2312,6 +2830,26 @@ var Timeline = class _Timeline {
2312
2830
  this.maybeStartDragAutoScroll();
2313
2831
  return;
2314
2832
  }
2833
+ if (this.drag.kind === "keyframe-drag") {
2834
+ const found = findClip(this.project, this.drag.clipId);
2835
+ if (!found) return;
2836
+ const clip = found.clip;
2837
+ const duration = clip.out - clip.in;
2838
+ const dxPx = x - this.drag.pointerStartX;
2839
+ const dxMs = dxPx / this.pxPerSec * 1e3;
2840
+ const nextLocal = Math.max(
2841
+ 0,
2842
+ Math.min(duration, this.drag.originalTimeMs + dxMs)
2843
+ );
2844
+ const snappedAbs = this.applySnap(clip.start + nextLocal, null);
2845
+ const snappedLocal = Math.max(
2846
+ 0,
2847
+ Math.min(duration, snappedAbs - clip.start)
2848
+ );
2849
+ this.drag.ghostTimeMs = Math.round(snappedLocal);
2850
+ this.scheduleRender();
2851
+ return;
2852
+ }
2315
2853
  if (this.drag.kind === "trim-left" || this.drag.kind === "trim-right") {
2316
2854
  const dxPx = x - this.drag.pointerStartX;
2317
2855
  const dxMs = dxPx / this.pxPerSec * 1e3;
@@ -2464,6 +3002,14 @@ var Timeline = class _Timeline {
2464
3002
  });
2465
3003
  this.opts.onChange?.(this.getProject());
2466
3004
  }
3005
+ } else if (drag.kind === "keyframe-drag") {
3006
+ if (drag.ghostTimeMs !== drag.originalTimeMs) {
3007
+ this.opts.onMoveKeyframe?.(
3008
+ drag.clipId,
3009
+ drag.keyframeId,
3010
+ drag.ghostTimeMs
3011
+ );
3012
+ }
2467
3013
  } else if (drag.kind === "trim-left" || drag.kind === "trim-right") {
2468
3014
  const found = findClip(this.project, drag.clipId);
2469
3015
  if (found) {
@@ -2477,6 +3023,25 @@ var Timeline = class _Timeline {
2477
3023
  }
2478
3024
  this.scheduleRender();
2479
3025
  }
3026
+ attachKeyboard() {
3027
+ this.canvas.tabIndex = -1;
3028
+ this.canvas.style.outline = "none";
3029
+ this.canvas.addEventListener("keydown", (e) => {
3030
+ if (e.code !== "ArrowLeft" && e.code !== "ArrowRight") return;
3031
+ e.preventDefault();
3032
+ const step = e.shiftKey ? bigFrameStepMs(this.project) : frameStepMs(this.project);
3033
+ const dir = e.code === "ArrowLeft" ? -1 : 1;
3034
+ const dur = projectDuration(this.project);
3035
+ const next = Math.max(0, Math.min(dur, this.timeMs + dir * step));
3036
+ if (next === this.timeMs) return;
3037
+ this.timeMs = next;
3038
+ this.opts.onSeek?.(next);
3039
+ this.scheduleRender();
3040
+ });
3041
+ this.canvas.addEventListener("pointerdown", () => {
3042
+ if (document.activeElement !== this.canvas) this.canvas.focus();
3043
+ });
3044
+ }
2480
3045
  attachWheel() {
2481
3046
  this.canvas.addEventListener(
2482
3047
  "wheel",
@@ -2497,7 +3062,7 @@ var Timeline = class _Timeline {
2497
3062
  if (Math.abs(next - this.pxPerSec) < 0.01) return;
2498
3063
  this.pxPerSec = next;
2499
3064
  this.hasAutoFitted = true;
2500
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
3065
+ const baseX = contentLeftX(this.showHeader);
2501
3066
  this.scrollLeft = anchorMs / 1e3 * this.pxPerSec - (cursorX - baseX);
2502
3067
  this.clampScroll();
2503
3068
  this.touchScrollbar("h");
@@ -2560,7 +3125,8 @@ var Timeline = class _Timeline {
2560
3125
  showHeader: this.showHeader,
2561
3126
  viewportWidth: this.viewportWidth,
2562
3127
  viewportHeight: this.viewportHeight,
2563
- isDragging: this.drag?.kind === "move"
3128
+ isDragging: this.drag?.kind === "move",
3129
+ keyframesEnabled: this.keyframesEnabled
2564
3130
  });
2565
3131
  }
2566
3132
  trackIndexAtY(y) {
@@ -2583,7 +3149,7 @@ var Timeline = class _Timeline {
2583
3149
  }
2584
3150
  }
2585
3151
  if (best !== ms) {
2586
- const baseX = this.showHeader ? HEADER_WIDTH : 0;
3152
+ const baseX = contentLeftX(this.showHeader);
2587
3153
  this.snapX = baseX + best / 1e3 * this.pxPerSec - this.scrollLeft;
2588
3154
  } else {
2589
3155
  this.snapX = null;
@@ -2592,6 +3158,709 @@ var Timeline = class _Timeline {
2592
3158
  }
2593
3159
  };
2594
3160
 
3161
+ // src/ui/keyframe-overlay.ts
3162
+ var KeyframeOverlay = class _KeyframeOverlay {
3163
+ editor;
3164
+ host;
3165
+ root;
3166
+ frameBody;
3167
+ handles;
3168
+ rafHandle = null;
3169
+ destroyed = false;
3170
+ drag = null;
3171
+ capturedPointerId = null;
3172
+ /** Timer handle for the wheel-burst → interaction commit. */
3173
+ wheelInteractionTimer = null;
3174
+ /** Snap-target threshold in CSS px — the same feel as the timeline. */
3175
+ static SNAP_PX = 8;
3176
+ constructor(host, editor) {
3177
+ this.host = host;
3178
+ this.editor = editor;
3179
+ this.root = document.createElement("div");
3180
+ this.root.className = "aicut-keyframe-overlay";
3181
+ this.root.setAttribute("data-testid", "aicut-keyframe-overlay");
3182
+ this.root.style.display = "none";
3183
+ this.frameBody = document.createElement("div");
3184
+ this.frameBody.className = "aicut-keyframe-overlay__frame";
3185
+ this.frameBody.setAttribute("data-testid", "aicut-keyframe-frame");
3186
+ this.frameBody.addEventListener("pointerdown", (e) => this.onTransStart(e));
3187
+ this.frameBody.addEventListener(
3188
+ "wheel",
3189
+ (e) => this.onPinchScale(e),
3190
+ { passive: false }
3191
+ );
3192
+ this.root.appendChild(this.frameBody);
3193
+ this.handles = {
3194
+ tl: this.makeHandle("tl"),
3195
+ tr: this.makeHandle("tr"),
3196
+ bl: this.makeHandle("bl"),
3197
+ br: this.makeHandle("br")
3198
+ };
3199
+ host.appendChild(this.root);
3200
+ this.startTick();
3201
+ }
3202
+ destroy() {
3203
+ this.destroyed = true;
3204
+ if (this.rafHandle != null) cancelAnimationFrame(this.rafHandle);
3205
+ if (this.wheelInteractionTimer != null) {
3206
+ clearTimeout(this.wheelInteractionTimer);
3207
+ this.wheelInteractionTimer = null;
3208
+ this.editor.endInteraction();
3209
+ }
3210
+ this.root.remove();
3211
+ }
3212
+ // ---- frame body drag (translate) -------------------------------------
3213
+ onTransStart(e) {
3214
+ if (e.button !== 0) return;
3215
+ const ctx = this.ensureSelectedClip();
3216
+ if (!ctx) return;
3217
+ e.preventDefault();
3218
+ e.stopPropagation();
3219
+ this.frameBody.setPointerCapture(e.pointerId);
3220
+ this.capturedPointerId = e.pointerId;
3221
+ this.drag = {
3222
+ kind: "translate",
3223
+ clipId: ctx.clip.id,
3224
+ pointerStartX: e.clientX,
3225
+ pointerStartY: e.clientY,
3226
+ startPanX: ctx.transform.panX,
3227
+ startPanY: ctx.transform.panY
3228
+ };
3229
+ this.editor.beginInteraction();
3230
+ this.frameBody.addEventListener("pointermove", this.onPointerMove);
3231
+ this.frameBody.addEventListener("pointerup", this.onPointerUp);
3232
+ this.frameBody.addEventListener("pointercancel", this.onPointerUp);
3233
+ }
3234
+ // ---- pinch-to-scale --------------------------------------------------
3235
+ onPinchScale(e) {
3236
+ if (!e.ctrlKey) return;
3237
+ const ctx = this.ensureSelectedClip();
3238
+ if (!ctx) return;
3239
+ e.preventDefault();
3240
+ e.stopPropagation();
3241
+ const step = Math.max(-50, Math.min(50, -e.deltaY));
3242
+ const factor = Math.exp(step * 0.01);
3243
+ const next = Math.max(
3244
+ 0.05,
3245
+ Math.min(16, ctx.transform.scale * factor)
3246
+ );
3247
+ if (this.wheelInteractionTimer == null) {
3248
+ this.editor.beginInteraction();
3249
+ } else {
3250
+ clearTimeout(this.wheelInteractionTimer);
3251
+ }
3252
+ this.wheelInteractionTimer = window.setTimeout(() => {
3253
+ this.wheelInteractionTimer = null;
3254
+ this.editor.endInteraction();
3255
+ }, 200);
3256
+ this.editor.setValueAtPlayhead(
3257
+ ctx.clip.id,
3258
+ "scale",
3259
+ Math.round(next * 100) / 100
3260
+ );
3261
+ }
3262
+ // ---- corner-handle drag (scale) --------------------------------------
3263
+ onScaleStart(corner, e) {
3264
+ if (e.button !== 0) return;
3265
+ const ctx = this.ensureSelectedClip();
3266
+ if (!ctx) return;
3267
+ e.preventDefault();
3268
+ e.stopPropagation();
3269
+ const rect = this.editor.getActiveOutputFrameRect() ?? this.editor.getActiveFrameRect();
3270
+ if (!rect) return;
3271
+ const hostRect = this.host.getBoundingClientRect();
3272
+ const cx = hostRect.left + rect.x + rect.w / 2;
3273
+ const cy = hostRect.top + rect.y + rect.h / 2;
3274
+ const startDist = Math.hypot(e.clientX - cx, e.clientY - cy);
3275
+ if (startDist < 1) return;
3276
+ const target = this.handles[corner];
3277
+ target.setPointerCapture(e.pointerId);
3278
+ this.capturedPointerId = e.pointerId;
3279
+ this.drag = {
3280
+ kind: "scale",
3281
+ clipId: ctx.clip.id,
3282
+ centerX: cx,
3283
+ centerY: cy,
3284
+ startDistance: startDist,
3285
+ startScale: ctx.transform.scale
3286
+ };
3287
+ this.editor.beginInteraction();
3288
+ target.addEventListener("pointermove", this.onPointerMove);
3289
+ target.addEventListener("pointerup", this.onPointerUp);
3290
+ target.addEventListener("pointercancel", this.onPointerUp);
3291
+ }
3292
+ onPointerMove = (e) => {
3293
+ if (!this.drag) return;
3294
+ if (this.drag.kind === "translate") {
3295
+ const dx = e.clientX - this.drag.pointerStartX;
3296
+ const dy = e.clientY - this.drag.pointerStartY;
3297
+ const rawPanX = this.drag.startPanX + dx;
3298
+ const rawPanY = this.drag.startPanY + dy;
3299
+ const snapped = this.applySnap(this.drag.clipId, rawPanX, rawPanY);
3300
+ this.editor.setValueAtPlayhead(
3301
+ this.drag.clipId,
3302
+ "panX",
3303
+ Math.round(snapped.panX)
3304
+ );
3305
+ this.editor.setValueAtPlayhead(
3306
+ this.drag.clipId,
3307
+ "panY",
3308
+ Math.round(snapped.panY)
3309
+ );
3310
+ } else {
3311
+ const dist = Math.hypot(
3312
+ e.clientX - this.drag.centerX,
3313
+ e.clientY - this.drag.centerY
3314
+ );
3315
+ const ratio = dist / this.drag.startDistance;
3316
+ const next = Math.max(
3317
+ 0.05,
3318
+ Math.min(16, this.drag.startScale * ratio)
3319
+ );
3320
+ this.editor.setValueAtPlayhead(
3321
+ this.drag.clipId,
3322
+ "scale",
3323
+ Math.round(next * 100) / 100
3324
+ );
3325
+ }
3326
+ };
3327
+ /**
3328
+ * Snap raw pan to: centered (panX/Y = 0) and the four edge-alignment
3329
+ * stops (content's L/R/T/B edge flush with the output's matching
3330
+ * edge). When content is smaller than output, the edge stops collapse
3331
+ * to the same point as 0 — harmless dup. Threshold = 8 CSS px.
3332
+ */
3333
+ applySnap(clipId, rawPanX, rawPanY) {
3334
+ const out = this.editor.getActiveOutputFrameRect();
3335
+ if (!out) return { panX: rawPanX, panY: rawPanY };
3336
+ const clip = this.findClip(clipId);
3337
+ if (!clip) return { panX: rawPanX, panY: rawPanY };
3338
+ const t = (() => {
3339
+ try {
3340
+ const transformer = this.editor.getActiveFrameRect();
3341
+ if (!transformer) return null;
3342
+ return { w: transformer.w, h: transformer.h };
3343
+ } catch {
3344
+ return null;
3345
+ }
3346
+ })();
3347
+ const contentW = t?.w ?? out.w;
3348
+ const contentH = t?.h ?? out.h;
3349
+ const edgeX = (contentW - out.w) / 2;
3350
+ const edgeY = (contentH - out.h) / 2;
3351
+ const xTargets = [0, edgeX, -edgeX];
3352
+ const yTargets = [0, edgeY, -edgeY];
3353
+ const px = nearestSnap(rawPanX, xTargets, _KeyframeOverlay.SNAP_PX);
3354
+ const py = nearestSnap(rawPanY, yTargets, _KeyframeOverlay.SNAP_PX);
3355
+ return { panX: px, panY: py };
3356
+ }
3357
+ findClip(clipId) {
3358
+ const project = this.editor.getProject();
3359
+ for (const t of project.tracks) {
3360
+ const c = t.clips.find((cl) => cl.id === clipId);
3361
+ if (c) return c;
3362
+ }
3363
+ return null;
3364
+ }
3365
+ onPointerUp = (e) => {
3366
+ if (!this.drag) return;
3367
+ const targetEl = e.currentTarget;
3368
+ if (targetEl && this.capturedPointerId === e.pointerId) {
3369
+ try {
3370
+ targetEl.releasePointerCapture(e.pointerId);
3371
+ } catch {
3372
+ }
3373
+ }
3374
+ targetEl?.removeEventListener("pointermove", this.onPointerMove);
3375
+ targetEl?.removeEventListener("pointerup", this.onPointerUp);
3376
+ targetEl?.removeEventListener("pointercancel", this.onPointerUp);
3377
+ this.drag = null;
3378
+ this.capturedPointerId = null;
3379
+ this.editor.endInteraction();
3380
+ };
3381
+ // ---- per-frame layout ------------------------------------------------
3382
+ startTick() {
3383
+ const tick = () => {
3384
+ if (this.destroyed) return;
3385
+ this.layout();
3386
+ this.rafHandle = requestAnimationFrame(tick);
3387
+ };
3388
+ this.rafHandle = requestAnimationFrame(tick);
3389
+ }
3390
+ layout() {
3391
+ const enabled = this.editor.isKeyframesEnabled();
3392
+ if (!enabled) {
3393
+ this.root.style.display = "none";
3394
+ return;
3395
+ }
3396
+ const outRect = this.editor.getActiveOutputFrameRect();
3397
+ const contentRect = this.editor.getActiveFrameRect() ?? outRect;
3398
+ if (!outRect) {
3399
+ this.root.style.display = "none";
3400
+ return;
3401
+ }
3402
+ this.root.style.display = "block";
3403
+ Object.assign(this.frameBody.style, {
3404
+ left: `${outRect.x}px`,
3405
+ top: `${outRect.y}px`,
3406
+ width: `${outRect.w}px`,
3407
+ height: `${outRect.h}px`
3408
+ });
3409
+ const fullyCovered = contentRect ? contentRect.x <= outRect.x + 0.5 && contentRect.x + contentRect.w >= outRect.x + outRect.w - 0.5 && contentRect.y <= outRect.y + 0.5 && contentRect.y + contentRect.h >= outRect.y + outRect.h - 0.5 : true;
3410
+ this.frameBody.classList.toggle(
3411
+ "aicut-keyframe-overlay__frame--warn",
3412
+ !fullyCovered
3413
+ );
3414
+ const halfHandle = 6;
3415
+ const r = contentRect ?? outRect;
3416
+ const fbLeft = r.x;
3417
+ const fbTop = r.y;
3418
+ const fbRight = r.x + r.w;
3419
+ const fbBottom = r.y + r.h;
3420
+ const place = (el, cx, cy) => {
3421
+ el.style.left = `${cx - halfHandle}px`;
3422
+ el.style.top = `${cy - halfHandle}px`;
3423
+ };
3424
+ place(this.handles.tl, fbLeft, fbTop);
3425
+ place(this.handles.tr, fbRight, fbTop);
3426
+ place(this.handles.bl, fbLeft, fbBottom);
3427
+ place(this.handles.br, fbRight, fbBottom);
3428
+ }
3429
+ // ---- helpers ---------------------------------------------------------
3430
+ makeHandle(name) {
3431
+ const el = document.createElement("div");
3432
+ el.className = `aicut-keyframe-overlay__handle aicut-keyframe-overlay__handle--${name}`;
3433
+ el.setAttribute("data-testid", `aicut-keyframe-handle-${name}`);
3434
+ el.addEventListener("pointerdown", (e) => this.onScaleStart(name, e));
3435
+ this.root.appendChild(el);
3436
+ return el;
3437
+ }
3438
+ /**
3439
+ * Resolve the currently selected clip + its current effective
3440
+ * transform (so drag baselines are correct). Returns null when no
3441
+ * clip is selected or the playhead isn't over it.
3442
+ */
3443
+ ensureSelectedClip() {
3444
+ const selectedClipId = this.editor.getSelection();
3445
+ if (!selectedClipId) return null;
3446
+ const project = this.editor.getProject();
3447
+ let clip = null;
3448
+ for (const t of project.tracks) {
3449
+ const c = t.clips.find((cl) => cl.id === selectedClipId);
3450
+ if (c) {
3451
+ clip = c;
3452
+ break;
3453
+ }
3454
+ }
3455
+ if (!clip) return null;
3456
+ const playheadLocal = this.editor.getTime() - clip.start;
3457
+ if (playheadLocal < 0 || playheadLocal > clip.out - clip.in) {
3458
+ return null;
3459
+ }
3460
+ const transform = getEffectiveTransform(clip, playheadLocal);
3461
+ return { clip, transform };
3462
+ }
3463
+ };
3464
+ function nearestSnap(raw, targets, threshold) {
3465
+ let best = raw;
3466
+ let bestDist = threshold;
3467
+ for (const t of targets) {
3468
+ const d = Math.abs(raw - t);
3469
+ if (d < bestDist) {
3470
+ bestDist = d;
3471
+ best = t;
3472
+ }
3473
+ }
3474
+ return best;
3475
+ }
3476
+
3477
+ // src/ui/keyframe-panel.ts
3478
+ var EASING_VALUES = [
3479
+ "linear",
3480
+ "easeIn",
3481
+ "easeOut",
3482
+ "easeInOut"
3483
+ ];
3484
+ function easingLabel(value, locale) {
3485
+ switch (value) {
3486
+ case "linear":
3487
+ return locale.keyframeEasingLinear;
3488
+ case "easeIn":
3489
+ return locale.keyframeEasingEaseIn;
3490
+ case "easeOut":
3491
+ return locale.keyframeEasingEaseOut;
3492
+ case "easeInOut":
3493
+ return locale.keyframeEasingEaseInOut;
3494
+ }
3495
+ }
3496
+ var TIME_EPS_MS = 16;
3497
+ var KeyframePanel = class {
3498
+ editor;
3499
+ locale;
3500
+ root;
3501
+ inputs;
3502
+ kfBadges;
3503
+ timeLabel;
3504
+ titleLabel;
3505
+ resetBtn;
3506
+ easingTrigger;
3507
+ easingTriggerLabel;
3508
+ easingMenu;
3509
+ easingItems;
3510
+ easingValue = "linear";
3511
+ easingDisabled = false;
3512
+ easingOpen = false;
3513
+ easingLabelEl;
3514
+ rowLabels;
3515
+ lastSyncKey = "";
3516
+ // Bound once so add/remove listener pairs reference the same fn.
3517
+ boundOutsideClick = null;
3518
+ boundDocKeydown = null;
3519
+ constructor(host, editor, locale) {
3520
+ this.editor = editor;
3521
+ this.locale = locale;
3522
+ this.root = document.createElement("div");
3523
+ this.root.className = "aicut-keyframe-panel";
3524
+ this.root.setAttribute("data-testid", "aicut-keyframe-panel");
3525
+ this.root.style.display = "none";
3526
+ this.root.addEventListener("pointerdown", (e) => e.stopPropagation());
3527
+ this.root.addEventListener("wheel", (e) => e.stopPropagation());
3528
+ const title = document.createElement("div");
3529
+ title.className = "aicut-keyframe-panel__title";
3530
+ this.titleLabel = document.createElement("span");
3531
+ this.timeLabel = document.createElement("span");
3532
+ this.timeLabel.className = "aicut-keyframe-panel__time";
3533
+ title.append(this.titleLabel, this.timeLabel);
3534
+ this.root.appendChild(title);
3535
+ const xRow = this.makeRow("kf-x", "panX", 1);
3536
+ const yRow = this.makeRow("kf-y", "panY", 1);
3537
+ const scaleRow = this.makeRow("kf-scale", "scale", 0.05);
3538
+ this.inputs = {
3539
+ panX: xRow.input,
3540
+ panY: yRow.input,
3541
+ scale: scaleRow.input
3542
+ };
3543
+ this.rowLabels = {
3544
+ panX: xRow.label,
3545
+ panY: yRow.label,
3546
+ scale: scaleRow.label
3547
+ };
3548
+ this.kfBadges = {
3549
+ panX: this.makeBadge(this.inputs.panX),
3550
+ panY: this.makeBadge(this.inputs.panY),
3551
+ scale: this.makeBadge(this.inputs.scale)
3552
+ };
3553
+ const easingRow = document.createElement("div");
3554
+ easingRow.className = "aicut-keyframe-panel__row aicut-keyframe-panel__row--easing";
3555
+ this.easingLabelEl = document.createElement("label");
3556
+ const dd = document.createElement("div");
3557
+ dd.className = "aicut-keyframe-panel__dropdown";
3558
+ dd.setAttribute("data-testid", "aicut-kf-easing");
3559
+ this.easingTrigger = document.createElement("button");
3560
+ this.easingTrigger.type = "button";
3561
+ this.easingTrigger.className = "aicut-keyframe-panel__dropdown-trigger";
3562
+ this.easingTrigger.setAttribute("aria-haspopup", "listbox");
3563
+ this.easingTrigger.setAttribute("aria-expanded", "false");
3564
+ this.easingTriggerLabel = document.createElement("span");
3565
+ this.easingTriggerLabel.className = "aicut-keyframe-panel__dropdown-trigger-label";
3566
+ const chevron = document.createElement("span");
3567
+ chevron.className = "aicut-keyframe-panel__dropdown-chevron";
3568
+ chevron.setAttribute("aria-hidden", "true");
3569
+ this.easingTrigger.append(this.easingTriggerLabel, chevron);
3570
+ this.easingTrigger.addEventListener("click", (e) => {
3571
+ e.stopPropagation();
3572
+ if (this.easingDisabled) return;
3573
+ this.toggleEasingMenu();
3574
+ });
3575
+ this.easingTrigger.addEventListener("keydown", (e) => {
3576
+ if (this.easingDisabled) return;
3577
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
3578
+ e.preventDefault();
3579
+ if (!this.easingOpen) this.openEasingMenu();
3580
+ this.easingItems.get(this.easingValue)?.focus();
3581
+ }
3582
+ });
3583
+ this.easingMenu = document.createElement("ul");
3584
+ this.easingMenu.className = "aicut-keyframe-panel__dropdown-menu";
3585
+ this.easingMenu.setAttribute("role", "listbox");
3586
+ this.easingMenu.style.display = "none";
3587
+ this.easingItems = /* @__PURE__ */ new Map();
3588
+ for (const value of EASING_VALUES) {
3589
+ const li = document.createElement("li");
3590
+ li.className = "aicut-keyframe-panel__dropdown-item";
3591
+ li.setAttribute("role", "option");
3592
+ li.setAttribute("data-value", value);
3593
+ li.setAttribute("tabindex", "-1");
3594
+ li.addEventListener("click", (e) => {
3595
+ e.stopPropagation();
3596
+ this.selectEasing(value);
3597
+ });
3598
+ li.addEventListener("keydown", (e) => this.onMenuKeydown(e, value));
3599
+ this.easingItems.set(value, li);
3600
+ this.easingMenu.appendChild(li);
3601
+ }
3602
+ dd.append(this.easingTrigger, this.easingMenu);
3603
+ easingRow.append(this.easingLabelEl, dd);
3604
+ this.root.appendChild(easingRow);
3605
+ const actions = document.createElement("div");
3606
+ actions.className = "aicut-keyframe-panel__actions";
3607
+ this.resetBtn = document.createElement("button");
3608
+ this.resetBtn.type = "button";
3609
+ this.resetBtn.className = "aicut-keyframe-panel__reset";
3610
+ this.resetBtn.setAttribute("data-testid", "aicut-keyframe-reset");
3611
+ this.resetBtn.addEventListener("click", () => this.onReset());
3612
+ actions.appendChild(this.resetBtn);
3613
+ this.root.appendChild(actions);
3614
+ this.applyLocaleText();
3615
+ host.appendChild(this.root);
3616
+ }
3617
+ setLocale(locale) {
3618
+ this.locale = locale;
3619
+ this.applyLocaleText();
3620
+ this.lastSyncKey = "";
3621
+ this.render();
3622
+ }
3623
+ applyLocaleText() {
3624
+ this.titleLabel.textContent = this.locale.keyframePanelTitle;
3625
+ this.rowLabels.panX.textContent = this.locale.keyframePanelLabelX;
3626
+ this.rowLabels.panY.textContent = this.locale.keyframePanelLabelY;
3627
+ this.rowLabels.scale.textContent = this.locale.keyframePanelLabelScale;
3628
+ this.easingLabelEl.textContent = this.locale.keyframePanelLabelEasing;
3629
+ this.resetBtn.textContent = this.locale.keyframePanelReset;
3630
+ this.resetBtn.title = this.locale.keyframePanelResetTitle;
3631
+ for (const [value, li] of this.easingItems) {
3632
+ li.textContent = easingLabel(value, this.locale);
3633
+ }
3634
+ this.easingTriggerLabel.textContent = easingLabel(
3635
+ this.easingValue,
3636
+ this.locale
3637
+ );
3638
+ }
3639
+ destroy() {
3640
+ this.closeEasingMenu();
3641
+ this.root.remove();
3642
+ }
3643
+ render() {
3644
+ const enabled = this.editor.isKeyframesEnabled();
3645
+ const sel = this.editor.getSelectedKeyframe();
3646
+ if (!enabled || !sel) {
3647
+ this.root.style.display = "none";
3648
+ this.lastSyncKey = "";
3649
+ return;
3650
+ }
3651
+ const clip = this.findClip(sel.clipId);
3652
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3653
+ if (!clip || !anchorKf) {
3654
+ this.root.style.display = "none";
3655
+ this.lastSyncKey = "";
3656
+ return;
3657
+ }
3658
+ const time = anchorKf.time;
3659
+ const moment = (clip.keyframes ?? []).filter(
3660
+ (k) => Math.abs(k.time - time) < TIME_EPS_MS
3661
+ );
3662
+ const interp = getEffectiveTransform(clip, time);
3663
+ const valueOf = (prop) => {
3664
+ const m = moment.find((k) => k.prop === prop);
3665
+ if (m) return m.value;
3666
+ return interp[prop];
3667
+ };
3668
+ const v = {
3669
+ panX: valueOf("panX"),
3670
+ panY: valueOf("panY"),
3671
+ scale: valueOf("scale")
3672
+ };
3673
+ const sharedEasing = (() => {
3674
+ if (moment.length === 0) return "linear";
3675
+ const anchor = moment.find((k) => k.id === sel.keyframeId) ?? moment[0];
3676
+ return anchor.easing ?? "linear";
3677
+ })();
3678
+ const syncKey = `${clip.id}|${time}|${v.panX.toFixed(2)}|${v.panY.toFixed(2)}|${v.scale.toFixed(4)}|${moment.map((m) => m.prop).join(",")}|${sharedEasing}`;
3679
+ this.root.style.display = "flex";
3680
+ if (syncKey === this.lastSyncKey) return;
3681
+ this.lastSyncKey = syncKey;
3682
+ this.setIfBlur(this.inputs.panX, String(Math.round(v.panX)));
3683
+ this.setIfBlur(this.inputs.panY, String(Math.round(v.panY)));
3684
+ this.setIfBlur(this.inputs.scale, v.scale.toFixed(2));
3685
+ this.timeLabel.textContent = `${(time / 1e3).toFixed(2)}${this.locale.keyframePanelTimeSuffix}`;
3686
+ this.setEasingValue(sharedEasing);
3687
+ this.setEasingDisabled(moment.length === 0);
3688
+ for (const p of ["panX", "panY", "scale"]) {
3689
+ const animated = moment.some((k) => k.prop === p) || hasKeyframesForProp(clip, p);
3690
+ const pinned = moment.some((k) => k.prop === p);
3691
+ this.kfBadges[p].classList.toggle(
3692
+ "aicut-keyframe-panel__badge--on",
3693
+ pinned
3694
+ );
3695
+ this.kfBadges[p].title = pinned ? this.locale.keyframePanelBadgePinned : animated ? this.locale.keyframePanelBadgeAnimated : this.locale.keyframePanelBadgeStatic;
3696
+ }
3697
+ this.resetBtn.disabled = false;
3698
+ }
3699
+ // ---- internals ------------------------------------------------------
3700
+ makeRow(testId, prop, step) {
3701
+ const row = document.createElement("div");
3702
+ row.className = "aicut-keyframe-panel__row";
3703
+ const lab = document.createElement("label");
3704
+ const input = document.createElement("input");
3705
+ input.type = "number";
3706
+ input.step = String(step);
3707
+ input.setAttribute("data-testid", `aicut-${testId}`);
3708
+ input.addEventListener("blur", () => this.commit(prop, input.value));
3709
+ input.addEventListener("keydown", (e) => {
3710
+ if (e.key === "Enter") input.blur();
3711
+ });
3712
+ row.append(lab, input);
3713
+ this.root.appendChild(row);
3714
+ return { input, label: lab };
3715
+ }
3716
+ makeBadge(input) {
3717
+ const dot = document.createElement("span");
3718
+ dot.className = "aicut-keyframe-panel__badge";
3719
+ input.parentElement?.appendChild(dot);
3720
+ return dot;
3721
+ }
3722
+ commit(prop, raw) {
3723
+ const num = Number(raw);
3724
+ if (!Number.isFinite(num)) return;
3725
+ const sel = this.editor.getSelectedKeyframe();
3726
+ if (!sel) return;
3727
+ const clip = this.findClip(sel.clipId);
3728
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3729
+ if (!clip || !anchorKf) return;
3730
+ this.editor.addKeyframe(sel.clipId, prop, {
3731
+ time: anchorKf.time,
3732
+ value: num
3733
+ });
3734
+ if (anchorKf.prop !== prop) {
3735
+ const refreshedClip = this.findClip(sel.clipId);
3736
+ const created = (refreshedClip?.keyframes ?? []).find(
3737
+ (k) => k.prop === prop && Math.abs(k.time - anchorKf.time) < TIME_EPS_MS
3738
+ );
3739
+ if (created) {
3740
+ this.editor.setSelectedKeyframe({
3741
+ clipId: sel.clipId,
3742
+ keyframeId: created.id
3743
+ });
3744
+ }
3745
+ }
3746
+ }
3747
+ // ---- custom dropdown -------------------------------------------------
3748
+ setEasingValue(value) {
3749
+ if (this.easingValue === value) return;
3750
+ this.easingValue = value;
3751
+ this.easingTriggerLabel.textContent = easingLabel(value, this.locale);
3752
+ for (const [v, li] of this.easingItems) {
3753
+ li.classList.toggle(
3754
+ "aicut-keyframe-panel__dropdown-item--selected",
3755
+ v === value
3756
+ );
3757
+ li.setAttribute("aria-selected", v === value ? "true" : "false");
3758
+ }
3759
+ }
3760
+ setEasingDisabled(disabled) {
3761
+ if (this.easingDisabled === disabled) return;
3762
+ this.easingDisabled = disabled;
3763
+ this.easingTrigger.disabled = disabled;
3764
+ this.easingTrigger.classList.toggle(
3765
+ "aicut-keyframe-panel__dropdown-trigger--disabled",
3766
+ disabled
3767
+ );
3768
+ if (disabled && this.easingOpen) this.closeEasingMenu();
3769
+ }
3770
+ toggleEasingMenu() {
3771
+ if (this.easingOpen) this.closeEasingMenu();
3772
+ else this.openEasingMenu();
3773
+ }
3774
+ openEasingMenu() {
3775
+ if (this.easingOpen || this.easingDisabled) return;
3776
+ this.easingOpen = true;
3777
+ this.easingMenu.style.display = "";
3778
+ this.easingTrigger.setAttribute("aria-expanded", "true");
3779
+ this.easingTrigger.classList.add(
3780
+ "aicut-keyframe-panel__dropdown-trigger--open"
3781
+ );
3782
+ requestAnimationFrame(() => {
3783
+ if (!this.easingOpen) return;
3784
+ this.boundOutsideClick = (e) => {
3785
+ if (!this.easingMenu.contains(e.target) && !this.easingTrigger.contains(e.target)) {
3786
+ this.closeEasingMenu();
3787
+ }
3788
+ };
3789
+ this.boundDocKeydown = (e) => {
3790
+ if (e.key === "Escape") {
3791
+ e.stopPropagation();
3792
+ this.closeEasingMenu();
3793
+ this.easingTrigger.focus();
3794
+ } else if (e.key === "Tab") {
3795
+ this.closeEasingMenu();
3796
+ }
3797
+ };
3798
+ document.addEventListener("click", this.boundOutsideClick, true);
3799
+ document.addEventListener("keydown", this.boundDocKeydown);
3800
+ });
3801
+ }
3802
+ closeEasingMenu() {
3803
+ if (!this.easingOpen) return;
3804
+ this.easingOpen = false;
3805
+ this.easingMenu.style.display = "none";
3806
+ this.easingTrigger.setAttribute("aria-expanded", "false");
3807
+ this.easingTrigger.classList.remove(
3808
+ "aicut-keyframe-panel__dropdown-trigger--open"
3809
+ );
3810
+ if (this.boundOutsideClick) {
3811
+ document.removeEventListener("click", this.boundOutsideClick, true);
3812
+ this.boundOutsideClick = null;
3813
+ }
3814
+ if (this.boundDocKeydown) {
3815
+ document.removeEventListener("keydown", this.boundDocKeydown);
3816
+ this.boundDocKeydown = null;
3817
+ }
3818
+ }
3819
+ selectEasing(value) {
3820
+ this.closeEasingMenu();
3821
+ this.easingTrigger.focus();
3822
+ const sel = this.editor.getSelectedKeyframe();
3823
+ if (!sel) return;
3824
+ const clip = this.findClip(sel.clipId);
3825
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3826
+ if (!clip || !anchorKf) return;
3827
+ this.editor.setKeyframesEasingAtTime(sel.clipId, anchorKf.time, value);
3828
+ }
3829
+ onMenuKeydown(e, value) {
3830
+ if (e.key === "Enter" || e.key === " ") {
3831
+ e.preventDefault();
3832
+ this.selectEasing(value);
3833
+ return;
3834
+ }
3835
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
3836
+ e.preventDefault();
3837
+ const idx = EASING_VALUES.indexOf(value);
3838
+ const next = e.key === "ArrowDown" ? EASING_VALUES[(idx + 1) % EASING_VALUES.length] : EASING_VALUES[(idx - 1 + EASING_VALUES.length) % EASING_VALUES.length];
3839
+ this.easingItems.get(next)?.focus();
3840
+ }
3841
+ }
3842
+ onReset() {
3843
+ const sel = this.editor.getSelectedKeyframe();
3844
+ if (!sel) return;
3845
+ const clip = this.findClip(sel.clipId);
3846
+ const anchorKf = clip?.keyframes?.find((k) => k.id === sel.keyframeId);
3847
+ if (!clip || !anchorKf) return;
3848
+ this.editor.resetKeyframesAtTime(sel.clipId, anchorKf.time);
3849
+ }
3850
+ setIfBlur(input, value) {
3851
+ if (document.activeElement === input) return;
3852
+ if (input.value !== value) input.value = value;
3853
+ }
3854
+ findClip(clipId) {
3855
+ const project = this.editor.getProject();
3856
+ for (const t of project.tracks) {
3857
+ const c = t.clips.find((cl) => cl.id === clipId);
3858
+ if (c) return c;
3859
+ }
3860
+ return null;
3861
+ }
3862
+ };
3863
+
2595
3864
  // src/ui/icons.ts
2596
3865
  var wrap = (path) => `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">${path}</svg>`;
2597
3866
  var ICONS = {
@@ -2635,6 +3904,24 @@ var ICONS = {
2635
3904
  trash: wrap(
2636
3905
  `<g transform="translate(1 1)" fill="currentColor"><path d="M5 1.25h4v.9H13v1.05H1V2.15h4v-.9zM2.4 4.1h9.2l-.65 8.9c-.04.55-.5.96-1.05.96H4.1c-.55 0-1.01-.41-1.05-.96L2.4 4.1zm2.3 1.7l.35 7.1h1l-.35-7.1h-1zm2.65 0v7.1h1V5.8h-1zm2.3 0l-.35 7.1h1l.35-7.1h-1z"/></g>`
2637
3906
  ),
3907
+ /** "Skip to start" — vertical bar + left-pointing triangle. Sits to
3908
+ * the left of the keyframe diamond so the clip-edge nav cluster
3909
+ * reads as [|◀ ◇ ▶|] = "go to clip start / add kf / go to clip end". */
3910
+ seekClipStart: wrap(
3911
+ `<g transform="translate(2 3)" fill="currentColor"><rect x="0" y="0" width="1.6" height="10" rx="0.4"/><path d="M11 0.6c0-0.5-0.55-0.78-0.95-0.48l-6.5 4.4c-0.34 0.23-0.34 0.73 0 0.96l6.5 4.4c0.4 0.3 0.95 0.02 0.95-0.48z"/></g>`
3912
+ ),
3913
+ /** "Skip to end" — mirror of seekClipStart. */
3914
+ seekClipEnd: wrap(
3915
+ `<g transform="translate(1 3)" fill="currentColor"><path d="M0 0.6c0-0.5 0.55-0.78 0.95-0.48l6.5 4.4c0.34 0.23 0.34 0.73 0 0.96l-6.5 4.4c-0.4 0.3-0.95 0.02-0.95-0.48z"/><rect x="10.4" y="0" width="1.6" height="10" rx="0.4"/></g>`
3916
+ ),
3917
+ /** Outlined diamond (rotated square) — "add keyframe" affordance. */
3918
+ keyframeOutline: wrap(
3919
+ `<g transform="translate(8 8) rotate(45) translate(-4 -4)" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="0.5" y="0.5" width="7" height="7" rx="0.5"/></g>`
3920
+ ),
3921
+ /** Filled diamond — shown when a keyframe already exists at playhead. */
3922
+ keyframeFilled: wrap(
3923
+ `<g transform="translate(8 8) rotate(45) translate(-4 -4)" fill="currentColor"><rect x="0" y="0" width="8" height="8" rx="0.8"/></g>`
3924
+ ),
2638
3925
  /** Counter-clockwise circular arrow — "reset to initial layout". */
2639
3926
  reset: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`
2640
3927
  };
@@ -2661,6 +3948,9 @@ var Toolbar = class {
2661
3948
  splitBtn;
2662
3949
  trimLeftBtn;
2663
3950
  trimRightBtn;
3951
+ seekClipStartBtn;
3952
+ seekClipEndBtn;
3953
+ keyframeBtn;
2664
3954
  playBtn;
2665
3955
  playIcon;
2666
3956
  timeLabel;
@@ -2686,9 +3976,37 @@ var Toolbar = class {
2686
3976
  this.splitBtn = mkIconButton("split", locale.split, () => cb.onSplit(), "aicut-split");
2687
3977
  this.trimLeftBtn = mkIconButton("trimLeft", locale.trimLeft, () => cb.onTrimLeft(), "aicut-trim-left");
2688
3978
  this.trimRightBtn = mkIconButton("trimRight", locale.trimRight, () => cb.onTrimRight(), "aicut-trim-right");
2689
- const speedBtn = mkIconButton("speed", locale.speedComingSoon, () => void 0, "aicut-speed");
2690
- speedBtn.disabled = true;
2691
- left.append(this.undoBtn, this.redoBtn, this.splitBtn, this.trimLeftBtn, this.trimRightBtn, speedBtn);
3979
+ this.seekClipStartBtn = mkIconButton(
3980
+ "seekClipStart",
3981
+ locale.seekClipStart,
3982
+ () => cb.onSeekClipStart(),
3983
+ "aicut-seek-clip-start"
3984
+ );
3985
+ this.seekClipStartBtn.style.display = "none";
3986
+ this.keyframeBtn = mkIconButton(
3987
+ "keyframeOutline",
3988
+ locale.keyframeAdd,
3989
+ () => cb.onKeyframeToggle(),
3990
+ "aicut-keyframe"
3991
+ );
3992
+ this.keyframeBtn.style.display = "none";
3993
+ this.seekClipEndBtn = mkIconButton(
3994
+ "seekClipEnd",
3995
+ locale.seekClipEnd,
3996
+ () => cb.onSeekClipEnd(),
3997
+ "aicut-seek-clip-end"
3998
+ );
3999
+ this.seekClipEndBtn.style.display = "none";
4000
+ left.append(
4001
+ this.undoBtn,
4002
+ this.redoBtn,
4003
+ this.splitBtn,
4004
+ this.trimLeftBtn,
4005
+ this.trimRightBtn,
4006
+ this.seekClipStartBtn,
4007
+ this.keyframeBtn,
4008
+ this.seekClipEndBtn
4009
+ );
2692
4010
  const center = mkGroup("aicut-toolbar-center");
2693
4011
  this.timeLabel = mkSpan("aicut-time-current", "00:00", "aicut-time-current");
2694
4012
  this.playBtn = document.createElement("button");
@@ -2765,11 +4083,38 @@ var Toolbar = class {
2765
4083
  this.trimLeftBtn.disabled = !state.canTrim;
2766
4084
  this.trimRightBtn.disabled = !state.canTrim;
2767
4085
  }
4086
+ if (!this.lastState || this.lastState.clipEdgeNavEnabled !== state.clipEdgeNavEnabled) {
4087
+ const display = state.clipEdgeNavEnabled ? "" : "none";
4088
+ this.seekClipStartBtn.style.display = display;
4089
+ this.seekClipEndBtn.style.display = display;
4090
+ }
4091
+ if (!this.lastState || this.lastState.canSeekClipEdge !== state.canSeekClipEdge) {
4092
+ this.seekClipStartBtn.disabled = !state.canSeekClipEdge;
4093
+ this.seekClipEndBtn.disabled = !state.canSeekClipEdge;
4094
+ }
2768
4095
  if (!this.lastState || this.lastState.snap !== state.snap) {
2769
4096
  this.snapBtn.setAttribute("aria-pressed", state.snap ? "true" : "false");
2770
4097
  this.snapBtn.classList.toggle("aicut-toggle-on", state.snap);
2771
4098
  this.snapBtn.title = state.snap ? this.locale.snapOnTitle : this.locale.snapOffTitle;
2772
4099
  }
4100
+ if (!this.lastState || this.lastState.keyframesEnabled !== state.keyframesEnabled) {
4101
+ this.keyframeBtn.style.display = state.keyframesEnabled ? "" : "none";
4102
+ }
4103
+ if (state.keyframesEnabled) {
4104
+ if (!this.lastState || this.lastState.hasKeyframeAtPlayhead !== state.hasKeyframeAtPlayhead) {
4105
+ this.keyframeBtn.innerHTML = state.hasKeyframeAtPlayhead ? ICONS.keyframeFilled : ICONS.keyframeOutline;
4106
+ const title = state.hasKeyframeAtPlayhead ? this.locale.keyframeRemove : this.locale.keyframeAdd;
4107
+ this.keyframeBtn.title = title;
4108
+ this.keyframeBtn.setAttribute("aria-label", title);
4109
+ this.keyframeBtn.setAttribute(
4110
+ "data-state",
4111
+ state.hasKeyframeAtPlayhead ? "on" : "off"
4112
+ );
4113
+ }
4114
+ if (!this.lastState || this.lastState.canKeyframe !== state.canKeyframe) {
4115
+ this.keyframeBtn.disabled = !state.canKeyframe;
4116
+ }
4117
+ }
2773
4118
  if (!this.lastState || this.lastState.pxPerSec !== state.pxPerSec) {
2774
4119
  const ratio = scaleToSlider(state.pxPerSec);
2775
4120
  const nextVal = String(Math.round(ratio * 100));
@@ -2802,12 +4147,20 @@ var Toolbar = class {
2802
4147
  applyTitle(this.splitBtn, locale.split);
2803
4148
  applyTitle(this.trimLeftBtn, locale.trimLeft);
2804
4149
  applyTitle(this.trimRightBtn, locale.trimRight);
4150
+ applyTitle(this.seekClipStartBtn, locale.seekClipStart);
4151
+ applyTitle(this.seekClipEndBtn, locale.seekClipEnd);
2805
4152
  applyTitle(this.playBtn, locale.playPause);
2806
4153
  applyTitle(this.fullscreenBtn, locale.fullscreen);
2807
4154
  applyTitle(this.snapBtn, locale.snap);
2808
4155
  applyTitle(this.zoomOutBtn, locale.zoomOut);
2809
4156
  applyTitle(this.zoomInBtn, locale.zoomIn);
2810
4157
  applyTitle(this.resetBtn, locale.reset);
4158
+ if (this.keyframeBtn) {
4159
+ const hasKf = this.lastState?.hasKeyframeAtPlayhead === true;
4160
+ const t = hasKf ? locale.keyframeRemove : locale.keyframeAdd;
4161
+ this.keyframeBtn.title = t;
4162
+ this.keyframeBtn.setAttribute("aria-label", t);
4163
+ }
2811
4164
  if (this.lastState) {
2812
4165
  this.snapBtn.title = this.lastState.snap ? locale.snapOnTitle : locale.snapOffTitle;
2813
4166
  }
@@ -2867,6 +4220,8 @@ var EditorUI = class {
2867
4220
  toolbar;
2868
4221
  timelineHost;
2869
4222
  timeline;
4223
+ keyframePanel;
4224
+ keyframeOverlay;
2870
4225
  fullscreen = false;
2871
4226
  onDocKeydown = null;
2872
4227
  constructor(root, editor, cb) {
@@ -2913,10 +4268,14 @@ var EditorUI = class {
2913
4268
  snap: editor.getSnap(),
2914
4269
  autoFit: true,
2915
4270
  locale,
4271
+ keyframesEnabled: editor.isKeyframesEnabled(),
4272
+ selectedKeyframe: editor.getSelectedKeyframe(),
2916
4273
  onSeek: cb.onSeek,
2917
4274
  onSelectClip: cb.onSelectClip,
2918
4275
  onMoveClip: cb.onMoveClip,
2919
4276
  onResizeClip: cb.onResizeClip,
4277
+ onSelectKeyframe: cb.onSelectKeyframe,
4278
+ onMoveKeyframe: cb.onMoveKeyframe,
2920
4279
  onScaleChange: cb.onScaleChange,
2921
4280
  onDeleteTrack: (trackId) => editor.removeTrack(trackId),
2922
4281
  // Mirror the editor's smart routing into the drag preview so
@@ -2941,6 +4300,8 @@ var EditorUI = class {
2941
4300
  };
2942
4301
  }
2943
4302
  });
4303
+ this.keyframePanel = new KeyframePanel(this.preview, editor, locale);
4304
+ this.keyframeOverlay = new KeyframeOverlay(this.preview, editor);
2944
4305
  this.attachKeyboard(cb);
2945
4306
  }
2946
4307
  // ---- fullscreen -----------------------------------------------------
@@ -2990,6 +4351,13 @@ var EditorUI = class {
2990
4351
  const selectedClipId = this.editor.getSelection();
2991
4352
  const pxPerSec = this.editor.getScale();
2992
4353
  const snap = this.editor.getSnap();
4354
+ const kfEnabled = this.editor.isKeyframesEnabled();
4355
+ const kfState = this.computeKeyframeToolbarState(
4356
+ project,
4357
+ selectedClipId,
4358
+ time,
4359
+ kfEnabled
4360
+ );
2993
4361
  this.toolbar.render({
2994
4362
  playing: this.editor.isPlaying(),
2995
4363
  time,
@@ -2998,18 +4366,34 @@ var EditorUI = class {
2998
4366
  canRedo: this.editor.canRedo(),
2999
4367
  canSplit: this.canSplitAt(time),
3000
4368
  canTrim: this.canTrimAt(time, selectedClipId),
4369
+ canSeekClipEdge: selectedClipId != null,
4370
+ clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
3001
4371
  snap,
3002
- pxPerSec
4372
+ pxPerSec,
4373
+ ...kfState
3003
4374
  });
3004
4375
  this.timeline.setProject(project);
3005
4376
  this.timeline.setTime(time);
3006
4377
  this.timeline.setScale(pxPerSec);
3007
4378
  this.timeline.setSelection(selectedClipId);
3008
4379
  this.timeline.setSnap(snap);
4380
+ this.timeline.setKeyframeState({
4381
+ enabled: this.editor.isKeyframesEnabled(),
4382
+ selected: this.editor.getSelectedKeyframe()
4383
+ });
4384
+ this.keyframePanel.render();
3009
4385
  }
3010
4386
  /** Playback-fast path: nudge playhead + toolbar time label only. */
3011
4387
  onTimeTick(timeMs) {
3012
4388
  this.timeline.setTime(timeMs);
4389
+ const selectedClipId = this.editor.getSelection();
4390
+ const kfEnabled = this.editor.isKeyframesEnabled();
4391
+ const kfState = this.computeKeyframeToolbarState(
4392
+ this.editor.getProject(),
4393
+ selectedClipId,
4394
+ timeMs,
4395
+ kfEnabled
4396
+ );
3013
4397
  this.toolbar.render({
3014
4398
  playing: this.editor.isPlaying(),
3015
4399
  time: timeMs,
@@ -3017,9 +4401,12 @@ var EditorUI = class {
3017
4401
  canUndo: this.editor.canUndo(),
3018
4402
  canRedo: this.editor.canRedo(),
3019
4403
  canSplit: this.canSplitAt(timeMs),
3020
- canTrim: this.canTrimAt(timeMs, this.editor.getSelection()),
4404
+ canTrim: this.canTrimAt(timeMs, selectedClipId),
4405
+ canSeekClipEdge: selectedClipId != null,
4406
+ clipEdgeNavEnabled: this.editor.isClipEdgeNavEnabled(),
3021
4407
  snap: this.editor.getSnap(),
3022
- pxPerSec: this.editor.getScale()
4408
+ pxPerSec: this.editor.getScale(),
4409
+ ...kfState
3023
4410
  });
3024
4411
  }
3025
4412
  /** Explicit re-fit — Editor calls this when a brand-new project replaces the current one. */
@@ -3031,6 +4418,7 @@ var EditorUI = class {
3031
4418
  this.fullscreenExitBtn.title = locale.exitFullscreenTitle;
3032
4419
  this.fullscreenExitBtn.textContent = locale.exitFullscreen;
3033
4420
  this.timeline.setLocale(locale);
4421
+ this.keyframePanel.setLocale(locale);
3034
4422
  this.render();
3035
4423
  }
3036
4424
  destroy() {
@@ -3040,10 +4428,56 @@ var EditorUI = class {
3040
4428
  }
3041
4429
  this.toolbar.destroy();
3042
4430
  this.timeline.destroy();
4431
+ this.keyframePanel.destroy();
4432
+ this.keyframeOverlay.destroy();
3043
4433
  this.root.innerHTML = "";
3044
4434
  this.root.classList.remove("aicut-root", "aicut-fullscreen");
3045
4435
  }
3046
4436
  // ---- helpers --------------------------------------------------------
4437
+ /** Walk the selected clip + playhead state to figure out (a) whether
4438
+ * the keyframe button should be enabled, and (b) whether a keyframe
4439
+ * already exists at the playhead's clip-local time (so the button
4440
+ * swaps to "remove" mode). */
4441
+ computeKeyframeToolbarState(project, selectedClipId, time, keyframesEnabled) {
4442
+ if (!keyframesEnabled || !selectedClipId) {
4443
+ return {
4444
+ canKeyframe: false,
4445
+ hasKeyframeAtPlayhead: false,
4446
+ keyframesEnabled
4447
+ };
4448
+ }
4449
+ let clip = null;
4450
+ for (const t of project.tracks) {
4451
+ const c = t.clips.find((cl) => cl.id === selectedClipId);
4452
+ if (c) {
4453
+ clip = c;
4454
+ break;
4455
+ }
4456
+ }
4457
+ if (!clip) {
4458
+ return {
4459
+ canKeyframe: false,
4460
+ hasKeyframeAtPlayhead: false,
4461
+ keyframesEnabled
4462
+ };
4463
+ }
4464
+ const localMs = time - clip.start;
4465
+ const duration = clipDuration(clip);
4466
+ if (localMs < 0 || localMs > duration) {
4467
+ return {
4468
+ canKeyframe: false,
4469
+ hasKeyframeAtPlayhead: false,
4470
+ keyframesEnabled
4471
+ };
4472
+ }
4473
+ const roundedLocal = Math.round(localMs);
4474
+ const hasKf = clip.keyframes?.some((k) => k.time === roundedLocal) ?? false;
4475
+ return {
4476
+ canKeyframe: true,
4477
+ hasKeyframeAtPlayhead: hasKf,
4478
+ keyframesEnabled
4479
+ };
4480
+ }
3047
4481
  canSplitAt(timeMs) {
3048
4482
  const project = this.editor.getProject();
3049
4483
  for (const t of project.tracks) {
@@ -3085,6 +4519,22 @@ var EditorUI = class {
3085
4519
  } else if (e.code === "KeyW") {
3086
4520
  e.preventDefault();
3087
4521
  cb.onTrimRight();
4522
+ } else if (e.code === "KeyI" && this.editor.isClipEdgeNavEnabled()) {
4523
+ e.preventDefault();
4524
+ cb.onSeekClipStart();
4525
+ } else if (e.code === "KeyO" && this.editor.isClipEdgeNavEnabled()) {
4526
+ e.preventDefault();
4527
+ cb.onSeekClipEnd();
4528
+ } else if (e.code === "ArrowLeft" || e.code === "ArrowRight") {
4529
+ e.preventDefault();
4530
+ const project = this.editor.getProject();
4531
+ const step = e.shiftKey ? bigFrameStepMs(project) : frameStepMs(project);
4532
+ const dir = e.code === "ArrowLeft" ? -1 : 1;
4533
+ const next = Math.max(
4534
+ 0,
4535
+ Math.min(this.editor.getDuration(), this.editor.getTime() + dir * step)
4536
+ );
4537
+ cb.onSeek(next);
3088
4538
  } else if ((e.metaKey || e.ctrlKey) && e.code === "KeyZ") {
3089
4539
  e.preventDefault();
3090
4540
  if (e.shiftKey) cb.onRedo();
@@ -3113,6 +4563,13 @@ var Editor = class _Editor {
3113
4563
  bus = new EventBus();
3114
4564
  history = new HistoryStack();
3115
4565
  selectedClipId = null;
4566
+ selectedKeyframe = null;
4567
+ keyframesEnabled;
4568
+ clipEdgeNavEnabled;
4569
+ /** Drag-session bookkeeping for ripple-merge undo. See
4570
+ * beginInteraction / endInteraction docs on EditorApi. */
4571
+ interactionDepth = 0;
4572
+ interactionStartSnapshot = null;
3116
4573
  pxPerSec;
3117
4574
  snap;
3118
4575
  locale;
@@ -3123,6 +4580,8 @@ var Editor = class _Editor {
3123
4580
  this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
3124
4581
  this.snap = opts.initialSnap !== false;
3125
4582
  this.locale = mergeLocale(opts.locale);
4583
+ this.keyframesEnabled = opts.keyframes?.enabled === true;
4584
+ this.clipEdgeNavEnabled = opts.clipEdgeNav?.enabled === true;
3126
4585
  if (opts.trackHeight != null || opts.rulerHeight != null) {
3127
4586
  setTimelineMetrics({
3128
4587
  ...opts.trackHeight != null ? { trackHeight: opts.trackHeight } : {},
@@ -3151,7 +4610,12 @@ var Editor = class _Editor {
3151
4610
  onSelectClip: (id) => this.setSelection(id),
3152
4611
  onDeleteClip: (id) => this.removeClip(id),
3153
4612
  onMoveClip: (id, opts2) => this.moveClip(id, opts2),
3154
- onResizeClip: (id, edits) => this.resizeClip(id, edits)
4613
+ onResizeClip: (id, edits) => this.resizeClip(id, edits),
4614
+ onSelectKeyframe: (target) => this.setSelectedKeyframe(target),
4615
+ onMoveKeyframe: (clipId, keyframeId, timeMs) => this.moveKeyframe(clipId, keyframeId, timeMs),
4616
+ onKeyframeToggle: () => this.toggleKeyframeAtPlayhead(),
4617
+ onSeekClipStart: () => this.seekToSelectedClipEdge("start"),
4618
+ onSeekClipEnd: () => this.seekToSelectedClipEdge("end")
3155
4619
  });
3156
4620
  const engineFactory = opts.playbackEngine ?? ((o) => new HtmlVideoEngine(o));
3157
4621
  this.engine = engineFactory({
@@ -3575,6 +5039,9 @@ var Editor = class _Editor {
3575
5039
  for (const c of t.clips) {
3576
5040
  if (c.id === ignoreClipId) continue;
3577
5041
  targets.push(c.start, clipEnd(c));
5042
+ if (c.keyframes) {
5043
+ for (const kf of c.keyframes) targets.push(c.start + kf.time);
5044
+ }
3578
5045
  }
3579
5046
  }
3580
5047
  let best = timeMs;
@@ -3596,6 +5063,304 @@ var Editor = class _Editor {
3596
5063
  if (clipId === this.selectedClipId) return;
3597
5064
  this.selectedClipId = clipId;
3598
5065
  this.bus.emit("selectionChange", { clipId });
5066
+ if (this.selectedKeyframe && this.selectedKeyframe.clipId !== clipId) {
5067
+ this.selectedKeyframe = null;
5068
+ this.bus.emit("keyframeSelectionChange", { target: null });
5069
+ }
5070
+ this.ui.render();
5071
+ }
5072
+ // ---- keyframes ------------------------------------------------------
5073
+ isKeyframesEnabled() {
5074
+ return this.keyframesEnabled;
5075
+ }
5076
+ /**
5077
+ * Screen-space CSS-pixel rect of the actively painted frame
5078
+ * (post-transform), relative to the editor's preview element.
5079
+ * Null when no clip is active, the engine doesn't expose
5080
+ * `getFrameRect`, or the rect isn't computed yet. Used by the
5081
+ * library's keyframe-editing overlay.
5082
+ */
5083
+ getActiveFrameRect() {
5084
+ return this.engine.getFrameRect?.() ?? null;
5085
+ }
5086
+ /**
5087
+ * Screen-space CSS-pixel rect of the OUTPUT FRAME (the fixed
5088
+ * stage that clips the rendered video). Different from
5089
+ * `getActiveFrameRect` which includes the keyframe transform —
5090
+ * this one stays put as the user drags / scales the content.
5091
+ * Used by the overlay to anchor the dashed border + drag body.
5092
+ */
5093
+ getActiveOutputFrameRect() {
5094
+ return this.engine.getOutputFrameRect?.() ?? null;
5095
+ }
5096
+ setKeyframesEnabled(enabled) {
5097
+ if (enabled === this.keyframesEnabled) return;
5098
+ this.keyframesEnabled = enabled;
5099
+ if (!enabled && this.selectedKeyframe) {
5100
+ this.selectedKeyframe = null;
5101
+ this.bus.emit("keyframeSelectionChange", { target: null });
5102
+ }
5103
+ this.bus.emit("keyframesEnabledChange", { enabled });
5104
+ this.ui.render();
5105
+ }
5106
+ isClipEdgeNavEnabled() {
5107
+ return this.clipEdgeNavEnabled;
5108
+ }
5109
+ setClipEdgeNavEnabled(enabled) {
5110
+ if (enabled === this.clipEdgeNavEnabled) return;
5111
+ this.clipEdgeNavEnabled = enabled;
5112
+ this.bus.emit("clipEdgeNavEnabledChange", { enabled });
5113
+ this.ui.render();
5114
+ }
5115
+ addKeyframe(clipId, prop, opts = {}) {
5116
+ const trk = findTrackOfClip(this.project, clipId);
5117
+ const cl = trk?.clips.find((c) => c.id === clipId);
5118
+ if (!trk || !cl) return null;
5119
+ const duration = clipDuration(cl);
5120
+ const playheadLocal = this.engine.getTime() - cl.start;
5121
+ const rawTime = opts.time ?? playheadLocal;
5122
+ const time = Math.max(0, Math.min(duration, Math.round(rawTime)));
5123
+ const value = opts.value ?? interpolateProp(cl, prop, time);
5124
+ this.pushHistory();
5125
+ cl.keyframes = upsertKeyframe(
5126
+ cl.keyframes,
5127
+ prop,
5128
+ time,
5129
+ value,
5130
+ () => createId("kf")
5131
+ );
5132
+ cl.keyframes.sort((a, b) => {
5133
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5134
+ return a.time - b.time;
5135
+ });
5136
+ this.afterMutation();
5137
+ const created = cl.keyframes.find(
5138
+ (k) => k.prop === prop && Math.abs(k.time - time) < 16
5139
+ );
5140
+ return created?.id ?? null;
5141
+ }
5142
+ removeKeyframe(clipId, keyframeId) {
5143
+ const trk = findTrackOfClip(this.project, clipId);
5144
+ const cl = trk?.clips.find((c) => c.id === clipId);
5145
+ if (!trk || !cl || !cl.keyframes) return false;
5146
+ const idx = cl.keyframes.findIndex((k) => k.id === keyframeId);
5147
+ if (idx < 0) return false;
5148
+ this.pushHistory();
5149
+ const next = cl.keyframes.slice();
5150
+ next.splice(idx, 1);
5151
+ cl.keyframes = next.length > 0 ? next : void 0;
5152
+ if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId && this.selectedKeyframe.keyframeId === keyframeId) {
5153
+ this.selectedKeyframe = null;
5154
+ this.bus.emit("keyframeSelectionChange", { target: null });
5155
+ }
5156
+ this.afterMutation();
5157
+ return true;
5158
+ }
5159
+ moveKeyframe(clipId, keyframeId, timeMs) {
5160
+ const trk = findTrackOfClip(this.project, clipId);
5161
+ const cl = trk?.clips.find((c) => c.id === clipId);
5162
+ const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
5163
+ if (!trk || !cl || !kf || !cl.keyframes) return false;
5164
+ const duration = clipDuration(cl);
5165
+ const clamped = Math.max(0, Math.min(duration, Math.round(timeMs)));
5166
+ if (clamped === kf.time) return false;
5167
+ if (cl.keyframes.some(
5168
+ (k) => k.id !== keyframeId && k.prop === kf.prop && k.time === clamped
5169
+ )) {
5170
+ return false;
5171
+ }
5172
+ this.pushHistory();
5173
+ kf.time = clamped;
5174
+ cl.keyframes.sort((a, b) => {
5175
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5176
+ return a.time - b.time;
5177
+ });
5178
+ this.afterMutation();
5179
+ return true;
5180
+ }
5181
+ setKeyframeValue(clipId, keyframeId, value) {
5182
+ const trk = findTrackOfClip(this.project, clipId);
5183
+ const cl = trk?.clips.find((c) => c.id === clipId);
5184
+ const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
5185
+ if (!trk || !cl || !kf) return false;
5186
+ if (Math.abs(kf.value - value) < 1e-9) return false;
5187
+ this.pushHistory();
5188
+ kf.value = value;
5189
+ this.afterMutation();
5190
+ return true;
5191
+ }
5192
+ setKeyframeEasing(clipId, keyframeId, easing) {
5193
+ const trk = findTrackOfClip(this.project, clipId);
5194
+ const cl = trk?.clips.find((c) => c.id === clipId);
5195
+ const kf = cl?.keyframes?.find((k) => k.id === keyframeId);
5196
+ if (!trk || !cl || !kf) return false;
5197
+ const current = kf.easing ?? "linear";
5198
+ if (current === easing) return false;
5199
+ this.pushHistory();
5200
+ if (easing === "linear") {
5201
+ delete kf.easing;
5202
+ } else {
5203
+ kf.easing = easing;
5204
+ }
5205
+ this.afterMutation();
5206
+ return true;
5207
+ }
5208
+ setKeyframesEasingAtTime(clipId, timeMs, easing) {
5209
+ const trk = findTrackOfClip(this.project, clipId);
5210
+ const cl = trk?.clips.find((c) => c.id === clipId);
5211
+ if (!trk || !cl || !cl.keyframes) return false;
5212
+ const t = Math.round(timeMs);
5213
+ const matches = cl.keyframes.filter((k) => Math.abs(k.time - t) < 16);
5214
+ if (matches.length === 0) return false;
5215
+ const anyChange = matches.some((k) => (k.easing ?? "linear") !== easing);
5216
+ if (!anyChange) return false;
5217
+ this.pushHistory();
5218
+ for (const kf of matches) {
5219
+ if (easing === "linear") delete kf.easing;
5220
+ else kf.easing = easing;
5221
+ }
5222
+ this.afterMutation();
5223
+ return true;
5224
+ }
5225
+ setValueAtPlayhead(clipId, prop, value) {
5226
+ const trk = findTrackOfClip(this.project, clipId);
5227
+ const cl = trk?.clips.find((c) => c.id === clipId);
5228
+ if (!trk || !cl) return false;
5229
+ const duration = clipDuration(cl);
5230
+ const playheadLocal = this.engine.getTime() - cl.start;
5231
+ const time = Math.max(0, Math.min(duration, Math.round(playheadLocal)));
5232
+ const hasKf = cl.keyframes?.some((k) => k.prop === prop) ?? false;
5233
+ if (hasKf) {
5234
+ this.pushHistory();
5235
+ cl.keyframes = upsertKeyframe(
5236
+ cl.keyframes,
5237
+ prop,
5238
+ time,
5239
+ value,
5240
+ () => createId("kf")
5241
+ );
5242
+ cl.keyframes.sort((a, b) => {
5243
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5244
+ return a.time - b.time;
5245
+ });
5246
+ this.afterMutation();
5247
+ return true;
5248
+ }
5249
+ if ((cl[prop] ?? (prop === "scale" ? 1 : 0)) === value) return false;
5250
+ this.pushHistory();
5251
+ cl[prop] = value;
5252
+ this.afterMutation();
5253
+ return true;
5254
+ }
5255
+ getSelectedKeyframe() {
5256
+ return this.selectedKeyframe;
5257
+ }
5258
+ resetClipTransform(clipId) {
5259
+ const trk = findTrackOfClip(this.project, clipId);
5260
+ const cl = trk?.clips.find((c) => c.id === clipId);
5261
+ if (!trk || !cl) return false;
5262
+ const dirty = cl.keyframes && cl.keyframes.length > 0 || cl.panX !== void 0 || cl.panY !== void 0 || cl.scale !== void 0;
5263
+ if (!dirty) return false;
5264
+ this.pushHistory();
5265
+ delete cl.panX;
5266
+ delete cl.panY;
5267
+ delete cl.scale;
5268
+ cl.keyframes = void 0;
5269
+ if (this.selectedKeyframe && this.selectedKeyframe.clipId === clipId) {
5270
+ this.selectedKeyframe = null;
5271
+ this.bus.emit("keyframeSelectionChange", { target: null });
5272
+ }
5273
+ this.afterMutation();
5274
+ return true;
5275
+ }
5276
+ resetKeyframesAtTime(clipId, timeMs) {
5277
+ const trk = findTrackOfClip(this.project, clipId);
5278
+ const cl = trk?.clips.find((c) => c.id === clipId);
5279
+ if (!trk || !cl) return false;
5280
+ const duration = clipDuration(cl);
5281
+ const t = Math.max(0, Math.min(duration, Math.round(timeMs)));
5282
+ this.pushHistory();
5283
+ let kfs = cl.keyframes ?? [];
5284
+ kfs = upsertKeyframe(kfs, "panX", t, 0, () => createId("kf"));
5285
+ kfs = upsertKeyframe(kfs, "panY", t, 0, () => createId("kf"));
5286
+ kfs = upsertKeyframe(kfs, "scale", t, 1, () => createId("kf"));
5287
+ kfs.sort((a, b) => {
5288
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5289
+ return a.time - b.time;
5290
+ });
5291
+ cl.keyframes = kfs;
5292
+ this.afterMutation();
5293
+ return true;
5294
+ }
5295
+ seekToClipEdge(clipId, edge) {
5296
+ const trk = findTrackOfClip(this.project, clipId);
5297
+ const cl = trk?.clips.find((c) => c.id === clipId);
5298
+ if (!trk || !cl) return false;
5299
+ const target = edge === "start" ? cl.start : Math.max(cl.start, clipEnd(cl) - 1);
5300
+ if (this.engine.getTime() === target) return false;
5301
+ this.seek(target);
5302
+ return true;
5303
+ }
5304
+ seekToSelectedClipEdge(edge) {
5305
+ if (!this.selectedClipId) return false;
5306
+ return this.seekToClipEdge(this.selectedClipId, edge);
5307
+ }
5308
+ toggleKeyframeAtPlayhead() {
5309
+ const clipId = this.selectedClipId;
5310
+ if (!clipId) return false;
5311
+ const trk = findTrackOfClip(this.project, clipId);
5312
+ const cl = trk?.clips.find((c) => c.id === clipId);
5313
+ if (!trk || !cl) return false;
5314
+ const localMs = this.engine.getTime() - cl.start;
5315
+ const duration = clipDuration(cl);
5316
+ if (localMs < 0 || localMs > duration) return false;
5317
+ const t = Math.round(localMs);
5318
+ const existing = cl.keyframes?.filter((k) => Math.abs(k.time - t) < 16);
5319
+ if (existing && existing.length > 0) {
5320
+ this.pushHistory();
5321
+ const ids = new Set(existing.map((k) => k.id));
5322
+ const next = cl.keyframes.filter((k) => !ids.has(k.id));
5323
+ cl.keyframes = next.length > 0 ? next : void 0;
5324
+ if (this.selectedKeyframe && ids.has(this.selectedKeyframe.keyframeId)) {
5325
+ this.selectedKeyframe = null;
5326
+ this.bus.emit("keyframeSelectionChange", { target: null });
5327
+ }
5328
+ this.afterMutation();
5329
+ return true;
5330
+ }
5331
+ const current = getEffectiveTransform(cl, t);
5332
+ this.pushHistory();
5333
+ let kfs = cl.keyframes ?? [];
5334
+ kfs = upsertKeyframe(kfs, "panX", t, current.panX, () => createId("kf"));
5335
+ kfs = upsertKeyframe(kfs, "panY", t, current.panY, () => createId("kf"));
5336
+ kfs = upsertKeyframe(kfs, "scale", t, current.scale, () => createId("kf"));
5337
+ kfs.sort((a, b) => {
5338
+ if (a.prop !== b.prop) return a.prop.localeCompare(b.prop);
5339
+ return a.time - b.time;
5340
+ });
5341
+ cl.keyframes = kfs;
5342
+ const anchor = kfs.find(
5343
+ (k) => k.prop === "panX" && Math.abs(k.time - t) < 16
5344
+ );
5345
+ if (anchor) {
5346
+ this.selectedKeyframe = { clipId, keyframeId: anchor.id };
5347
+ this.bus.emit("keyframeSelectionChange", {
5348
+ target: this.selectedKeyframe
5349
+ });
5350
+ }
5351
+ this.afterMutation();
5352
+ return true;
5353
+ }
5354
+ setSelectedKeyframe(target) {
5355
+ if (target?.clipId === this.selectedKeyframe?.clipId && target?.keyframeId === this.selectedKeyframe?.keyframeId) {
5356
+ return;
5357
+ }
5358
+ this.selectedKeyframe = target;
5359
+ if (target && target.clipId !== this.selectedClipId) {
5360
+ this.selectedClipId = target.clipId;
5361
+ this.bus.emit("selectionChange", { clipId: target.clipId });
5362
+ }
5363
+ this.bus.emit("keyframeSelectionChange", { target });
3599
5364
  this.ui.render();
3600
5365
  }
3601
5366
  // ---- history --------------------------------------------------------
@@ -3609,6 +5374,7 @@ var Editor = class _Editor {
3609
5374
  const prev = this.history.undo(this.project);
3610
5375
  if (!prev) return false;
3611
5376
  this.project = prev;
5377
+ this.reconcileSelectionsWithProject();
3612
5378
  this.engine.setProject(this.project);
3613
5379
  this.bus.emit("change", { project: this.getProject() });
3614
5380
  this.emitHistory();
@@ -3619,12 +5385,57 @@ var Editor = class _Editor {
3619
5385
  const next = this.history.redo(this.project);
3620
5386
  if (!next) return false;
3621
5387
  this.project = next;
5388
+ this.reconcileSelectionsWithProject();
3622
5389
  this.engine.setProject(this.project);
3623
5390
  this.bus.emit("change", { project: this.getProject() });
3624
5391
  this.emitHistory();
3625
5392
  this.ui.render();
3626
5393
  return true;
3627
5394
  }
5395
+ beginInteraction() {
5396
+ this.interactionDepth += 1;
5397
+ }
5398
+ endInteraction() {
5399
+ if (this.interactionDepth === 0) return;
5400
+ this.interactionDepth -= 1;
5401
+ if (this.interactionDepth > 0) return;
5402
+ const snapshot = this.interactionStartSnapshot;
5403
+ this.interactionStartSnapshot = null;
5404
+ if (snapshot == null) return;
5405
+ const now = JSON.stringify(this.project);
5406
+ if (now === snapshot) return;
5407
+ this.history.push(JSON.parse(snapshot));
5408
+ this.emitHistory();
5409
+ }
5410
+ /**
5411
+ * Selections (clipId + selectedKeyframe) live OUTSIDE the project
5412
+ * snapshot, so undo / redo can leave them pointing at ids that no
5413
+ * longer exist. Defend against dangling refs by clearing anything
5414
+ * the restored project doesn't actually contain — and emit the
5415
+ * paired change events so panels / overlays hide cleanly instead
5416
+ * of holding zombie references.
5417
+ */
5418
+ reconcileSelectionsWithProject() {
5419
+ if (this.selectedKeyframe) {
5420
+ const trk = findTrackOfClip(this.project, this.selectedKeyframe.clipId);
5421
+ const cl = trk?.clips.find((c) => c.id === this.selectedKeyframe.clipId);
5422
+ const kf = cl?.keyframes?.find(
5423
+ (k) => k.id === this.selectedKeyframe.keyframeId
5424
+ );
5425
+ if (!kf) {
5426
+ this.selectedKeyframe = null;
5427
+ this.bus.emit("keyframeSelectionChange", { target: null });
5428
+ }
5429
+ }
5430
+ if (this.selectedClipId) {
5431
+ const trk = findTrackOfClip(this.project, this.selectedClipId);
5432
+ const cl = trk?.clips.find((c) => c.id === this.selectedClipId);
5433
+ if (!cl) {
5434
+ this.selectedClipId = null;
5435
+ this.bus.emit("selectionChange", { clipId: null });
5436
+ }
5437
+ }
5438
+ }
3628
5439
  // ---- events ---------------------------------------------------------
3629
5440
  on(event, handler) {
3630
5441
  return this.bus.on(event, handler);
@@ -3661,6 +5472,12 @@ var Editor = class _Editor {
3661
5472
  return null;
3662
5473
  }
3663
5474
  pushHistory() {
5475
+ if (this.interactionDepth > 0) {
5476
+ if (this.interactionStartSnapshot == null) {
5477
+ this.interactionStartSnapshot = JSON.stringify(this.project);
5478
+ }
5479
+ return;
5480
+ }
3664
5481
  this.history.push(this.project);
3665
5482
  this.emitHistory();
3666
5483
  }
@@ -3706,12 +5523,16 @@ exports.CanvasCompositorEngine = CanvasCompositorEngine;
3706
5523
  exports.Editor = Editor;
3707
5524
  exports.HEADER_WIDTH = HEADER_WIDTH;
3708
5525
  exports.HtmlVideoEngine = HtmlVideoEngine;
5526
+ exports.IDENTITY_TRANSFORM = IDENTITY_TRANSFORM;
3709
5527
  exports.Timeline = Timeline;
3710
5528
  exports.canvasCompositorEngineFactory = canvasCompositorEngineFactory;
3711
5529
  exports.createEmptyProject = createEmptyProject;
3712
5530
  exports.createId = createId;
3713
5531
  exports.formatLabel = formatLabel;
5532
+ exports.getEffectiveTransform = getEffectiveTransform;
5533
+ exports.getTransformAtTimelineTime = getTransformAtTimelineTime;
3714
5534
  exports.htmlVideoEngineFactory = htmlVideoEngineFactory;
5535
+ exports.isIdentityTransform = isIdentityTransform;
3715
5536
  exports.localeEn = localeEn;
3716
5537
  exports.localeZh = localeZh;
3717
5538
  exports.mergeLocale = mergeLocale;