@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.
- package/package.json +1 -1
- package/src/crop/VideoSourceCropOverlay.tsx +249 -0
- package/src/crop/__tests__/VideoSourceCropOverlay.test.tsx +105 -0
- package/src/crop/__tests__/crop-math.test.ts +15 -0
- package/src/index.ts +2 -0
- package/src/schema.ts +6 -1
- package/src/types.ts +50 -0
- package/src/video/CaptionRegenModal.tsx +177 -0
- package/src/video/RenderModal.tsx +21 -21
- package/src/video/VersionPanel.tsx +11 -11
- package/src/video/VideoEditor.tsx +148 -37
- package/src/video/__tests__/CaptionRegenModal.test.tsx +65 -0
- package/src/video/preview/PreviewPlayer.tsx +42 -4
- package/src/video/preview/__tests__/sourceCropStyle.test.ts +61 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +52 -0
- package/src/video/preview/sourceCropStyle.ts +70 -0
- package/src/video/preview/useVideoPlayback.ts +55 -14
- package/src/video/timeline/Timeline.tsx +6 -1
- package/src/video/timeline/TranscriptPanel.tsx +12 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
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
|
|
546
|
-
|
|
547
|
-
|
|
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
|
|
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]
|
|
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 -
|
|
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 -
|
|
646
|
+
const loopDur = outPoint - clipInPoint
|
|
606
647
|
loopOffsetRef.current += loopDur
|
|
607
|
-
video.currentTime =
|
|
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
|
|
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 -
|
|
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"
|