@aicut/core 0.4.3 → 0.5.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
@@ -144,18 +144,26 @@ function projectDuration(project) {
144
144
  }
145
145
 
146
146
  // src/timeline/layout.ts
147
- var TRACK_HEIGHT = 56;
148
- var RULER_HEIGHT = 24;
147
+ exports.TRACK_HEIGHT = 56;
148
+ exports.RULER_HEIGHT = 24;
149
149
  var HEADER_WIDTH = 96;
150
150
  var HANDLE_PX = 8;
151
151
  var CLIP_INSET = 6;
152
152
  var SCALE_MIN = 10;
153
153
  var SCALE_MAX = 400;
154
+ function setTimelineMetrics(opts) {
155
+ if (opts.trackHeight != null && opts.trackHeight > 0) {
156
+ exports.TRACK_HEIGHT = Math.round(opts.trackHeight);
157
+ }
158
+ if (opts.rulerHeight != null && opts.rulerHeight > 0) {
159
+ exports.RULER_HEIGHT = Math.round(opts.rulerHeight);
160
+ }
161
+ }
154
162
  var SCROLLBAR_THICKNESS = 10;
155
163
  var SCROLLBAR_MIN_THUMB = 24;
156
164
  var SCROLLBAR_INSET = 2;
157
165
  function contentHeight(tracks, isDragging) {
158
- return tracks.length * TRACK_HEIGHT + (isDragging ? TRACK_HEIGHT : 0);
166
+ return tracks.length * exports.TRACK_HEIGHT + (isDragging ? exports.TRACK_HEIGHT : 0);
159
167
  }
160
168
  function contentWidth(project, pxPerSec) {
161
169
  let max = 0;
@@ -168,12 +176,12 @@ function contentWidth(project, pxPerSec) {
168
176
  return max / 1e3 * pxPerSec;
169
177
  }
170
178
  function trackY(index) {
171
- return RULER_HEIGHT + index * TRACK_HEIGHT;
179
+ return exports.RULER_HEIGHT + index * exports.TRACK_HEIGHT;
172
180
  }
173
181
  function trackIndexAt(y, trackCount, scrollTop = 0) {
174
- if (y < RULER_HEIGHT) return -1;
175
- const contentY = y - RULER_HEIGHT + scrollTop;
176
- const idx = Math.floor(contentY / TRACK_HEIGHT);
182
+ if (y < exports.RULER_HEIGHT) return -1;
183
+ const contentY = y - exports.RULER_HEIGHT + scrollTop;
184
+ const idx = Math.floor(contentY / exports.TRACK_HEIGHT);
177
185
  if (idx < 0 || idx >= trackCount) return -1;
178
186
  return idx;
179
187
  }
@@ -253,8 +261,8 @@ function uncoveredIntervals(project) {
253
261
  return gaps;
254
262
  }
255
263
 
256
- // src/playback.ts
257
- var PlaybackEngine = class {
264
+ // src/playback/html-video.ts
265
+ var HtmlVideoEngine = class {
258
266
  host;
259
267
  mount;
260
268
  videos = /* @__PURE__ */ new Map();
@@ -270,9 +278,9 @@ var PlaybackEngine = class {
270
278
  onError;
271
279
  onReady;
272
280
  onSourceMetadata;
273
- constructor(host, project) {
274
- this.host = host;
275
- this.project = project;
281
+ constructor(opts) {
282
+ this.host = opts.host;
283
+ this.project = opts.project;
276
284
  this.mount = document.createElement("div");
277
285
  this.mount.className = "aicut-preview";
278
286
  this.host.appendChild(this.mount);
@@ -514,6 +522,328 @@ var PlaybackEngine = class {
514
522
  this.onTimeUpdate?.(this.timeMs);
515
523
  }
516
524
  };
525
+ var htmlVideoEngineFactory = (opts) => new HtmlVideoEngine(opts);
526
+
527
+ // src/playback/canvas-compositor.ts
528
+ var CanvasCompositorEngine = class {
529
+ host;
530
+ mount;
531
+ canvas;
532
+ ctx;
533
+ /** Only created when constructed with `debug: true`. */
534
+ badge = null;
535
+ videos = /* @__PURE__ */ new Map();
536
+ project;
537
+ currentClipId = null;
538
+ playing = false;
539
+ timeMs = 0;
540
+ rafHandle = null;
541
+ lastFrameTs = 0;
542
+ paintedFrames = 0;
543
+ onTimeUpdate;
544
+ onEnded;
545
+ onError;
546
+ onReady;
547
+ onSourceMetadata;
548
+ constructor(opts) {
549
+ this.host = opts.host;
550
+ this.project = opts.project;
551
+ this.mount = document.createElement("div");
552
+ this.mount.className = "aicut-preview aicut-preview--canvas";
553
+ Object.assign(this.mount.style, {
554
+ position: "absolute",
555
+ inset: "0",
556
+ width: "100%",
557
+ height: "100%"
558
+ });
559
+ this.canvas = document.createElement("canvas");
560
+ Object.assign(this.canvas.style, {
561
+ position: "absolute",
562
+ inset: "0",
563
+ width: "100%",
564
+ height: "100%",
565
+ // Stretch with letterboxing handled by the draw loop.
566
+ objectFit: "contain",
567
+ // Black until the first frame is drawn so the swap from the
568
+ // previous engine doesn't flash the host background.
569
+ background: "#000"
570
+ });
571
+ this.mount.appendChild(this.canvas);
572
+ const ctx = this.canvas.getContext("2d");
573
+ if (!ctx) throw new Error("CanvasCompositorEngine: 2d context unavailable");
574
+ this.ctx = ctx;
575
+ if (opts.debug) {
576
+ const badge = document.createElement("div");
577
+ badge.className = "aicut-preview__badge";
578
+ Object.assign(badge.style, {
579
+ position: "absolute",
580
+ top: "8px",
581
+ left: "8px",
582
+ padding: "4px 8px",
583
+ borderRadius: "6px",
584
+ background: "rgba(0, 0, 0, 0.55)",
585
+ color: "rgba(255, 255, 255, 0.92)",
586
+ font: "11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
587
+ pointerEvents: "none",
588
+ zIndex: "2",
589
+ letterSpacing: "0.02em"
590
+ });
591
+ badge.textContent = "engine: canvas compositor";
592
+ this.mount.appendChild(badge);
593
+ this.badge = badge;
594
+ }
595
+ this.host.appendChild(this.mount);
596
+ this.syncSources();
597
+ this.resizeCanvas();
598
+ this.startTickLoop();
599
+ }
600
+ setProject(next) {
601
+ this.project = next;
602
+ this.syncSources();
603
+ const clip = this.clipAtTime(this.timeMs);
604
+ if (!clip) {
605
+ this.timeMs = 0;
606
+ this.activate(null);
607
+ this.onTimeUpdate?.(0);
608
+ } else {
609
+ this.activate(clip);
610
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
611
+ }
612
+ }
613
+ play() {
614
+ if (this.playing) return;
615
+ if (this.totalDuration() <= 0) return;
616
+ const clip = this.clipAtTime(this.timeMs) ?? this.nextClipAfterTime(this.timeMs);
617
+ if (!clip) return;
618
+ if (this.timeMs < clip.start) this.timeMs = clip.start;
619
+ this.activate(clip);
620
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
621
+ const v = this.videos.get(clip.sourceId);
622
+ if (!v) return;
623
+ void v.play().catch((err) => this.onError?.(err));
624
+ this.playing = true;
625
+ this.lastFrameTs = performance.now();
626
+ }
627
+ pause() {
628
+ if (!this.playing) return;
629
+ this.playing = false;
630
+ if (this.currentClipId) {
631
+ const clip = this.clipById(this.currentClipId);
632
+ if (clip) this.videos.get(clip.sourceId)?.pause();
633
+ }
634
+ }
635
+ isPlaying() {
636
+ return this.playing;
637
+ }
638
+ getTime() {
639
+ return this.timeMs;
640
+ }
641
+ seek(timeMs) {
642
+ const total = this.totalDuration();
643
+ if (total <= 0) {
644
+ this.timeMs = 0;
645
+ return;
646
+ }
647
+ const clamped = Math.max(0, Math.min(timeMs, total));
648
+ this.timeMs = clamped;
649
+ const clip = this.clipAtTime(clamped);
650
+ if (clip) {
651
+ this.activate(clip);
652
+ this.seekVideoToClipOffset(clip, clamped - clip.start);
653
+ } else {
654
+ this.activate(null);
655
+ }
656
+ this.onTimeUpdate?.(clamped);
657
+ }
658
+ destroy() {
659
+ this.stopTickLoop();
660
+ for (const v of this.videos.values()) {
661
+ v.pause();
662
+ v.removeAttribute("src");
663
+ v.load();
664
+ }
665
+ this.videos.clear();
666
+ this.mount.remove();
667
+ }
668
+ // --- internals -------------------------------------------------------
669
+ syncSources() {
670
+ const wanted = new Set(this.project.sources.map((s) => s.id));
671
+ for (const [id, v] of this.videos) {
672
+ if (!wanted.has(id)) {
673
+ v.pause();
674
+ this.videos.delete(id);
675
+ }
676
+ }
677
+ for (const src of this.project.sources) {
678
+ if (src.kind !== "video") continue;
679
+ if (this.videos.has(src.id)) continue;
680
+ const v = document.createElement("video");
681
+ v.preload = "auto";
682
+ v.playsInline = true;
683
+ v.muted = false;
684
+ v.src = src.url;
685
+ const sourceId = src.id;
686
+ v.addEventListener(
687
+ "error",
688
+ () => this.onError?.(new Error(`Failed to load ${src.url}`))
689
+ );
690
+ v.addEventListener("loadedmetadata", () => {
691
+ this.onReady?.();
692
+ const durMs = Math.round(v.duration * 1e3);
693
+ if (Number.isFinite(durMs) && durMs > 0) {
694
+ this.onSourceMetadata?.(sourceId, durMs);
695
+ }
696
+ });
697
+ this.videos.set(src.id, v);
698
+ }
699
+ }
700
+ activate(clip) {
701
+ if (clip?.id === this.currentClipId) return;
702
+ if (this.currentClipId) {
703
+ const prev = this.clipById(this.currentClipId);
704
+ if (prev) this.videos.get(prev.sourceId)?.pause();
705
+ }
706
+ this.currentClipId = clip ? clip.id : null;
707
+ }
708
+ seekVideoToClipOffset(clip, offsetMs) {
709
+ const v = this.videos.get(clip.sourceId);
710
+ if (!v) return;
711
+ const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
712
+ if (Math.abs(v.currentTime - target) > 0.05) {
713
+ v.currentTime = target;
714
+ }
715
+ }
716
+ clipById(id) {
717
+ for (const t of this.project.tracks) {
718
+ for (const c of t.clips) if (c.id === id) return c;
719
+ }
720
+ return null;
721
+ }
722
+ clipAtTime(timeMs) {
723
+ for (const t of this.project.tracks) {
724
+ if (t.kind !== "video") continue;
725
+ for (const c of t.clips) {
726
+ if (timeMs >= c.start && timeMs < c.start + (c.out - c.in)) return c;
727
+ }
728
+ }
729
+ return null;
730
+ }
731
+ nextClipAfterTime(timeMs) {
732
+ let best = null;
733
+ for (const t of this.project.tracks) {
734
+ if (t.kind !== "video") continue;
735
+ for (const c of t.clips) {
736
+ if (c.start >= timeMs && (!best || c.start < best.start)) best = c;
737
+ }
738
+ }
739
+ return best;
740
+ }
741
+ totalDuration() {
742
+ let max = 0;
743
+ for (const t of this.project.tracks) {
744
+ if (t.kind !== "video") continue;
745
+ for (const c of t.clips) {
746
+ const e = c.start + (c.out - c.in);
747
+ if (e > max) max = e;
748
+ }
749
+ }
750
+ return max;
751
+ }
752
+ resizeCanvas() {
753
+ const rect = this.mount.getBoundingClientRect();
754
+ const dpr = window.devicePixelRatio || 1;
755
+ const w = Math.max(1, Math.floor(rect.width * dpr));
756
+ const h = Math.max(1, Math.floor(rect.height * dpr));
757
+ if (this.canvas.width !== w || this.canvas.height !== h) {
758
+ this.canvas.width = w;
759
+ this.canvas.height = h;
760
+ }
761
+ }
762
+ startTickLoop() {
763
+ this.lastFrameTs = performance.now();
764
+ const tick = (now) => {
765
+ this.resizeCanvas();
766
+ if (this.playing) {
767
+ const dtMs = now - this.lastFrameTs;
768
+ this.lastFrameTs = now;
769
+ this.advance(dtMs);
770
+ }
771
+ this.paint();
772
+ this.rafHandle = requestAnimationFrame(tick);
773
+ };
774
+ this.rafHandle = requestAnimationFrame(tick);
775
+ }
776
+ stopTickLoop() {
777
+ if (this.rafHandle != null) {
778
+ cancelAnimationFrame(this.rafHandle);
779
+ this.rafHandle = null;
780
+ }
781
+ }
782
+ advance(dtMs) {
783
+ if (this.project.tracks.length === 0) return;
784
+ this.timeMs += dtMs;
785
+ const totalDur = this.totalDuration();
786
+ if (this.timeMs >= totalDur) {
787
+ this.timeMs = totalDur;
788
+ this.onTimeUpdate?.(this.timeMs);
789
+ this.pause();
790
+ this.onEnded?.();
791
+ return;
792
+ }
793
+ const clip = this.clipAtTime(this.timeMs);
794
+ if (!clip) {
795
+ const next = this.nextClipAfterTime(this.timeMs);
796
+ if (next) {
797
+ this.timeMs = next.start;
798
+ this.activate(next);
799
+ this.seekVideoToClipOffset(next, 0);
800
+ const v = this.videos.get(next.sourceId);
801
+ if (v) void v.play().catch((err) => this.onError?.(err));
802
+ } else {
803
+ this.pause();
804
+ this.onEnded?.();
805
+ return;
806
+ }
807
+ } else if (clip.id !== this.currentClipId) {
808
+ this.activate(clip);
809
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
810
+ const v = this.videos.get(clip.sourceId);
811
+ if (v) void v.play().catch((err) => this.onError?.(err));
812
+ }
813
+ this.onTimeUpdate?.(this.timeMs);
814
+ }
815
+ /**
816
+ * One paint per rAF — clears the canvas, draws the current active
817
+ * video frame letterboxed to fit, then refreshes the HUD. Done
818
+ * unconditionally (not just on `playing`) so the HUD frame counter
819
+ * and the seek preview both update when paused.
820
+ */
821
+ paint() {
822
+ const cw = this.canvas.width;
823
+ const ch = this.canvas.height;
824
+ this.ctx.clearRect(0, 0, cw, ch);
825
+ const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
826
+ const v = clip ? this.videos.get(clip.sourceId) : null;
827
+ if (v && v.videoWidth > 0 && v.videoHeight > 0) {
828
+ const vw = v.videoWidth;
829
+ 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);
836
+ this.paintedFrames += 1;
837
+ }
838
+ this.updateBadge();
839
+ }
840
+ updateBadge() {
841
+ if (!this.badge) return;
842
+ const sec = (this.timeMs / 1e3).toFixed(2);
843
+ this.badge.textContent = `engine: canvas compositor \u2022 t=${sec}s \u2022 frames painted: ${this.paintedFrames}`;
844
+ }
845
+ };
846
+ var canvasCompositorEngineFactory = (opts) => new CanvasCompositorEngine(opts);
517
847
 
518
848
  // src/theme.ts
519
849
  var THEME_VARS = {
@@ -657,11 +987,19 @@ var ThumbnailRibbon = class {
657
987
  /**
658
988
  * Paint thumbnails for the clip's visible window onto `ctx`. The
659
989
  * canvas is the per-clip strip — width = clip's px width, height =
660
- * THUMB_HEIGHT. Source-time range derives from the clip's `in/out`
661
- * and the px range we're drawing into.
990
+ * `pxHeight` (defaults to the cached `THUMB_HEIGHT`). Source-time
991
+ * range derives from the clip's `in/out` and the px range we're
992
+ * drawing into.
993
+ *
994
+ * `pxHeight` lets the caller stretch thumbs to fill a taller clip
995
+ * body when `trackHeight` is configured above the default. Aspect
996
+ * ratio is already broken per-thumb (we slice variable widths from a
997
+ * fixed-aspect cached bitmap), so stretching height too is fine — it
998
+ * preserves the "filmstrip" look without leaving an empty bottom
999
+ * band of the brand gradient showing through.
662
1000
  */
663
- paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth) {
664
- ctx.clearRect(0, 0, pxWidth, THUMB_HEIGHT);
1001
+ paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth, pxHeight = THUMB_HEIGHT) {
1002
+ ctx.clearRect(0, 0, pxWidth, pxHeight);
665
1003
  const st = this.sources.get(sourceId);
666
1004
  if (!st) return;
667
1005
  if (sourceOutMs <= sourceInMs || pxWidth <= 0) return;
@@ -674,10 +1012,10 @@ var ThumbnailRibbon = class {
674
1012
  const x = Math.round(i * pxWidth / count);
675
1013
  const w = Math.round((i + 1) * pxWidth / count) - x;
676
1014
  if (bmp) {
677
- ctx.drawImage(bmp, x, 0, w, THUMB_HEIGHT);
1015
+ ctx.drawImage(bmp, x, 0, w, pxHeight);
678
1016
  } else {
679
1017
  ctx.fillStyle = "rgba(255,255,255,0.04)";
680
- ctx.fillRect(x, 0, w, THUMB_HEIGHT);
1018
+ ctx.fillRect(x, 0, w, pxHeight);
681
1019
  this.enqueue(st, bucket);
682
1020
  }
683
1021
  }
@@ -770,10 +1108,10 @@ function drawAll(ctx, state, style, thumbs) {
770
1108
  ctx.fillRect(0, 0, W, H);
771
1109
  const baseX = state.showHeader ? HEADER_WIDTH : 0;
772
1110
  const trackAreaW = W - baseX - SCROLLBAR_THICKNESS;
773
- const trackAreaH = H - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1111
+ const trackAreaH = H - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
774
1112
  ctx.save();
775
1113
  ctx.beginPath();
776
- ctx.rect(baseX, RULER_HEIGHT, trackAreaW, trackAreaH);
1114
+ ctx.rect(baseX, exports.RULER_HEIGHT, trackAreaW, trackAreaH);
777
1115
  ctx.clip();
778
1116
  ctx.translate(0, -state.scrollTop);
779
1117
  drawTracks(ctx, state, style, thumbs);
@@ -785,7 +1123,7 @@ function drawAll(ctx, state, style, thumbs) {
785
1123
  if (state.showHeader) {
786
1124
  ctx.save();
787
1125
  ctx.beginPath();
788
- ctx.rect(0, RULER_HEIGHT, HEADER_WIDTH, trackAreaH);
1126
+ ctx.rect(0, exports.RULER_HEIGHT, HEADER_WIDTH, trackAreaH);
789
1127
  ctx.clip();
790
1128
  ctx.translate(0, -state.scrollTop);
791
1129
  drawHeaders(ctx, state, style);
@@ -793,7 +1131,7 @@ function drawAll(ctx, state, style, thumbs) {
793
1131
  }
794
1132
  ctx.save();
795
1133
  ctx.beginPath();
796
- ctx.rect(baseX, 0, trackAreaW, RULER_HEIGHT);
1134
+ ctx.rect(baseX, 0, trackAreaW, exports.RULER_HEIGHT);
797
1135
  ctx.clip();
798
1136
  drawRuler(ctx, state, style);
799
1137
  ctx.restore();
@@ -817,7 +1155,7 @@ function drawCoverageGaps(ctx, state, style) {
817
1155
  const gaps = uncoveredIntervals(state.project);
818
1156
  if (gaps.length === 0) return;
819
1157
  const baseX = state.showHeader ? HEADER_WIDTH : 0;
820
- const trackStackH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1158
+ const trackStackH = state.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
821
1159
  for (const [s, e] of gaps) {
822
1160
  const x1 = Math.max(
823
1161
  baseX,
@@ -829,16 +1167,16 @@ function drawCoverageGaps(ctx, state, style) {
829
1167
  );
830
1168
  if (x2 <= x1) continue;
831
1169
  ctx.fillStyle = "rgba(250, 167, 0, 0.35)";
832
- ctx.fillRect(x1, 0, x2 - x1, RULER_HEIGHT);
1170
+ ctx.fillRect(x1, 0, x2 - x1, exports.RULER_HEIGHT);
833
1171
  ctx.fillStyle = "rgba(250, 167, 0, 0.12)";
834
- ctx.fillRect(x1, RULER_HEIGHT, x2 - x1, trackStackH);
1172
+ ctx.fillRect(x1, exports.RULER_HEIGHT, x2 - x1, trackStackH);
835
1173
  ctx.save();
836
1174
  ctx.strokeStyle = "rgba(250, 167, 0, 0.6)";
837
1175
  ctx.lineWidth = 1;
838
1176
  ctx.beginPath();
839
1177
  for (let hx = Math.floor(x1); hx < x2; hx += 6) {
840
- ctx.moveTo(hx, RULER_HEIGHT - 1);
841
- ctx.lineTo(hx + 6, RULER_HEIGHT - 7);
1178
+ ctx.moveTo(hx, exports.RULER_HEIGHT - 1);
1179
+ ctx.lineTo(hx + 6, exports.RULER_HEIGHT - 7);
842
1180
  }
843
1181
  ctx.stroke();
844
1182
  ctx.restore();
@@ -887,7 +1225,7 @@ function drawDragGhost(ctx, state, style, thumbs) {
887
1225
  }
888
1226
  function drawDropOutline(ctx, startX, trackIndex, widthPx, color, emphasized) {
889
1227
  const y = trackY(trackIndex) + CLIP_INSET - 1;
890
- const h = TRACK_HEIGHT - CLIP_INSET * 2 + 2;
1228
+ const h = exports.TRACK_HEIGHT - CLIP_INSET * 2 + 2;
891
1229
  ctx.save();
892
1230
  if (emphasized) {
893
1231
  ctx.shadowColor = withAlpha(color, 0.45);
@@ -904,22 +1242,22 @@ function drawPhantomRow(ctx, trackIndex, baseX, state, style) {
904
1242
  const w = state.viewportWidth - baseX;
905
1243
  ctx.save();
906
1244
  ctx.fillStyle = withAlpha(style.info, 0.04);
907
- ctx.fillRect(baseX, y, w, TRACK_HEIGHT);
1245
+ ctx.fillRect(baseX, y, w, exports.TRACK_HEIGHT);
908
1246
  ctx.strokeStyle = withAlpha(style.info, 0.35);
909
1247
  ctx.lineWidth = 1;
910
1248
  ctx.setLineDash([3, 4]);
911
1249
  ctx.beginPath();
912
1250
  ctx.moveTo(baseX, y + 0.5);
913
1251
  ctx.lineTo(baseX + w, y + 0.5);
914
- ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
915
- ctx.lineTo(baseX + w, y + TRACK_HEIGHT - 0.5);
1252
+ ctx.moveTo(baseX, y + exports.TRACK_HEIGHT - 0.5);
1253
+ ctx.lineTo(baseX + w, y + exports.TRACK_HEIGHT - 0.5);
916
1254
  ctx.stroke();
917
1255
  ctx.setLineDash([]);
918
1256
  if (state.showHeader) {
919
1257
  ctx.fillStyle = withAlpha(style.info, 0.7);
920
1258
  ctx.font = "10px system-ui, -apple-system, sans-serif";
921
1259
  ctx.textBaseline = "middle";
922
- ctx.fillText(state.locale.newTrack, 12, y + TRACK_HEIGHT / 2);
1260
+ ctx.fillText(state.locale.newTrack, 12, y + exports.TRACK_HEIGHT / 2);
923
1261
  }
924
1262
  ctx.restore();
925
1263
  }
@@ -928,12 +1266,12 @@ function drawRuler(ctx, state, style) {
928
1266
  const baseX = state.showHeader ? HEADER_WIDTH : 0;
929
1267
  const rulerW = W - baseX;
930
1268
  ctx.fillStyle = style.bg;
931
- ctx.fillRect(baseX, 0, rulerW, RULER_HEIGHT);
1269
+ ctx.fillRect(baseX, 0, rulerW, exports.RULER_HEIGHT);
932
1270
  ctx.strokeStyle = style.border;
933
1271
  ctx.lineWidth = 1;
934
1272
  ctx.beginPath();
935
- ctx.moveTo(baseX, RULER_HEIGHT - 0.5);
936
- ctx.lineTo(W, RULER_HEIGHT - 0.5);
1273
+ ctx.moveTo(baseX, exports.RULER_HEIGHT - 0.5);
1274
+ ctx.lineTo(W, exports.RULER_HEIGHT - 0.5);
937
1275
  ctx.stroke();
938
1276
  const minPx = 80;
939
1277
  const tickSec = niceTickSeconds(minPx / pxPerSec);
@@ -954,12 +1292,12 @@ function drawRuler(ctx, state, style) {
954
1292
  ctx.lineWidth = 1;
955
1293
  const h = isMajor ? 10 : 6;
956
1294
  ctx.beginPath();
957
- ctx.moveTo(x + 0.5, RULER_HEIGHT - h);
958
- ctx.lineTo(x + 0.5, RULER_HEIGHT - 1);
1295
+ ctx.moveTo(x + 0.5, exports.RULER_HEIGHT - h);
1296
+ ctx.lineTo(x + 0.5, exports.RULER_HEIGHT - 1);
959
1297
  ctx.stroke();
960
1298
  if (isMajor) {
961
1299
  ctx.fillStyle = withAlpha(style.textMuted, 0.85);
962
- ctx.fillText(formatRulerLabel(s), x + 3, RULER_HEIGHT - 12);
1300
+ ctx.fillText(formatRulerLabel(s), x + 3, exports.RULER_HEIGHT - 12);
963
1301
  }
964
1302
  }
965
1303
  }
@@ -974,12 +1312,12 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
974
1312
  const baseX = state.showHeader ? HEADER_WIDTH : 0;
975
1313
  const y = trackY(trackIndex);
976
1314
  ctx.fillStyle = style.trackBg;
977
- ctx.fillRect(baseX, y, W - baseX, TRACK_HEIGHT);
1315
+ ctx.fillRect(baseX, y, W - baseX, exports.TRACK_HEIGHT);
978
1316
  ctx.strokeStyle = style.border;
979
1317
  ctx.lineWidth = 1;
980
1318
  ctx.beginPath();
981
- ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
982
- ctx.lineTo(W, y + TRACK_HEIGHT - 0.5);
1319
+ ctx.moveTo(baseX, y + exports.TRACK_HEIGHT - 0.5);
1320
+ ctx.lineTo(W, y + exports.TRACK_HEIGHT - 0.5);
983
1321
  ctx.stroke();
984
1322
  if (state.dropTargetTrackIndex === trackIndex) {
985
1323
  ctx.strokeStyle = withAlpha(style.info, 0.45);
@@ -987,8 +1325,8 @@ function drawTrackRow(ctx, trackIndex, track, sources, state, style, thumbs) {
987
1325
  ctx.beginPath();
988
1326
  ctx.moveTo(baseX, y + 0.5);
989
1327
  ctx.lineTo(W, y + 0.5);
990
- ctx.moveTo(baseX, y + TRACK_HEIGHT - 0.5);
991
- ctx.lineTo(W, y + TRACK_HEIGHT - 0.5);
1328
+ ctx.moveTo(baseX, y + exports.TRACK_HEIGHT - 0.5);
1329
+ ctx.lineTo(W, y + exports.TRACK_HEIGHT - 0.5);
992
1330
  ctx.stroke();
993
1331
  }
994
1332
  for (const clip of track.clips) {
@@ -1013,7 +1351,7 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
1013
1351
  const startX = baseX + startMs / 1e3 * pxPerSec - scrollLeft;
1014
1352
  const widthPx = Math.max(2, (clip.out - clip.in) / 1e3 * pxPerSec);
1015
1353
  const y = trackY(trackIndex) + CLIP_INSET;
1016
- const h = TRACK_HEIGHT - CLIP_INSET * 2;
1354
+ const h = exports.TRACK_HEIGHT - CLIP_INSET * 2;
1017
1355
  if (startX + widthPx < baseX || startX > state.viewportWidth) return;
1018
1356
  ctx.save();
1019
1357
  if (dim) ctx.globalAlpha = 0.3;
@@ -1032,7 +1370,7 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
1032
1370
  roundRect(ctx, startX, y, widthPx, h, 6);
1033
1371
  ctx.clip();
1034
1372
  ctx.translate(startX, y);
1035
- thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx);
1373
+ thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx, h);
1036
1374
  ctx.restore();
1037
1375
  ctx.strokeStyle = "rgba(255,255,255,0.2)";
1038
1376
  ctx.lineWidth = 1;
@@ -1076,18 +1414,18 @@ function drawHeaders(ctx, state, style) {
1076
1414
  const y = trackY(i);
1077
1415
  ctx.strokeStyle = style.border;
1078
1416
  ctx.beginPath();
1079
- ctx.moveTo(0, y + TRACK_HEIGHT - 0.5);
1080
- ctx.lineTo(HEADER_WIDTH, y + TRACK_HEIGHT - 0.5);
1417
+ ctx.moveTo(0, y + exports.TRACK_HEIGHT - 0.5);
1418
+ ctx.lineTo(HEADER_WIDTH, y + exports.TRACK_HEIGHT - 0.5);
1081
1419
  ctx.stroke();
1082
1420
  ctx.fillStyle = withAlpha(style.text, 0.7);
1083
1421
  const template = t.kind === "video" ? state.locale.videoTrackLabel : state.locale.audioTrackLabel;
1084
1422
  const label = formatLabel(template, { n: i + 1 });
1085
- ctx.fillText(label, 12, y + TRACK_HEIGHT / 2);
1423
+ ctx.fillText(label, 12, y + exports.TRACK_HEIGHT / 2);
1086
1424
  if (t.clips.length === 0) {
1087
1425
  const hovered = state.hoveredTrackIndex === i;
1088
1426
  const btnSize = 18;
1089
1427
  const btnLeft = HEADER_WIDTH - btnSize - 6;
1090
- const btnTop = y + (TRACK_HEIGHT - btnSize) / 2;
1428
+ const btnTop = y + (exports.TRACK_HEIGHT - btnSize) / 2;
1091
1429
  ctx.save();
1092
1430
  if (hovered) {
1093
1431
  ctx.fillStyle = withAlpha(style.text, 0.1);
@@ -1150,11 +1488,11 @@ function drawSnapGuide(ctx, state, style) {
1150
1488
  }
1151
1489
  function drawScrollbarV(ctx, state, style) {
1152
1490
  if (state.scrollbarOpacityY <= 0.01) return;
1153
- const visibleH = state.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1491
+ const visibleH = state.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
1154
1492
  const contentH = contentHeight(state.project.tracks, state.isDragging);
1155
1493
  if (contentH <= visibleH) return;
1156
1494
  const trackX = state.viewportWidth - SCROLLBAR_THICKNESS + SCROLLBAR_INSET;
1157
- const trackY0 = RULER_HEIGHT + SCROLLBAR_INSET;
1495
+ const trackY0 = exports.RULER_HEIGHT + SCROLLBAR_INSET;
1158
1496
  const trackLen = visibleH - SCROLLBAR_INSET * 2;
1159
1497
  const thumbLen = Math.max(
1160
1498
  SCROLLBAR_MIN_THUMB,
@@ -1266,16 +1604,16 @@ function parseColor(s) {
1266
1604
  function hitTest(x, y, ctx) {
1267
1605
  if (y < 0 || x < 0) return { kind: "outside" };
1268
1606
  const baseX = ctx.showHeader ? HEADER_WIDTH : 0;
1269
- const visibleH = ctx.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1607
+ const visibleH = ctx.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
1270
1608
  const contentH = contentHeight(ctx.project.tracks, ctx.isDragging);
1271
- if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
1609
+ if (contentH > visibleH && x >= ctx.viewportWidth - SCROLLBAR_THICKNESS && x < ctx.viewportWidth && y >= exports.RULER_HEIGHT && y < ctx.viewportHeight - SCROLLBAR_THICKNESS) {
1272
1610
  const trackLen = visibleH - SCROLLBAR_INSET * 2;
1273
1611
  const thumbLen = Math.max(
1274
1612
  SCROLLBAR_MIN_THUMB,
1275
1613
  trackLen * (visibleH / contentH)
1276
1614
  );
1277
1615
  const maxScroll = contentH - visibleH;
1278
- const thumbY = RULER_HEIGHT + SCROLLBAR_INSET + (maxScroll > 0 ? ctx.scrollTop / maxScroll * (trackLen - thumbLen) : 0);
1616
+ const thumbY = exports.RULER_HEIGHT + SCROLLBAR_INSET + (maxScroll > 0 ? ctx.scrollTop / maxScroll * (trackLen - thumbLen) : 0);
1279
1617
  if (y >= thumbY && y <= thumbY + thumbLen) {
1280
1618
  return { kind: "scrollbar-thumb-v", thumbY, thumbLen };
1281
1619
  }
@@ -1296,14 +1634,14 @@ function hitTest(x, y, ctx) {
1296
1634
  }
1297
1635
  return { kind: "scrollbar-track-h", before: x < thumbX };
1298
1636
  }
1299
- if (ctx.showHeader && x < HEADER_WIDTH && y >= RULER_HEIGHT) {
1637
+ if (ctx.showHeader && x < HEADER_WIDTH && y >= exports.RULER_HEIGHT) {
1300
1638
  const ti2 = trackIndexAt(y, ctx.project.tracks.length, ctx.scrollTop);
1301
1639
  if (ti2 >= 0) {
1302
1640
  const track2 = ctx.project.tracks[ti2];
1303
1641
  if (track2.clips.length === 0) {
1304
1642
  const btnSize = 18;
1305
1643
  const btnLeft = HEADER_WIDTH - btnSize - 6;
1306
- const btnTop = RULER_HEIGHT + ti2 * 56 + (56 - btnSize) / 2 - ctx.scrollTop;
1644
+ const btnTop = exports.RULER_HEIGHT + ti2 * exports.TRACK_HEIGHT + (exports.TRACK_HEIGHT - btnSize) / 2 - ctx.scrollTop;
1307
1645
  if (x >= btnLeft && x <= btnLeft + btnSize && y >= btnTop && y <= btnTop + btnSize) {
1308
1646
  return { kind: "header-delete", trackIndex: ti2 };
1309
1647
  }
@@ -1312,7 +1650,7 @@ function hitTest(x, y, ctx) {
1312
1650
  }
1313
1651
  return { kind: "outside" };
1314
1652
  }
1315
- if (y < RULER_HEIGHT) return { kind: "ruler" };
1653
+ if (y < exports.RULER_HEIGHT) return { kind: "ruler" };
1316
1654
  const ti = trackIndexAt(y, ctx.project.tracks.length, ctx.scrollTop);
1317
1655
  if (ti < 0) return { kind: "outside" };
1318
1656
  const track = ctx.project.tracks[ti];
@@ -1560,14 +1898,14 @@ var Timeline = class _Timeline {
1560
1898
  for (const c of t.clips) {
1561
1899
  const x = baseX + c.start / 1e3 * this.pxPerSec - this.scrollLeft;
1562
1900
  const width = (c.out - c.in) / 1e3 * this.pxPerSec;
1563
- const y = RULER_HEIGHT + ti * TRACK_HEIGHT + 6;
1901
+ const y = exports.RULER_HEIGHT + ti * exports.TRACK_HEIGHT + 6;
1564
1902
  clips.push({
1565
1903
  id: c.id,
1566
1904
  trackIndex: ti,
1567
1905
  x,
1568
1906
  width,
1569
1907
  y,
1570
- height: TRACK_HEIGHT - 12
1908
+ height: exports.TRACK_HEIGHT - 12
1571
1909
  });
1572
1910
  }
1573
1911
  }
@@ -1597,13 +1935,12 @@ var Timeline = class _Timeline {
1597
1935
  const rect = this.canvas.getBoundingClientRect();
1598
1936
  this.viewportWidth = Math.max(1, Math.floor(rect.width));
1599
1937
  this.viewportHeight = Math.max(
1600
- Math.floor(rect.height) || RULER_HEIGHT + TRACK_HEIGHT + SCROLLBAR_THICKNESS,
1601
- RULER_HEIGHT + TRACK_HEIGHT + SCROLLBAR_THICKNESS
1938
+ Math.floor(rect.height) || exports.RULER_HEIGHT + exports.TRACK_HEIGHT + SCROLLBAR_THICKNESS,
1939
+ exports.RULER_HEIGHT + exports.TRACK_HEIGHT + SCROLLBAR_THICKNESS
1602
1940
  );
1603
1941
  const dpr = window.devicePixelRatio || 1;
1604
1942
  this.canvas.width = Math.floor(this.viewportWidth * dpr);
1605
1943
  this.canvas.height = Math.floor(this.viewportHeight * dpr);
1606
- this.canvas.style.height = `${this.viewportHeight}px`;
1607
1944
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1608
1945
  }
1609
1946
  computeFitScale() {
@@ -1620,7 +1957,7 @@ var Timeline = class _Timeline {
1620
1957
  return Math.max(0, cw - visibleW + 24);
1621
1958
  }
1622
1959
  maxScrollTop() {
1623
- const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
1960
+ const visibleH = this.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
1624
1961
  const ch = contentHeight(this.project.tracks, this.drag?.kind === "move");
1625
1962
  return Math.max(0, ch - visibleH);
1626
1963
  }
@@ -1781,8 +2118,8 @@ var Timeline = class _Timeline {
1781
2118
  }
1782
2119
  if (target.kind === "scrollbar-track-v") {
1783
2120
  const page = Math.max(
1784
- TRACK_HEIGHT,
1785
- this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS
2121
+ exports.TRACK_HEIGHT,
2122
+ this.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS
1786
2123
  );
1787
2124
  this.scrollTop += target.before ? -page : page;
1788
2125
  this.clampScroll();
@@ -1881,7 +2218,7 @@ var Timeline = class _Timeline {
1881
2218
  const { x, y } = this.localCoords(e);
1882
2219
  if (this.scrollbarDrag) {
1883
2220
  if (this.scrollbarDrag.axis === "v") {
1884
- const visibleH = this.viewportHeight - RULER_HEIGHT - SCROLLBAR_THICKNESS;
2221
+ const visibleH = this.viewportHeight - exports.RULER_HEIGHT - SCROLLBAR_THICKNESS;
1885
2222
  const contentH = contentHeight(
1886
2223
  this.project.tracks,
1887
2224
  this.drag?.kind === "move"
@@ -2019,8 +2356,9 @@ var Timeline = class _Timeline {
2019
2356
  nextStart = this.applySnap(nextStart, drag.clipId);
2020
2357
  const tiRaw = this.trackIndexAtY(y);
2021
2358
  const phantomIdx = this.project.tracks.length;
2022
- const phantomScreenY = RULER_HEIGHT + phantomIdx * TRACK_HEIGHT - this.scrollTop;
2023
- const onPhantom = y >= phantomScreenY && y < phantomScreenY + TRACK_HEIGHT;
2359
+ const phantomScreenY = exports.RULER_HEIGHT + phantomIdx * exports.TRACK_HEIGHT - this.scrollTop;
2360
+ const viewportBottom = this.viewportHeight - SCROLLBAR_THICKNESS;
2361
+ const onPhantom = y >= phantomScreenY && y < Math.max(phantomScreenY + exports.TRACK_HEIGHT, viewportBottom);
2024
2362
  const intendedTrackIndex = onPhantom ? phantomIdx : tiRaw >= 0 ? tiRaw : drag.trackIndex;
2025
2363
  let ghostTrackIndex = intendedTrackIndex;
2026
2364
  let overlap = false;
@@ -2058,7 +2396,7 @@ var Timeline = class _Timeline {
2058
2396
  dragScrollSpeedY() {
2059
2397
  if (!this.drag || this.drag.kind !== "move") return 0;
2060
2398
  const y = this.lastDragPointerY;
2061
- const top = RULER_HEIGHT;
2399
+ const top = exports.RULER_HEIGHT;
2062
2400
  const bottom = this.viewportHeight - SCROLLBAR_THICKNESS;
2063
2401
  const zone = 36;
2064
2402
  const maxSpeed = 16;
@@ -2785,7 +3123,19 @@ var Editor = class _Editor {
2785
3123
  this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
2786
3124
  this.snap = opts.initialSnap !== false;
2787
3125
  this.locale = mergeLocale(opts.locale);
3126
+ if (opts.trackHeight != null || opts.rulerHeight != null) {
3127
+ setTimelineMetrics({
3128
+ ...opts.trackHeight != null ? { trackHeight: opts.trackHeight } : {},
3129
+ ...opts.rulerHeight != null ? { rulerHeight: opts.rulerHeight } : {}
3130
+ });
3131
+ }
2788
3132
  applyTheme(this.container, opts.theme);
3133
+ if (opts.timelineHeight != null && opts.timelineHeight > 0) {
3134
+ this.container.style.setProperty(
3135
+ "--aicut-timeline-height",
3136
+ `${Math.round(opts.timelineHeight)}px`
3137
+ );
3138
+ }
2789
3139
  this.ui = new EditorUI(this.container, this, {
2790
3140
  onPlayToggle: () => this.togglePlay(),
2791
3141
  onSplit: () => this.split(),
@@ -2803,7 +3153,11 @@ var Editor = class _Editor {
2803
3153
  onMoveClip: (id, opts2) => this.moveClip(id, opts2),
2804
3154
  onResizeClip: (id, edits) => this.resizeClip(id, edits)
2805
3155
  });
2806
- this.engine = new PlaybackEngine(this.ui.previewHost, this.project);
3156
+ const engineFactory = opts.playbackEngine ?? ((o) => new HtmlVideoEngine(o));
3157
+ this.engine = engineFactory({
3158
+ host: this.ui.previewHost,
3159
+ project: this.project
3160
+ });
2807
3161
  this.engine.onTimeUpdate = (ms) => {
2808
3162
  this.bus.emit("time", { timeMs: ms });
2809
3163
  this.ui.onTimeTick(ms);
@@ -3348,17 +3702,20 @@ function clampScale2(s) {
3348
3702
  return Math.max(MIN_PX_PER_SEC, Math.min(MAX_PX_PER_SEC, s));
3349
3703
  }
3350
3704
 
3705
+ exports.CanvasCompositorEngine = CanvasCompositorEngine;
3351
3706
  exports.Editor = Editor;
3352
3707
  exports.HEADER_WIDTH = HEADER_WIDTH;
3353
- exports.RULER_HEIGHT = RULER_HEIGHT;
3354
- exports.TRACK_HEIGHT = TRACK_HEIGHT;
3708
+ exports.HtmlVideoEngine = HtmlVideoEngine;
3355
3709
  exports.Timeline = Timeline;
3710
+ exports.canvasCompositorEngineFactory = canvasCompositorEngineFactory;
3356
3711
  exports.createEmptyProject = createEmptyProject;
3357
3712
  exports.createId = createId;
3358
3713
  exports.formatLabel = formatLabel;
3714
+ exports.htmlVideoEngineFactory = htmlVideoEngineFactory;
3359
3715
  exports.localeEn = localeEn;
3360
3716
  exports.localeZh = localeZh;
3361
3717
  exports.mergeLocale = mergeLocale;
3362
3718
  exports.normalizeProject = normalizeProject;
3719
+ exports.setTimelineMetrics = setTimelineMetrics;
3363
3720
  //# sourceMappingURL=index.cjs.map
3364
3721
  //# sourceMappingURL=index.cjs.map