@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.js CHANGED
@@ -152,6 +152,14 @@ var HANDLE_PX = 8;
152
152
  var CLIP_INSET = 6;
153
153
  var SCALE_MIN = 10;
154
154
  var SCALE_MAX = 400;
155
+ function setTimelineMetrics(opts) {
156
+ if (opts.trackHeight != null && opts.trackHeight > 0) {
157
+ TRACK_HEIGHT = Math.round(opts.trackHeight);
158
+ }
159
+ if (opts.rulerHeight != null && opts.rulerHeight > 0) {
160
+ RULER_HEIGHT = Math.round(opts.rulerHeight);
161
+ }
162
+ }
155
163
  var SCROLLBAR_THICKNESS = 10;
156
164
  var SCROLLBAR_MIN_THUMB = 24;
157
165
  var SCROLLBAR_INSET = 2;
@@ -254,8 +262,8 @@ function uncoveredIntervals(project) {
254
262
  return gaps;
255
263
  }
256
264
 
257
- // src/playback.ts
258
- var PlaybackEngine = class {
265
+ // src/playback/html-video.ts
266
+ var HtmlVideoEngine = class {
259
267
  host;
260
268
  mount;
261
269
  videos = /* @__PURE__ */ new Map();
@@ -271,9 +279,9 @@ var PlaybackEngine = class {
271
279
  onError;
272
280
  onReady;
273
281
  onSourceMetadata;
274
- constructor(host, project) {
275
- this.host = host;
276
- this.project = project;
282
+ constructor(opts) {
283
+ this.host = opts.host;
284
+ this.project = opts.project;
277
285
  this.mount = document.createElement("div");
278
286
  this.mount.className = "aicut-preview";
279
287
  this.host.appendChild(this.mount);
@@ -515,6 +523,328 @@ var PlaybackEngine = class {
515
523
  this.onTimeUpdate?.(this.timeMs);
516
524
  }
517
525
  };
526
+ var htmlVideoEngineFactory = (opts) => new HtmlVideoEngine(opts);
527
+
528
+ // src/playback/canvas-compositor.ts
529
+ var CanvasCompositorEngine = class {
530
+ host;
531
+ mount;
532
+ canvas;
533
+ ctx;
534
+ /** Only created when constructed with `debug: true`. */
535
+ badge = null;
536
+ videos = /* @__PURE__ */ new Map();
537
+ project;
538
+ currentClipId = null;
539
+ playing = false;
540
+ timeMs = 0;
541
+ rafHandle = null;
542
+ lastFrameTs = 0;
543
+ paintedFrames = 0;
544
+ onTimeUpdate;
545
+ onEnded;
546
+ onError;
547
+ onReady;
548
+ onSourceMetadata;
549
+ constructor(opts) {
550
+ this.host = opts.host;
551
+ this.project = opts.project;
552
+ this.mount = document.createElement("div");
553
+ this.mount.className = "aicut-preview aicut-preview--canvas";
554
+ Object.assign(this.mount.style, {
555
+ position: "absolute",
556
+ inset: "0",
557
+ width: "100%",
558
+ height: "100%"
559
+ });
560
+ this.canvas = document.createElement("canvas");
561
+ Object.assign(this.canvas.style, {
562
+ position: "absolute",
563
+ inset: "0",
564
+ width: "100%",
565
+ height: "100%",
566
+ // Stretch with letterboxing handled by the draw loop.
567
+ objectFit: "contain",
568
+ // Black until the first frame is drawn so the swap from the
569
+ // previous engine doesn't flash the host background.
570
+ background: "#000"
571
+ });
572
+ this.mount.appendChild(this.canvas);
573
+ const ctx = this.canvas.getContext("2d");
574
+ if (!ctx) throw new Error("CanvasCompositorEngine: 2d context unavailable");
575
+ this.ctx = ctx;
576
+ if (opts.debug) {
577
+ const badge = document.createElement("div");
578
+ badge.className = "aicut-preview__badge";
579
+ Object.assign(badge.style, {
580
+ position: "absolute",
581
+ top: "8px",
582
+ left: "8px",
583
+ padding: "4px 8px",
584
+ borderRadius: "6px",
585
+ background: "rgba(0, 0, 0, 0.55)",
586
+ color: "rgba(255, 255, 255, 0.92)",
587
+ font: "11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
588
+ pointerEvents: "none",
589
+ zIndex: "2",
590
+ letterSpacing: "0.02em"
591
+ });
592
+ badge.textContent = "engine: canvas compositor";
593
+ this.mount.appendChild(badge);
594
+ this.badge = badge;
595
+ }
596
+ this.host.appendChild(this.mount);
597
+ this.syncSources();
598
+ this.resizeCanvas();
599
+ this.startTickLoop();
600
+ }
601
+ setProject(next) {
602
+ this.project = next;
603
+ this.syncSources();
604
+ const clip = this.clipAtTime(this.timeMs);
605
+ if (!clip) {
606
+ this.timeMs = 0;
607
+ this.activate(null);
608
+ this.onTimeUpdate?.(0);
609
+ } else {
610
+ this.activate(clip);
611
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
612
+ }
613
+ }
614
+ play() {
615
+ if (this.playing) return;
616
+ if (this.totalDuration() <= 0) return;
617
+ const clip = this.clipAtTime(this.timeMs) ?? this.nextClipAfterTime(this.timeMs);
618
+ if (!clip) return;
619
+ if (this.timeMs < clip.start) this.timeMs = clip.start;
620
+ this.activate(clip);
621
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
622
+ const v = this.videos.get(clip.sourceId);
623
+ if (!v) return;
624
+ void v.play().catch((err) => this.onError?.(err));
625
+ this.playing = true;
626
+ this.lastFrameTs = performance.now();
627
+ }
628
+ pause() {
629
+ if (!this.playing) return;
630
+ this.playing = false;
631
+ if (this.currentClipId) {
632
+ const clip = this.clipById(this.currentClipId);
633
+ if (clip) this.videos.get(clip.sourceId)?.pause();
634
+ }
635
+ }
636
+ isPlaying() {
637
+ return this.playing;
638
+ }
639
+ getTime() {
640
+ return this.timeMs;
641
+ }
642
+ seek(timeMs) {
643
+ const total = this.totalDuration();
644
+ if (total <= 0) {
645
+ this.timeMs = 0;
646
+ return;
647
+ }
648
+ const clamped = Math.max(0, Math.min(timeMs, total));
649
+ this.timeMs = clamped;
650
+ const clip = this.clipAtTime(clamped);
651
+ if (clip) {
652
+ this.activate(clip);
653
+ this.seekVideoToClipOffset(clip, clamped - clip.start);
654
+ } else {
655
+ this.activate(null);
656
+ }
657
+ this.onTimeUpdate?.(clamped);
658
+ }
659
+ destroy() {
660
+ this.stopTickLoop();
661
+ for (const v of this.videos.values()) {
662
+ v.pause();
663
+ v.removeAttribute("src");
664
+ v.load();
665
+ }
666
+ this.videos.clear();
667
+ this.mount.remove();
668
+ }
669
+ // --- internals -------------------------------------------------------
670
+ syncSources() {
671
+ const wanted = new Set(this.project.sources.map((s) => s.id));
672
+ for (const [id, v] of this.videos) {
673
+ if (!wanted.has(id)) {
674
+ v.pause();
675
+ this.videos.delete(id);
676
+ }
677
+ }
678
+ for (const src of this.project.sources) {
679
+ if (src.kind !== "video") continue;
680
+ if (this.videos.has(src.id)) continue;
681
+ const v = document.createElement("video");
682
+ v.preload = "auto";
683
+ v.playsInline = true;
684
+ v.muted = false;
685
+ v.src = src.url;
686
+ const sourceId = src.id;
687
+ v.addEventListener(
688
+ "error",
689
+ () => this.onError?.(new Error(`Failed to load ${src.url}`))
690
+ );
691
+ v.addEventListener("loadedmetadata", () => {
692
+ this.onReady?.();
693
+ const durMs = Math.round(v.duration * 1e3);
694
+ if (Number.isFinite(durMs) && durMs > 0) {
695
+ this.onSourceMetadata?.(sourceId, durMs);
696
+ }
697
+ });
698
+ this.videos.set(src.id, v);
699
+ }
700
+ }
701
+ activate(clip) {
702
+ if (clip?.id === this.currentClipId) return;
703
+ if (this.currentClipId) {
704
+ const prev = this.clipById(this.currentClipId);
705
+ if (prev) this.videos.get(prev.sourceId)?.pause();
706
+ }
707
+ this.currentClipId = clip ? clip.id : null;
708
+ }
709
+ seekVideoToClipOffset(clip, offsetMs) {
710
+ const v = this.videos.get(clip.sourceId);
711
+ if (!v) return;
712
+ const target = (clip.in + Math.max(0, offsetMs)) / 1e3;
713
+ if (Math.abs(v.currentTime - target) > 0.05) {
714
+ v.currentTime = target;
715
+ }
716
+ }
717
+ clipById(id) {
718
+ for (const t of this.project.tracks) {
719
+ for (const c of t.clips) if (c.id === id) return c;
720
+ }
721
+ return null;
722
+ }
723
+ clipAtTime(timeMs) {
724
+ for (const t of this.project.tracks) {
725
+ if (t.kind !== "video") continue;
726
+ for (const c of t.clips) {
727
+ if (timeMs >= c.start && timeMs < c.start + (c.out - c.in)) return c;
728
+ }
729
+ }
730
+ return null;
731
+ }
732
+ nextClipAfterTime(timeMs) {
733
+ let best = null;
734
+ for (const t of this.project.tracks) {
735
+ if (t.kind !== "video") continue;
736
+ for (const c of t.clips) {
737
+ if (c.start >= timeMs && (!best || c.start < best.start)) best = c;
738
+ }
739
+ }
740
+ return best;
741
+ }
742
+ totalDuration() {
743
+ let max = 0;
744
+ for (const t of this.project.tracks) {
745
+ if (t.kind !== "video") continue;
746
+ for (const c of t.clips) {
747
+ const e = c.start + (c.out - c.in);
748
+ if (e > max) max = e;
749
+ }
750
+ }
751
+ return max;
752
+ }
753
+ resizeCanvas() {
754
+ const rect = this.mount.getBoundingClientRect();
755
+ const dpr = window.devicePixelRatio || 1;
756
+ const w = Math.max(1, Math.floor(rect.width * dpr));
757
+ const h = Math.max(1, Math.floor(rect.height * dpr));
758
+ if (this.canvas.width !== w || this.canvas.height !== h) {
759
+ this.canvas.width = w;
760
+ this.canvas.height = h;
761
+ }
762
+ }
763
+ startTickLoop() {
764
+ this.lastFrameTs = performance.now();
765
+ const tick = (now) => {
766
+ this.resizeCanvas();
767
+ if (this.playing) {
768
+ const dtMs = now - this.lastFrameTs;
769
+ this.lastFrameTs = now;
770
+ this.advance(dtMs);
771
+ }
772
+ this.paint();
773
+ this.rafHandle = requestAnimationFrame(tick);
774
+ };
775
+ this.rafHandle = requestAnimationFrame(tick);
776
+ }
777
+ stopTickLoop() {
778
+ if (this.rafHandle != null) {
779
+ cancelAnimationFrame(this.rafHandle);
780
+ this.rafHandle = null;
781
+ }
782
+ }
783
+ advance(dtMs) {
784
+ if (this.project.tracks.length === 0) return;
785
+ this.timeMs += dtMs;
786
+ const totalDur = this.totalDuration();
787
+ if (this.timeMs >= totalDur) {
788
+ this.timeMs = totalDur;
789
+ this.onTimeUpdate?.(this.timeMs);
790
+ this.pause();
791
+ this.onEnded?.();
792
+ return;
793
+ }
794
+ const clip = this.clipAtTime(this.timeMs);
795
+ if (!clip) {
796
+ const next = this.nextClipAfterTime(this.timeMs);
797
+ if (next) {
798
+ this.timeMs = next.start;
799
+ this.activate(next);
800
+ this.seekVideoToClipOffset(next, 0);
801
+ const v = this.videos.get(next.sourceId);
802
+ if (v) void v.play().catch((err) => this.onError?.(err));
803
+ } else {
804
+ this.pause();
805
+ this.onEnded?.();
806
+ return;
807
+ }
808
+ } else if (clip.id !== this.currentClipId) {
809
+ this.activate(clip);
810
+ this.seekVideoToClipOffset(clip, this.timeMs - clip.start);
811
+ const v = this.videos.get(clip.sourceId);
812
+ if (v) void v.play().catch((err) => this.onError?.(err));
813
+ }
814
+ this.onTimeUpdate?.(this.timeMs);
815
+ }
816
+ /**
817
+ * One paint per rAF — clears the canvas, draws the current active
818
+ * video frame letterboxed to fit, then refreshes the HUD. Done
819
+ * unconditionally (not just on `playing`) so the HUD frame counter
820
+ * and the seek preview both update when paused.
821
+ */
822
+ paint() {
823
+ const cw = this.canvas.width;
824
+ const ch = this.canvas.height;
825
+ this.ctx.clearRect(0, 0, cw, ch);
826
+ const clip = this.currentClipId ? this.clipById(this.currentClipId) : null;
827
+ const v = clip ? this.videos.get(clip.sourceId) : null;
828
+ if (v && v.videoWidth > 0 && v.videoHeight > 0) {
829
+ const vw = v.videoWidth;
830
+ const vh = v.videoHeight;
831
+ const scale = Math.min(cw / vw, ch / vh);
832
+ const dw = vw * scale;
833
+ const dh = vh * scale;
834
+ const dx = (cw - dw) / 2;
835
+ const dy = (ch - dh) / 2;
836
+ this.ctx.drawImage(v, dx, dy, dw, dh);
837
+ this.paintedFrames += 1;
838
+ }
839
+ this.updateBadge();
840
+ }
841
+ updateBadge() {
842
+ if (!this.badge) return;
843
+ const sec = (this.timeMs / 1e3).toFixed(2);
844
+ this.badge.textContent = `engine: canvas compositor \u2022 t=${sec}s \u2022 frames painted: ${this.paintedFrames}`;
845
+ }
846
+ };
847
+ var canvasCompositorEngineFactory = (opts) => new CanvasCompositorEngine(opts);
518
848
 
519
849
  // src/ui/thumbnails.ts
520
850
  var THUMB_HEIGHT = 44;
@@ -574,11 +904,19 @@ var ThumbnailRibbon = class {
574
904
  /**
575
905
  * Paint thumbnails for the clip's visible window onto `ctx`. The
576
906
  * canvas is the per-clip strip — width = clip's px width, height =
577
- * THUMB_HEIGHT. Source-time range derives from the clip's `in/out`
578
- * and the px range we're drawing into.
907
+ * `pxHeight` (defaults to the cached `THUMB_HEIGHT`). Source-time
908
+ * range derives from the clip's `in/out` and the px range we're
909
+ * drawing into.
910
+ *
911
+ * `pxHeight` lets the caller stretch thumbs to fill a taller clip
912
+ * body when `trackHeight` is configured above the default. Aspect
913
+ * ratio is already broken per-thumb (we slice variable widths from a
914
+ * fixed-aspect cached bitmap), so stretching height too is fine — it
915
+ * preserves the "filmstrip" look without leaving an empty bottom
916
+ * band of the brand gradient showing through.
579
917
  */
580
- paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth) {
581
- ctx.clearRect(0, 0, pxWidth, THUMB_HEIGHT);
918
+ paintStrip(ctx, sourceId, sourceInMs, sourceOutMs, pxWidth, pxHeight = THUMB_HEIGHT) {
919
+ ctx.clearRect(0, 0, pxWidth, pxHeight);
582
920
  const st = this.sources.get(sourceId);
583
921
  if (!st) return;
584
922
  if (sourceOutMs <= sourceInMs || pxWidth <= 0) return;
@@ -591,10 +929,10 @@ var ThumbnailRibbon = class {
591
929
  const x = Math.round(i * pxWidth / count);
592
930
  const w = Math.round((i + 1) * pxWidth / count) - x;
593
931
  if (bmp) {
594
- ctx.drawImage(bmp, x, 0, w, THUMB_HEIGHT);
932
+ ctx.drawImage(bmp, x, 0, w, pxHeight);
595
933
  } else {
596
934
  ctx.fillStyle = "rgba(255,255,255,0.04)";
597
- ctx.fillRect(x, 0, w, THUMB_HEIGHT);
935
+ ctx.fillRect(x, 0, w, pxHeight);
598
936
  this.enqueue(st, bucket);
599
937
  }
600
938
  }
@@ -949,7 +1287,7 @@ function drawClipAt(ctx, clip, trackIndex, startMs, sources, state, style, thumb
949
1287
  roundRect(ctx, startX, y, widthPx, h, 6);
950
1288
  ctx.clip();
951
1289
  ctx.translate(startX, y);
952
- thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx);
1290
+ thumbs.paintStrip(ctx, clip.sourceId, clip.in, clip.out, widthPx, h);
953
1291
  ctx.restore();
954
1292
  ctx.strokeStyle = "rgba(255,255,255,0.2)";
955
1293
  ctx.lineWidth = 1;
@@ -1220,7 +1558,7 @@ function hitTest(x, y, ctx) {
1220
1558
  if (track2.clips.length === 0) {
1221
1559
  const btnSize = 18;
1222
1560
  const btnLeft = HEADER_WIDTH - btnSize - 6;
1223
- const btnTop = RULER_HEIGHT + ti2 * 56 + (56 - btnSize) / 2 - ctx.scrollTop;
1561
+ const btnTop = RULER_HEIGHT + ti2 * TRACK_HEIGHT + (TRACK_HEIGHT - btnSize) / 2 - ctx.scrollTop;
1224
1562
  if (x >= btnLeft && x <= btnLeft + btnSize && y >= btnTop && y <= btnTop + btnSize) {
1225
1563
  return { kind: "header-delete", trackIndex: ti2 };
1226
1564
  }
@@ -1520,7 +1858,6 @@ var Timeline = class _Timeline {
1520
1858
  const dpr = window.devicePixelRatio || 1;
1521
1859
  this.canvas.width = Math.floor(this.viewportWidth * dpr);
1522
1860
  this.canvas.height = Math.floor(this.viewportHeight * dpr);
1523
- this.canvas.style.height = `${this.viewportHeight}px`;
1524
1861
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1525
1862
  }
1526
1863
  computeFitScale() {
@@ -1937,7 +2274,8 @@ var Timeline = class _Timeline {
1937
2274
  const tiRaw = this.trackIndexAtY(y);
1938
2275
  const phantomIdx = this.project.tracks.length;
1939
2276
  const phantomScreenY = RULER_HEIGHT + phantomIdx * TRACK_HEIGHT - this.scrollTop;
1940
- const onPhantom = y >= phantomScreenY && y < phantomScreenY + TRACK_HEIGHT;
2277
+ const viewportBottom = this.viewportHeight - SCROLLBAR_THICKNESS;
2278
+ const onPhantom = y >= phantomScreenY && y < Math.max(phantomScreenY + TRACK_HEIGHT, viewportBottom);
1941
2279
  const intendedTrackIndex = onPhantom ? phantomIdx : tiRaw >= 0 ? tiRaw : drag.trackIndex;
1942
2280
  let ghostTrackIndex = intendedTrackIndex;
1943
2281
  let overlap = false;
@@ -2702,7 +3040,19 @@ var Editor = class _Editor {
2702
3040
  this.pxPerSec = clampScale2(opts.initialScale ?? DEFAULT_PX_PER_SEC);
2703
3041
  this.snap = opts.initialSnap !== false;
2704
3042
  this.locale = mergeLocale(opts.locale);
3043
+ if (opts.trackHeight != null || opts.rulerHeight != null) {
3044
+ setTimelineMetrics({
3045
+ ...opts.trackHeight != null ? { trackHeight: opts.trackHeight } : {},
3046
+ ...opts.rulerHeight != null ? { rulerHeight: opts.rulerHeight } : {}
3047
+ });
3048
+ }
2705
3049
  applyTheme(this.container, opts.theme);
3050
+ if (opts.timelineHeight != null && opts.timelineHeight > 0) {
3051
+ this.container.style.setProperty(
3052
+ "--aicut-timeline-height",
3053
+ `${Math.round(opts.timelineHeight)}px`
3054
+ );
3055
+ }
2706
3056
  this.ui = new EditorUI(this.container, this, {
2707
3057
  onPlayToggle: () => this.togglePlay(),
2708
3058
  onSplit: () => this.split(),
@@ -2720,7 +3070,11 @@ var Editor = class _Editor {
2720
3070
  onMoveClip: (id, opts2) => this.moveClip(id, opts2),
2721
3071
  onResizeClip: (id, edits) => this.resizeClip(id, edits)
2722
3072
  });
2723
- this.engine = new PlaybackEngine(this.ui.previewHost, this.project);
3073
+ const engineFactory = opts.playbackEngine ?? ((o) => new HtmlVideoEngine(o));
3074
+ this.engine = engineFactory({
3075
+ host: this.ui.previewHost,
3076
+ project: this.project
3077
+ });
2724
3078
  this.engine.onTimeUpdate = (ms) => {
2725
3079
  this.bus.emit("time", { timeMs: ms });
2726
3080
  this.ui.onTimeTick(ms);
@@ -3265,6 +3619,6 @@ function clampScale2(s) {
3265
3619
  return Math.max(MIN_PX_PER_SEC, Math.min(MAX_PX_PER_SEC, s));
3266
3620
  }
3267
3621
 
3268
- export { Editor, HEADER_WIDTH, RULER_HEIGHT, TRACK_HEIGHT, Timeline, createEmptyProject, createId, normalizeProject };
3622
+ export { CanvasCompositorEngine, Editor, HEADER_WIDTH, HtmlVideoEngine, RULER_HEIGHT, TRACK_HEIGHT, Timeline, canvasCompositorEngineFactory, createEmptyProject, createId, htmlVideoEngineFactory, normalizeProject, setTimelineMetrics };
3269
3623
  //# sourceMappingURL=index.js.map
3270
3624
  //# sourceMappingURL=index.js.map