@bycrux/editor 0.5.2 → 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.
@@ -6,6 +6,7 @@ import { getOverlayDesignCanvas } from '../design-canvas'
6
6
  import { useDragOverlay } from './useDragOverlay'
7
7
  import OverlayItemsLayer from './OverlayItemsLayer'
8
8
  import { useVideoPlayback } from './useVideoPlayback'
9
+ import { sourceCropVideoStyle } from './sourceCropStyle'
9
10
  import CarouselPreview from './CarouselPreview'
10
11
 
11
12
  // ---------------------------------------------------------------------------
@@ -42,6 +43,12 @@ export default function PreviewPlayer({
42
43
 
43
44
  const containerRef = useRef<HTMLDivElement>(null)
44
45
  const [renderScale, setRenderScale] = useState<number>(1)
46
+ // Frame pixel size — used to compute the sourceCrop CSS transform that mirrors
47
+ // render's crop→contain. Tracked alongside renderScale from the same observer.
48
+ const [frameSize, setFrameSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 })
49
+ // Intrinsic dims of the loaded source <video>, captured on loadedmetadata.
50
+ // Falls back to a clip's own sourceWidth/sourceHeight when present.
51
+ const [videoDims, setVideoDims] = useState<{ w: number; h: number } | null>(null)
45
52
 
46
53
  // Track container size to scale overlay components from 1080×1920 → preview size
47
54
  useEffect(() => {
@@ -49,6 +56,7 @@ export default function PreviewPlayer({
49
56
  if (!el) return
50
57
  const obs = new ResizeObserver(([entry]) => {
51
58
  setRenderScale(entry.contentRect.width / RENDER_W)
59
+ setFrameSize({ w: entry.contentRect.width, h: entry.contentRect.height })
52
60
  })
53
61
  obs.observe(el)
54
62
  return () => obs.disconnect()
@@ -81,6 +89,36 @@ export default function PreviewPlayer({
81
89
 
82
90
  const captionTrack = useMemo(() => project.captions, [project])
83
91
 
92
+ // ── sourceCrop reflection ───────────────────────────────────────────────────
93
+ // Mirror render's crop→contain so the preview frames the clip the way the
94
+ // final output will. The active clip is the one whose [start, end) contains the
95
+ // playhead (same selection the playback hook uses internally). Only the active
96
+ // <video> slot is opaque, so applying the active clip's crop to both slots is
97
+ // safe — the inactive slot is invisible.
98
+ const activeClip = useMemo(
99
+ () => clips.find(c => currentTime >= c.start && currentTime < c.end) ?? clips[clips.length - 1],
100
+ [clips, currentTime],
101
+ )
102
+ const cropStyle = useMemo(() => {
103
+ const crop = activeClip?.sourceCrop
104
+ if (!crop) return null
105
+ const sw = activeClip?.sourceWidth ?? videoDims?.w
106
+ const sh = activeClip?.sourceHeight ?? videoDims?.h
107
+ if (!sw || !sh || !frameSize.w || !frameSize.h) return null
108
+ return sourceCropVideoStyle({
109
+ crop,
110
+ sourceWidth: sw,
111
+ sourceHeight: sh,
112
+ frameWidth: frameSize.w,
113
+ frameHeight: frameSize.h,
114
+ })
115
+ }, [activeClip, videoDims, frameSize])
116
+
117
+ // The default full-frame style (no crop). object-contain letterboxes the source.
118
+ const baseVideoStyle = cropStyle
119
+ ? { ...cropStyle }
120
+ : { position: 'absolute' as const, inset: 0, width: '100%', height: '100%', objectFit: 'contain' as const }
121
+
84
122
  return (
85
123
  <div ref={containerRef} className="relative bg-black h-full max-w-full overflow-hidden rounded" style={{ aspectRatio: `${RENDER_W} / ${RENDER_H}`, isolation: 'isolate' }}>
86
124
  {isCanvasProject ? (
@@ -94,24 +132,24 @@ export default function PreviewPlayer({
94
132
  {/* Slot 0 */}
95
133
  <video
96
134
  ref={video0Ref}
97
- className="absolute inset-0 w-full h-full object-contain"
135
+ onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
98
136
  onTimeUpdate={() => { if (activeSlotRef.current === 0) handleTimeUpdate() }}
99
137
  onEnded={() => { if (activeSlotRef.current === 0) handleEnded() }}
100
138
  onPlay={() => { if (activeSlotRef.current === 0) setIsPlaying(true) }}
101
139
  onPause={() => { if (activeSlotRef.current === 0) handlePause() }}
102
140
  playsInline
103
- style={{ opacity: showVideo && activeSlot === 0 ? 1 : 0, pointerEvents: activeSlot === 0 ? 'auto' : 'none', zIndex: activeSlot === 0 ? 1 : 0 }}
141
+ style={{ ...baseVideoStyle, opacity: showVideo && activeSlot === 0 ? 1 : 0, pointerEvents: activeSlot === 0 ? 'auto' : 'none', zIndex: activeSlot === 0 ? 1 : 0 }}
104
142
  />
105
143
  {/* Slot 1 */}
106
144
  <video
107
145
  ref={video1Ref}
108
- className="absolute inset-0 w-full h-full object-contain"
146
+ onLoadedMetadata={(e) => { const v = e.currentTarget; if (v.videoWidth && v.videoHeight) setVideoDims({ w: v.videoWidth, h: v.videoHeight }) }}
109
147
  onTimeUpdate={() => { if (activeSlotRef.current === 1) handleTimeUpdate() }}
110
148
  onEnded={() => { if (activeSlotRef.current === 1) handleEnded() }}
111
149
  onPlay={() => { if (activeSlotRef.current === 1) setIsPlaying(true) }}
112
150
  onPause={() => { if (activeSlotRef.current === 1) handlePause() }}
113
151
  playsInline
114
- style={{ opacity: showVideo && activeSlot === 1 ? 1 : 0, pointerEvents: activeSlot === 1 ? 'auto' : 'none', zIndex: activeSlot === 1 ? 1 : 0 }}
152
+ style={{ ...baseVideoStyle, opacity: showVideo && activeSlot === 1 ? 1 : 0, pointerEvents: activeSlot === 1 ? 'auto' : 'none', zIndex: activeSlot === 1 ? 1 : 0 }}
115
153
  />
116
154
  </>
117
155
  )}
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { sourceCropVideoStyle } from '../sourceCropStyle'
3
+
4
+ describe('sourceCropVideoStyle', () => {
5
+ it('returns null for a full-frame (default) crop', () => {
6
+ expect(
7
+ sourceCropVideoStyle({
8
+ crop: { x: 0, y: 0, w: 1, h: 1 },
9
+ sourceWidth: 1920, sourceHeight: 1080,
10
+ frameWidth: 1080, frameHeight: 1920,
11
+ }),
12
+ ).toBeNull()
13
+ })
14
+
15
+ it('returns null without source dims', () => {
16
+ expect(
17
+ sourceCropVideoStyle({
18
+ crop: { x: 0.25, y: 0, w: 0.5, h: 1 },
19
+ sourceWidth: 0, sourceHeight: 0,
20
+ frameWidth: 1080, frameHeight: 1920,
21
+ }),
22
+ ).toBeNull()
23
+ })
24
+
25
+ it('center vertical-strip crop of a landscape source fills a portrait frame', () => {
26
+ // 1920x1080 source, crop the centre 50% width / full height → 960x1080 region
27
+ // (aspect 0.888...). Portrait frame 1080x1920 (aspect 0.5625). Crop aspect >
28
+ // frame aspect → crop fills frame WIDTH, letterboxed vertically.
29
+ const style = sourceCropVideoStyle({
30
+ crop: { x: 0.25, y: 0, w: 0.5, h: 1 },
31
+ sourceWidth: 1920, sourceHeight: 1080,
32
+ frameWidth: 1080, frameHeight: 1920,
33
+ })!
34
+ expect(style).not.toBeNull()
35
+ // videoWRatio = cropWRatio(1) / crop.w(0.5) = 2 → 200% wide
36
+ expect(style.width).toBe('200%')
37
+ // cropAspect = (1920*0.5)/(1080*1) = 0.8889; frameAspect = 0.5625
38
+ // cropHRatio = frameAspect/cropAspect = 0.6328; videoHRatio = /h(1) = 0.6328
39
+ expect(parseFloat(style.height as string)).toBeCloseTo(63.28, 1)
40
+ // leftRatio = (1-1)/2 - 0.25*2 = -0.5 → -50%
41
+ expect(style.left).toBe('-50%')
42
+ // topRatio = (1-0.6328)/2 - 0*... = 0.1836 → ~18.36%
43
+ expect(parseFloat(style.top as string)).toBeCloseTo(18.36, 1)
44
+ expect(style.objectFit).toBe('fill')
45
+ })
46
+
47
+ it('crop region matching frame aspect fills the frame exactly (no letterbox)', () => {
48
+ // Portrait source 1080x1920, crop a centred 9:16 sub-rect → same aspect as a
49
+ // 1080x1920 frame. Should fill edge-to-edge.
50
+ const style = sourceCropVideoStyle({
51
+ crop: { x: 0.1, y: 0.1, w: 0.8, h: 0.8 },
52
+ sourceWidth: 1080, sourceHeight: 1920,
53
+ frameWidth: 1080, frameHeight: 1920,
54
+ })!
55
+ expect(style.width).toBe('125%') // 1/0.8
56
+ expect(style.height).toBe('125%') // 1/0.8
57
+ // left/top = (1-1)/2 - 0.1*1.25 = -0.125 → -12.5%
58
+ expect(style.left).toBe('-12.5%')
59
+ expect(style.top).toBe('-12.5%')
60
+ })
61
+ })
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { effectiveInPoint, effectiveOutPoint } from '../useVideoPlayback'
3
+
4
+ // These helpers mirror render's collectAllItems rebase (render.js): when the
5
+ // preview loads a normalizedSrc window cache (which starts at 0 and covers
6
+ // [inPoint, outPoint] of the original), the effective seek inPoint is 0 and the
7
+ // outPoint is rebased to the window length. nobg_preview_src is the full source
8
+ // and must NOT rebase.
9
+
10
+ describe('effectiveInPoint', () => {
11
+ it('rebases to 0 when normalizedSrc is the chosen src', () => {
12
+ expect(effectiveInPoint({ inPoint: 496.92, normalizedSrc: '/cache/window.mp4' })).toBe(0)
13
+ })
14
+
15
+ it('returns clip.inPoint when there is no normalizedSrc', () => {
16
+ expect(effectiveInPoint({ inPoint: 496.92, src: '/orig.mov' })).toBe(496.92)
17
+ })
18
+
19
+ it('does NOT rebase when nobg_preview_src takes precedence over normalizedSrc', () => {
20
+ // nobg_preview_src wins in playbackSrcFor and covers the full source.
21
+ expect(
22
+ effectiveInPoint({ inPoint: 496.92, nobg_preview_src: '/nobg.webm', normalizedSrc: '/cache/window.mp4' }),
23
+ ).toBe(496.92)
24
+ })
25
+
26
+ it('defaults to 0 when inPoint is absent and no cache', () => {
27
+ expect(effectiveInPoint({ src: '/orig.mov' })).toBe(0)
28
+ })
29
+ })
30
+
31
+ describe('effectiveOutPoint', () => {
32
+ it('rebases to the window length (outPoint - inPoint) for a normalizedSrc cache', () => {
33
+ // original inPoint 496.92, outPoint 514.92 → 18s window cache
34
+ expect(
35
+ effectiveOutPoint({ inPoint: 496.92, outPoint: 514.92, normalizedSrc: '/cache/window.mp4' }),
36
+ ).toBeCloseTo(18, 5)
37
+ })
38
+
39
+ it('returns the stored outPoint when there is no cache', () => {
40
+ expect(effectiveOutPoint({ inPoint: 496.92, outPoint: 514.92, src: '/orig.mov' })).toBe(514.92)
41
+ })
42
+
43
+ it('does NOT rebase when nobg_preview_src takes precedence', () => {
44
+ expect(
45
+ effectiveOutPoint({ inPoint: 496.92, outPoint: 514.92, nobg_preview_src: '/nobg.webm', normalizedSrc: '/c.mp4' }),
46
+ ).toBe(514.92)
47
+ })
48
+
49
+ it('returns undefined when no outPoint is stored', () => {
50
+ expect(effectiveOutPoint({ inPoint: 496.92, normalizedSrc: '/cache/window.mp4' })).toBeUndefined()
51
+ })
52
+ })
@@ -0,0 +1,70 @@
1
+ // Pure CSS-math for reflecting a clip's `sourceCrop` in the preview <video>.
2
+ //
3
+ // Render (montaj_assets/render/encode-segment.js:buildVideoItemFilterParts)
4
+ // applies sourceCrop as an ffmpeg `crop=cw:ch:cx:cy` BEFORE the
5
+ // `scale=...:force_original_aspect_ratio=decrease` + `pad` step — i.e. it crops
6
+ // the source to the sub-rect, then CONTAIN-fits that sub-rect into the output
7
+ // frame (letterboxing if the sub-rect's aspect differs from the frame).
8
+ //
9
+ // This helper produces the equivalent CSS for a <video> sitting in an
10
+ // overflow-hidden frame box (the player surface, fixed at the output aspect):
11
+ // size the video larger than the frame and translate it so only the crop
12
+ // sub-rect shows, scaled to contain within the frame. All values are ratios of
13
+ // the frame's own dimensions, so the result is frame-pixel-size-independent and
14
+ // can be expressed purely in `%`.
15
+ //
16
+ // Requires the source's intrinsic pixel dims (to know the crop region's aspect
17
+ // ratio). Without them — or without a crop — returns null and the caller keeps
18
+ // the default `object-contain` full-frame behavior.
19
+
20
+ import type { CSSProperties } from 'react'
21
+
22
+ export interface SourceCropInput {
23
+ crop: { x: number; y: number; w: number; h: number }
24
+ sourceWidth: number
25
+ sourceHeight: number
26
+ frameWidth: number
27
+ frameHeight: number
28
+ }
29
+
30
+ export function sourceCropVideoStyle(input: SourceCropInput): CSSProperties | null {
31
+ const { crop, sourceWidth, sourceHeight, frameWidth, frameHeight } = input
32
+ if (!sourceWidth || !sourceHeight || !frameWidth || !frameHeight) return null
33
+ if (!crop || crop.w <= 0 || crop.h <= 0) return null
34
+ // A full-frame crop (the default) needs no special handling.
35
+ if (crop.x === 0 && crop.y === 0 && crop.w === 1 && crop.h === 1) return null
36
+
37
+ const frameAspect = frameWidth / frameHeight
38
+ const cropAspect = (sourceWidth * crop.w) / (sourceHeight * crop.h)
39
+
40
+ // Contain-fit the crop region into the frame → its displayed size as a ratio
41
+ // of the frame's own dimensions.
42
+ let cropWRatio: number
43
+ let cropHRatio: number
44
+ if (cropAspect >= frameAspect) {
45
+ cropWRatio = 1
46
+ cropHRatio = frameAspect / cropAspect
47
+ } else {
48
+ cropHRatio = 1
49
+ cropWRatio = cropAspect / frameAspect
50
+ }
51
+
52
+ // The full (uncropped) video's displayed size as a ratio of the frame.
53
+ const videoWRatio = cropWRatio / crop.w
54
+ const videoHRatio = cropHRatio / crop.h
55
+
56
+ // Position the video so the crop sub-rect's top-left aligns, then center the
57
+ // contained region within the frame (the letterbox offset).
58
+ const leftRatio = (1 - cropWRatio) / 2 - crop.x * videoWRatio
59
+ const topRatio = (1 - cropHRatio) / 2 - crop.y * videoHRatio
60
+
61
+ return {
62
+ position: 'absolute',
63
+ width: `${videoWRatio * 100}%`,
64
+ height: `${videoHRatio * 100}%`,
65
+ left: `${leftRatio * 100}%`,
66
+ top: `${topRatio * 100}%`,
67
+ objectFit: 'fill',
68
+ maxWidth: 'none',
69
+ }
70
+ }
@@ -26,8 +26,47 @@ interface MontajVideoElement extends HTMLVideoElement {
26
26
  * Note: `nobg_src` is the ProRes 4444 render-only artifact and is NEVER
27
27
  * loaded into a `<video>` element — browsers can't decode ProRes.
28
28
  */
29
- function playbackSrcFor(clip: { src?: string; nobg_preview_src?: string }): string {
30
- return clip.nobg_preview_src ?? clip.src ?? ''
29
+ function playbackSrcFor(clip: { src?: string; nobg_preview_src?: string; normalizedSrc?: string }): string {
30
+ return clip.nobg_preview_src ?? clip.normalizedSrc ?? clip.src ?? ''
31
+ }
32
+
33
+ /**
34
+ * The inPoint the preview should SEEK to for this clip, accounting for the
35
+ * normalizedSrc cache rebase.
36
+ *
37
+ * A `normalizedSrc` cache covers exactly [inPoint, outPoint] of the original
38
+ * and STARTS AT 0 (it is only `outPoint - inPoint` seconds long). When
39
+ * `playbackSrcFor` chooses that cache as the playback src, seeking to the
40
+ * original `clip.inPoint` (e.g. 496.92) would land far past the end of the
41
+ * short file → the browser clamps to EOF and the preview freezes on the last
42
+ * frame. So when the cache is the chosen src, the effective inPoint is 0 and
43
+ * the window maps to [0, outPoint - inPoint].
44
+ *
45
+ * This mirrors render's `collectAllItems` (montaj_assets/render/render.js),
46
+ * which substitutes `item.normalizedSrc` as the src AND rebases inPoint to 0.
47
+ *
48
+ * The rebase applies ONLY when the cache is actually the chosen src.
49
+ * `nobg_preview_src` takes precedence in `playbackSrcFor` and is NOT a window
50
+ * cache (it covers the full source), so it keeps the original inPoint — exactly
51
+ * as render's nobg path does.
52
+ */
53
+ export function effectiveInPoint(clip: { inPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number {
54
+ const usingNormalizedCache = !clip.nobg_preview_src && !!clip.normalizedSrc
55
+ return usingNormalizedCache ? 0 : (clip.inPoint ?? 0)
56
+ }
57
+
58
+ /**
59
+ * The outPoint in the loaded src's own timeline. For a normalizedSrc cache the
60
+ * stored `clip.outPoint` is in ORIGINAL-source coordinates (e.g. 514.92) while
61
+ * the cache plays in [0, outPoint - inPoint]; the boundary/loop checks compare
62
+ * against `video.currentTime` (cache time), so the outPoint must be rebased to
63
+ * the window length. Returns undefined when no outPoint is stored, so callers
64
+ * keep their existing fallback (clip.end - clip.start + effectiveInPoint).
65
+ */
66
+ export function effectiveOutPoint(clip: { inPoint?: number; outPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number | undefined {
67
+ if (clip.outPoint == null) return undefined
68
+ const usingNormalizedCache = !clip.nobg_preview_src && !!clip.normalizedSrc
69
+ return usingNormalizedCache ? clip.outPoint - (clip.inPoint ?? 0) : clip.outPoint
31
70
  }
32
71
 
33
72
  export function useVideoPlayback(
@@ -437,7 +476,7 @@ export function useVideoPlayback(
437
476
  setActiveSlot(0)
438
477
  preloadSrcRef.current = ''
439
478
  video.src = fileUrlRef.current(playbackSrcFor(clips[0]))
440
- video.currentTime = clips[0].inPoint ?? 0
479
+ video.currentTime = effectiveInPoint(clips[0])
441
480
  applyClipVolume(clips[0])
442
481
  // Clear inactive slot
443
482
  const inactive = getInactiveVideo()
@@ -488,7 +527,7 @@ export function useVideoPlayback(
488
527
  activeIdxRef.current = ni
489
528
  if (nv) {
490
529
  const src = fileUrlRef.current(playbackSrcFor(nc))
491
- if (preloadSrcRef.current !== src) { nv.src = src; nv.currentTime = nc.inPoint ?? 0 }
530
+ if (preloadSrcRef.current !== src) { nv.src = src; nv.currentTime = effectiveInPoint(nc) }
492
531
  const gain = ensureVideoGain(ns)
493
532
  if (gain) gain.gain.value = nc.muted ? 0 : (nc.volume ?? 1)
494
533
  nv.play().catch(() => {})
@@ -542,9 +581,10 @@ export function useVideoPlayback(
542
581
  if (inactive) { inactive.pause(); inactive.removeAttribute('src') }
543
582
  }
544
583
  applyClipVolume(clip)
545
- const inPoint = clip.inPoint ?? 0
546
- if (clip.loop && clip.outPoint) {
547
- const loopDur = clip.outPoint - inPoint
584
+ const inPoint = effectiveInPoint(clip)
585
+ const clipOutPoint = effectiveOutPoint(clip)
586
+ if (clip.loop && clipOutPoint != null) {
587
+ const loopDur = clipOutPoint - inPoint
548
588
  const elapsed = currentTime - clip.start
549
589
  const loops = Math.floor(elapsed / loopDur)
550
590
  loopOffsetRef.current = loops * loopDur
@@ -577,7 +617,8 @@ export function useVideoPlayback(
577
617
  const clip = clips[activeIdxRef.current]
578
618
  if (!clip) return
579
619
 
580
- const outPoint = clip.outPoint ?? clip.end - clip.start + (clip.inPoint ?? 0)
620
+ const clipInPoint = effectiveInPoint(clip)
621
+ const outPoint = effectiveOutPoint(clip) ?? clip.end - clip.start + clipInPoint
581
622
 
582
623
  // Preload next clip into inactive slot ~1s before end
583
624
  const timeLeft = outPoint - video.currentTime
@@ -589,7 +630,7 @@ export function useVideoPlayback(
589
630
  if (inactiveVideo && preloadSrcRef.current !== nextSrc) {
590
631
  preloadSrcRef.current = nextSrc
591
632
  inactiveVideo.src = nextSrc
592
- inactiveVideo.currentTime = clips[nextIdx].inPoint ?? 0
633
+ inactiveVideo.currentTime = effectiveInPoint(clips[nextIdx])
593
634
  const inactiveSlot = (1 - slot) as 0 | 1
594
635
  const nextGain = ensureVideoGain(inactiveSlot)
595
636
  if (nextGain) nextGain.gain.value = clips[nextIdx].muted ? 0 : (clips[nextIdx].volume ?? 1)
@@ -599,12 +640,12 @@ export function useVideoPlayback(
599
640
 
600
641
  if (video.currentTime >= outPoint) {
601
642
  if (clip.loop) {
602
- const projectT = clip.start + loopOffsetRef.current + (video.currentTime - (clip.inPoint ?? 0))
643
+ const projectT = clip.start + loopOffsetRef.current + (video.currentTime - clipInPoint)
603
644
  if (projectT < clip.end) {
604
645
  // Still within the clip's project window — loop the source video
605
- const loopDur = outPoint - (clip.inPoint ?? 0)
646
+ const loopDur = outPoint - clipInPoint
606
647
  loopOffsetRef.current += loopDur
607
- video.currentTime = clip.inPoint ?? 0
648
+ video.currentTime = clipInPoint
608
649
  return
609
650
  }
610
651
  // Project end reached — fall through to the stop/next-clip logic below
@@ -639,7 +680,7 @@ export function useVideoPlayback(
639
680
  const nextSrc = fileUrlRef.current(playbackSrcFor(next))
640
681
  if (preloadSrcRef.current !== nextSrc) {
641
682
  nextVideo.src = nextSrc
642
- nextVideo.currentTime = next.inPoint ?? 0
683
+ nextVideo.currentTime = effectiveInPoint(next)
643
684
  }
644
685
  const nextGain = ensureVideoGain(nextSlot)
645
686
  if (nextGain) nextGain.gain.value = next.muted ? 0 : (next.volume ?? 1)
@@ -674,7 +715,7 @@ export function useVideoPlayback(
674
715
  return
675
716
  }
676
717
 
677
- const t = clip.start + loopOffsetRef.current + (video.currentTime - (clip.inPoint ?? 0))
718
+ const t = clip.start + loopOffsetRef.current + (video.currentTime - clipInPoint)
678
719
 
679
720
  // For looping clips, stop when project time reaches clip.end mid-loop
680
721
  if (clip.loop && t >= clip.end) {
@@ -47,10 +47,14 @@ interface TimelineProps {
47
47
  * regenQueue, storyboard, and onSave — none of which the package types know.
48
48
  * Absent → the subcut tool is simply not rendered. */
49
49
  renderSubcutRegen?: (ctx: { clipId: string; onClose: () => void }) => ReactNode
50
+ /** Opens the caption-regeneration modal. Threaded down to TranscriptPanel.
51
+ * Provided only when the host adapter supports `generateCaptions`; absent →
52
+ * the "Regenerate" button is hidden. */
53
+ onRegenerateCaptions?: () => void
50
54
  }
51
55
 
52
56
 
53
- export default function Timeline({ project, currentTime, onTimeUpdate, onProjectChange, onCaptionEdit, onOverlayEdit, selectedIds = [], onSelectIds, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen }: TimelineProps) {
57
+ export default function Timeline({ project, currentTime, onTimeUpdate, onProjectChange, onCaptionEdit, onOverlayEdit, selectedIds = [], onSelectIds, onSplit, onCut, onInspectClip, onInspectAudio, rippleMode = false, getWaveformChunks, resolveFilePath, regenEnabled, isClipQueued, renderSubcutRegen, onRegenerateCaptions }: TimelineProps) {
54
58
  const primarySelectedId = selectedIds[0] ?? null
55
59
 
56
60
  // Click/shift-click handler — additive selection on shift or meta (cmd/ctrl).
@@ -355,6 +359,7 @@ export default function Timeline({ project, currentTime, onTimeUpdate, onProject
355
359
  onCaptionEdit={onCaptionEdit}
356
360
  onProjectChange={onProjectChange}
357
361
  onExpand={() => setTranscriptModalOpen(true)}
362
+ onRegenerateCaptions={onRegenerateCaptions}
358
363
  />
359
364
 
360
365
  {/* ── Transcript modal ── */}
@@ -10,9 +10,12 @@ interface TranscriptPanelProps {
10
10
  onCaptionEdit?: (project: Project) => void
11
11
  onProjectChange?: (project: Project) => void
12
12
  onExpand: () => void
13
+ /** Opens the caption-regeneration modal. Provided only when the host adapter
14
+ * supports `generateCaptions`; absent → the "Regenerate" button is hidden. */
15
+ onRegenerateCaptions?: () => void
13
16
  }
14
17
 
15
- export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand }: TranscriptPanelProps) {
18
+ export default function TranscriptPanel({ project, captionTrack, currentTime, onCaptionEdit, onProjectChange, onExpand, onRegenerateCaptions }: TranscriptPanelProps) {
16
19
  const segs = captionTrack?.segments ?? []
17
20
  // Find active segment index
18
21
  const activeIdx = segs.findIndex(s => currentTime >= s.start && currentTime < s.end)
@@ -49,6 +52,14 @@ export default function TranscriptPanel({ project, captionTrack, currentTime, on
49
52
  </button>
50
53
  )
51
54
  })}
55
+ {onRegenerateCaptions && (
56
+ <button
57
+ className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"
58
+ onClick={() => onRegenerateCaptions?.()}
59
+ >
60
+ Regenerate
61
+ </button>
62
+ )}
52
63
  {segs.length > 0 && (
53
64
  <button
54
65
  className="text-[10px] text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white border border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded px-2 py-0.5 transition-all"