diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 33c5ccf7..a6410fc7 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -178,6 +178,8 @@ interface Window { whisperExecutablePath?: string; whisperModelPath: string; language?: string; + durationMs?: number; + startTimeMs?: number; }) => Promise<{ success: boolean; cues?: CaptionCue[]; @@ -303,6 +305,7 @@ interface Window { cancelCountdown: () => Promise<{ success: boolean }>; getActiveCountdown: () => Promise<{ success: boolean; seconds: number | null }>; onAutoCaptionProgress: (callback: (payload: { progress: number }) => void) => () => void; + onAutoCaptionChunk: (callback: (payload: { cues: CaptionCue[] }) => void) => () => void; onCountdownTick: (callback: (seconds: number) => void) => () => void; }; } diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 4c6ac7ca..f159ccad 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1473,6 +1473,8 @@ async function extractCaptionAudioSource(options: { videoPath: string ffmpegPath: string wavPath: string + startTime?: number // in seconds + duration?: number // in seconds }) { const candidates = await resolveCaptionAudioCandidates(options.videoPath) const attemptedCandidates: Array<{ @@ -1486,10 +1488,20 @@ async function extractCaptionAudioSource(options: { for (const candidate of candidates) { try { await ensureReadableFile(candidate.path, 'video file') - console.log('[auto-captions] Extracting audio from:', candidate.path) + console.log('[auto-captions] Extracting audio from:', candidate.path, options.startTime ? `at ${options.startTime}s` : '') + + const ffmpegArgs = ['-y']; + if (options.startTime !== undefined) { + ffmpegArgs.push('-ss', options.startTime.toString()); + } + if (options.duration !== undefined) { + ffmpegArgs.push('-t', options.duration.toString()); + } + ffmpegArgs.push('-i', candidate.path, '-map', '0:a:0', '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', options.wavPath); + await execFileAsync( options.ffmpegPath, - ['-y', '-i', candidate.path, '-map', '0:a:0', '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', options.wavPath], + ffmpegArgs, { timeout: 5 * 60 * 1000, maxBuffer: 20 * 1024 * 1024 }, ) console.log('[auto-captions] Audio extracted successfully to:', options.wavPath) @@ -1518,6 +1530,8 @@ async function generateAutoCaptionsFromVideo( whisperExecutablePath?: string; whisperModelPath: string; language?: string; + durationMs?: number; + startTimeMs?: number; }, ) { const ffmpegPath = getFfmpegBinaryPath() @@ -1531,79 +1545,130 @@ async function generateAutoCaptionsFromVideo( await ensureReadableFile(whisperExecutablePath, 'whisper executable') await ensureReadableFile(whisperModelPath, 'whisper model') - console.log('[auto-captions] Starting caption generation sequence') + // Constants for segmentation + const CHUNK_SIZE_MS = 5 * 60 * 1000; // 5 minutes + const OVERLAP_MS = 10 * 1000; // 10 seconds overlap for word boundaries + + const startTimeMs = options.startTimeMs || 0; + const totalDurationMs = options.durationMs || 0; + const endTimeMs = totalDurationMs > 0 ? startTimeMs + totalDurationMs : Infinity; + + console.log('[auto-captions] Starting segmented caption generation sequence') console.log('[auto-captions] Video:', normalizedVideoPath) - console.log('[auto-captions] Runtime:', whisperExecutablePath) - console.log('[auto-captions] Model:', whisperModelPath) - console.log('[auto-captions] Language:', options.language || 'auto') + console.log('[auto-captions] Range:', `${(startTimeMs/1000).toFixed(2)}s - ${totalDurationMs ? `${((startTimeMs + totalDurationMs)/1000).toFixed(2)}s` : 'End'}`) - const tempBase = path.join(app.getPath('temp'), `recordly-captions-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) - const wavPath = `${tempBase}.wav` - const outputBase = `${tempBase}-whisper` - const srtPath = `${outputBase}.srt` - const jsonPath = `${outputBase}.json` + const allCues: any[] = []; + let chunkCount = 1; + if (totalDurationMs > 0) { + chunkCount = Math.ceil(totalDurationMs / CHUNK_SIZE_MS); + } - try { - const audioSource = await extractCaptionAudioSource({ - videoPath: normalizedVideoPath, - ffmpegPath, - wavPath, - }) + let audioSourceLabel = 'Unknown'; - const language = options.language && options.language.trim() ? options.language.trim() : 'auto' - const whisperBaseArgs = [ - '-m', whisperModelPath, - '-f', wavPath, - '-osrt', - '-of', outputBase, - '-l', language, - '-np', - ] + for (let offsetMs = startTimeMs; offsetMs < endTimeMs; offsetMs += CHUNK_SIZE_MS) { + const chunkIndex = Math.floor((offsetMs - startTimeMs) / CHUNK_SIZE_MS); + const tempBase = path.join(app.getPath('temp'), `recordly-captions-chunk-${chunkIndex}-${Date.now()}`) + const wavPath = `${tempBase}.wav` + const outputBase = `${tempBase}-whisper` + const srtPath = `${outputBase}.srt` + const jsonPath = `${outputBase}.json` - let jsonEnabled = true try { - console.log('[auto-captions] Running Whisper with JSON output...') - await runWhisperWithProgress(whisperExecutablePath, [...whisperBaseArgs, '-ojf'], (progress) => { - webContents.send('auto-caption-progress', { progress }) + console.log(`[auto-captions] Processing chunk ${chunkIndex + 1}/${chunkCount || '?'} at offset ${offsetMs / 1000}s`) + + const audioSource = await extractCaptionAudioSource({ + videoPath: normalizedVideoPath, + ffmpegPath, + wavPath, + startTime: offsetMs / 1000, + duration: (CHUNK_SIZE_MS + OVERLAP_MS) / 1000 }) - console.log('[auto-captions] Whisper JSON output generated.') - } catch (error) { - if (!shouldRetryWhisperWithoutJson(error)) { - throw error + audioSourceLabel = audioSource.label; + + const language = options.language && options.language.trim() ? options.language.trim() : 'auto' + const whisperBaseArgs = [ + '-m', whisperModelPath, + '-f', wavPath, + '-osrt', + '-of', outputBase, + '-l', language, + '-np', + ] + + let jsonEnabled = true + const updateChunkProgress = (progress: number) => { + if (totalDurationMs > 0) { + const totalProgress = (offsetMs / totalDurationMs * 100) + (progress / (totalDurationMs / CHUNK_SIZE_MS)); + webContents.send('auto-caption-progress', { progress: Math.min(99, totalProgress) }) + } else { + webContents.send('auto-caption-progress', { progress }) + } + }; + + try { + await runWhisperWithProgress(whisperExecutablePath, [...whisperBaseArgs, '-ojf'], updateChunkProgress) + } catch (error) { + if (!shouldRetryWhisperWithoutJson(error)) throw error + jsonEnabled = false + console.warn(`[auto-captions] Whisper runtime error, retrying with SRT: ${error}`) + await runWhisperWithProgress(whisperExecutablePath, whisperBaseArgs, updateChunkProgress) } - jsonEnabled = false - console.warn('[auto-captions] Whisper runtime does not support JSON full output, retrying with SRT only:', error) - console.log('[auto-captions] Running Whisper with SRT output...') - await runWhisperWithProgress(whisperExecutablePath, whisperBaseArgs, (progress) => { - webContents.send('auto-caption-progress', { progress }) - }) - console.log('[auto-captions] Whisper SRT output generated.') - } + let cues = jsonEnabled + ? parseWhisperJsonCues(await fs.readFile(jsonPath, 'utf-8')) + : parseSrtCues(await fs.readFile(srtPath, 'utf-8')) + + if (cues.length === 0 && !jsonEnabled) { + // If JSON failed, SRT might be empty or not yet read? + try { cues = parseSrtCues(await fs.readFile(srtPath, 'utf-8')); } catch { /* ignore */ } + } - const timedCues = jsonEnabled - ? parseWhisperJsonCues(await fs.readFile(jsonPath, 'utf-8')) - : [] - const cues = timedCues.length > 0 - ? timedCues - : parseSrtCues(await fs.readFile(srtPath, 'utf-8')) - if (cues.length === 0) { - console.error('[auto-captions] No cues were parsed from Whisper output.') - throw new Error('Whisper completed, but no caption cues were produced.') - } + // Adjust timings and deduplicate + const adjustedCues = cues + .map(cue => ({ + ...cue, + startMs: cue.startMs + offsetMs, + endMs: cue.endMs + offsetMs + })) + // Only keep cues that START within this chunk's main window (prevent overlap duplicates) + // Except for the very last chunk where we take everything + .filter(cue => { + const isLastChunk = totalDurationMs > 0 && (offsetMs + CHUNK_SIZE_MS >= totalDurationMs); + if (isLastChunk) return true; + return cue.startMs < offsetMs + CHUNK_SIZE_MS; + }); - console.log(`[auto-captions] Successfully generated ${cues.length} cues.`) + if (adjustedCues.length > 0) { + console.log(`[auto-captions] Chunk ${chunkIndex + 1} produced ${adjustedCues.length} adjusted cues.`) + allCues.push(...adjustedCues); + webContents.send('auto-caption-chunk', { cues: adjustedCues }); + } - return { - cues, - audioSourceLabel: audioSource.label, + // If we don't know duration and this was a short chunk, we might be at the end + // Actually, FFmpeg will just produce a short file if duration is past EOS. + const stats = await fs.stat(wavPath).catch(() => null); + if (stats && stats.size < 1000) { // Tiny audio file means we hit the end + break; + } + + if (totalDurationMs > 0 && offsetMs + CHUNK_SIZE_MS >= totalDurationMs) { + break; + } + + } finally { + await Promise.allSettled([ + fs.rm(wavPath, { force: true }), + fs.rm(srtPath, { force: true }), + fs.rm(jsonPath, { force: true }), + ]) } - } finally { - await Promise.allSettled([ - fs.rm(wavPath, { force: true }), - fs.rm(srtPath, { force: true }), - fs.rm(jsonPath, { force: true }), - ]) + } + + console.log(`[auto-captions] Generation complete. Total cues: ${allCues.length}`) + webContents.send('auto-caption-progress', { progress: 100 }) + return { + cues: allCues, + audioSourceLabel, } } diff --git a/electron/preload.ts b/electron/preload.ts index 1ff30845..462cdb5f 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -209,6 +209,12 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("auto-caption-progress", listener); return () => ipcRenderer.removeListener("auto-caption-progress", listener); }, + onAutoCaptionChunk: (callback: (payload: { cues: CaptionCue[] }) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { cues: CaptionCue[] }) => + callback(payload); + ipcRenderer.on("auto-caption-chunk", listener); + return () => ipcRenderer.removeListener("auto-caption-chunk", listener); + }, setCurrentVideoPath: (path: string) => { return ipcRenderer.invoke("set-current-video-path", path); }, diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 7f0d9b99..604b2860 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -228,6 +228,7 @@ interface SettingsPanelProps { selectedSpeedValue?: PlaybackSpeed | null; onSpeedChange?: (speed: PlaybackSpeed) => void; onSpeedDelete?: (id: string) => void; + timeSelection?: { startMs: number; endMs: number } | null; } export default SettingsPanel; @@ -550,9 +551,11 @@ export function SettingsPanel({ selectedSpeedValue, onSpeedChange, onSpeedDelete, + timeSelection, }: SettingsPanelProps) { const tSettings = useScopedT("settings"); const { t } = useI18n(); + const isBackgroundPanel = panelMode === "background"; const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []); const [builtInWallpapers, setBuiltInWallpapers] = @@ -1469,6 +1472,36 @@ export function SettingsPanel({ +
+
+ Generation Range + + val && + onAutoCaptionSettingsChange?.({ + ...autoCaptionSettings!, + generationRange: val as any, + }) + } + className="justify-start gap-1" + > + + Full Video + + + Selected Timeline {timeSelection ? `(${(timeSelection.startMs / 1000).toFixed(1)}s - ${(timeSelection.endMs / 1000).toFixed(1)}s)` : ""} + + +
+
@@ -3507,7 +3544,6 @@ export default function VideoEditor() { aspectRatio={aspectRatio} onAspectRatioChange={setAspectRatio} selectedAnnotationId={selectedAnnotationId} - annotationRegions={annotationRegions} onSeek={(time) => videoPlaybackRef.current?.seek(time)} autoCaptions={autoCaptions} onAutoCaptionsChange={setAutoCaptions} @@ -3541,6 +3577,7 @@ export default function VideoEditor() { onSpeedDelete={handleSpeedDelete} selectedCaptionId={selectedCaptionId} onSelectCaption={setSelectedCaptionId} + timeSelection={timeSelection} /> diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 3a50e4ec..206d44d8 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -498,6 +498,11 @@ export function normalizeProjectEditor(editor: Partial): Pro selectedModel: typeof rawAutoCaptionSettings.selectedModel === "string" ? rawAutoCaptionSettings.selectedModel : DEFAULT_AUTO_CAPTION_SETTINGS.selectedModel, + generationRange: + rawAutoCaptionSettings.generationRange === "full" || + rawAutoCaptionSettings.generationRange === "selected" + ? rawAutoCaptionSettings.generationRange + : "full", }; const rawCropX = isFiniteNumber(editor.cropRegion?.x) diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index a1e02540..efc1c16e 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -14,7 +14,7 @@ interface ItemProps { onSelect?: () => void; zoomDepth?: number; speedValue?: number; - variant?: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio' | 'caption'; + variant?: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio' | 'caption' | 'caption-range'; } // Map zoom depth to multiplier labels @@ -59,6 +59,7 @@ export default function Item({ const isSpeed = variant === 'speed'; const isAudio = variant === 'audio'; const isCaption = variant === 'caption'; + const isCaptionRange = variant === 'caption-range'; const glassClass = isZoom ? glassStyles.glassGreen @@ -70,6 +71,8 @@ export default function Item({ ? glassStyles.glassPurple : isCaption ? glassStyles.glassCyan + : isCaptionRange + ? glassStyles.glassCyanDashed : glassStyles.glassYellow; const endCapColor = isZoom @@ -82,6 +85,8 @@ export default function Item({ ? '#a855f7' : isCaption ? '#0891b2' + : isCaptionRange + ? '#06b6d4' : '#B4A046'; const timeLabel = useMemo( diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index b89936b3..deedc7a9 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -175,11 +175,38 @@ .glassAmber.selected .zoomEndCap, .glassPurple:hover .zoomEndCap, .glassPurple.selected .zoomEndCap, -.glassCyan:hover .zoomEndCap, -.glassCyan.selected .zoomEndCap { +.glassCyan.selected .zoomEndCap, +.glassCyanDashed:hover .zoomEndCap, +.glassCyanDashed.selected .zoomEndCap { opacity: 1; } +.glassCyanDashed { + position: relative; + border-radius: 8px; + -corner-smoothing: antialiased; + background: rgba(8, 145, 178, 0.05); + border: 1px dashed rgba(8, 145, 178, 0.5); + box-shadow: 0 2px 12px 0 rgba(8, 145, 178, 0.05) inset; + margin: 1px 0; + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.glassCyanDashed:hover { + background: rgba(8, 145, 178, 0.1); + border-color: rgba(8, 145, 178, 0.7); +} + +.glassCyanDashed.selected { + background: rgba(8, 145, 178, 0.2); + border-color: #0891b2; + border-style: solid; + box-shadow: 0 0 0 1px #0891b2, 0 4px 20px 0 rgba(8, 145, 178, 0.15) inset; + z-index: 10; +} + .zoomEndCap.left { left: 0; cursor: ew-resize; diff --git a/src/components/video-editor/timeline/KeyframeMarkers.tsx b/src/components/video-editor/timeline/KeyframeMarkers.tsx index d5c18be3..59bbcb52 100644 --- a/src/components/video-editor/timeline/KeyframeMarkers.tsx +++ b/src/components/video-editor/timeline/KeyframeMarkers.tsx @@ -23,7 +23,7 @@ const KeyframeMarkers: React.FC = ({ videoDurationMs, timelineRef }) => { - const { sidebarWidth, range, valueToPixels, pixelsToValue } = useTimelineContext(); + const { sidebarWidth = 0, range, valueToPixels, pixelsToValue } = useTimelineContext(); const [draggingKeyframeId, setDraggingKeyframeId] = useState(null); useEffect(() => { diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 6c368d2d..87088464 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -23,7 +23,7 @@ import Row from "./Row"; import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import type { Range, Span } from "dnd-timeline"; -import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus, CaptionCue } from "../types"; +import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus, CaptionCue, TimeSelection } from "../types"; import { toFileUrl } from "../projectPersistence"; import { detectInteractionCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils"; @@ -36,6 +36,7 @@ const CAPTION_ROW_ID = "row-caption"; const FALLBACK_RANGE_MS = 1000; const TARGET_MARKER_COUNT = 12; const SUGGESTION_SPACING_MS = 1800; +const DRAG_THRESHOLD_PX = 5; interface TimelineEditorProps { videoDuration: number; @@ -74,7 +75,7 @@ interface TimelineEditorProps { onAudioDelete?: (id: string) => void; selectedAudioId?: string | null; onSelectAudio?: (id: string | null) => void; - autoCaptions?: any[]; + autoCaptions?: CaptionCue[]; onCaptionSpanChange?: (id: string, span: Span) => void; selectedCaptionId?: string | null; onSelectCaption?: (id: string | null) => void; @@ -82,6 +83,8 @@ interface TimelineEditorProps { onAspectRatioChange: (aspectRatio: AspectRatio) => void; onOpenCropEditor?: () => void; isCropped?: boolean; + timeSelection?: TimeSelection | null; + onTimeSelectionChange?: (selection: TimeSelection | null) => void; } interface TimelineScaleConfig { @@ -219,7 +222,7 @@ function PlaybackCursor({ timelineRef: React.RefObject; keyframes?: { id: string; time: number }[]; }) { - const { sidebarWidth, direction, range, valueToPixels, pixelsToValue } = useTimelineContext(); + const { sidebarWidth = 0, direction, range, valueToPixels, pixelsToValue } = useTimelineContext(); const sideProperty = direction === "rtl" ? "right" : "left"; const [isDragging, setIsDragging] = useState(false); @@ -331,7 +334,7 @@ function TimelineAxis({ videoDurationMs: number; currentTimeMs: number; }) { - const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext(); + const { sidebarWidth = 0, direction, range, valueToPixels } = useTimelineContext(); const sideProperty = direction === "rtl" ? "right" : "left"; const { intervalMs } = useMemo( @@ -394,10 +397,18 @@ function TimelineAxis({ return (
{ + // Also allow starting selection from the ruler + (e.currentTarget.parentElement as any)?.__handleMouseDown?.(e); + }} + onClick={(e) => { + // Also allow seeking/clearing from the ruler + (e.currentTarget.parentElement as any)?.__handleTimelineClick?.(e); + }} > {/* Minor Ticks */} {markers.minorTicks.map((time) => { @@ -464,6 +475,8 @@ function Timeline({ selectAllBlocksActive = false, onClearBlockSelection, keyframes = [], + timeSelection, + onTimeSelectionChange, }: { items: TimelineRenderItem[]; videoDurationMs: number; @@ -484,31 +497,119 @@ function Timeline({ selectAllBlocksActive?: boolean; onClearBlockSelection?: () => void; keyframes?: { id: string; time: number }[]; + timeSelection?: TimeSelection | null; + onTimeSelectionChange?: (selection: TimeSelection | null) => void; }) { - const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); + const { setTimelineRef, style, sidebarWidth = 0, range, pixelsToValue, valueToPixels } = useTimelineContext(); const localTimelineRef = useRef(null); const setRefs = useCallback( (node: HTMLDivElement | null) => { - setTimelineRef(node); - localTimelineRef.current = node; + if (localTimelineRef.current !== node) { + setTimelineRef(node); + localTimelineRef.current = node; + } }, [setTimelineRef], ); + const isDraggingSelectionRef = useRef(false); + const selectionAnchorMsRef = useRef(null); + const initialMouseDownPosRef = useRef<{ x: number; y: number } | null>(null); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (videoDurationMs <= 0) return; + + // Capture the rect NOW — e.currentTarget becomes null after React's + // synthetic event is processed and must not be read inside async closures. + const capturedRect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - capturedRect.left - sidebarWidth; + + if (clickX < 0) return; + + const relativeMs = pixelsToValue(clickX); + const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); + + initialMouseDownPosRef.current = { x: e.clientX, y: e.clientY }; + isDraggingSelectionRef.current = false; // Reset drag flag + + if (e.shiftKey) { + // Shift+mousedown: anchor to the far edge of the existing selection, + // or to the current playhead if there is no selection yet. + let anchor = currentTimeMs; + if (timeSelection) { + const distToStart = Math.abs(timeSelection.startMs - absoluteMs); + const distToEnd = Math.abs(timeSelection.endMs - absoluteMs); + anchor = distToStart > distToEnd ? timeSelection.startMs : timeSelection.endMs; + } + + selectionAnchorMsRef.current = anchor; + const start = Math.min(anchor, absoluteMs); + const end = Math.max(anchor, absoluteMs); + onTimeSelectionChange?.({ startMs: start, endMs: end }); + } else { + // Plain drag: anchor starts at the click point itself + selectionAnchorMsRef.current = absoluteMs; + onTimeSelectionChange?.({ startMs: absoluteMs, endMs: absoluteMs }); + } + + const handleGlobalMouseMove = (moveEvent: MouseEvent) => { + if (selectionAnchorMsRef.current === null || initialMouseDownPosRef.current === null) return; + + const dx = Math.abs(moveEvent.clientX - initialMouseDownPosRef.current.x); + const dy = Math.abs(moveEvent.clientY - initialMouseDownPosRef.current.y); + + if (dx > DRAG_THRESHOLD_PX || dy > DRAG_THRESHOLD_PX) { + isDraggingSelectionRef.current = true; + } + + // Use the captured rect — safe to read from an async listener + const moveX = moveEvent.clientX - capturedRect.left - sidebarWidth; + const moveRelativeMs = pixelsToValue(moveX); + const moveAbsoluteMs = Math.max(0, Math.min(range.start + moveRelativeMs, videoDurationMs)); + + const start = Math.min(selectionAnchorMsRef.current, moveAbsoluteMs); + const end = Math.max(selectionAnchorMsRef.current, moveAbsoluteMs); + + onTimeSelectionChange?.({ startMs: start, endMs: end }); + }; + + const handleGlobalMouseUp = () => { + selectionAnchorMsRef.current = null; + initialMouseDownPosRef.current = null; + window.removeEventListener("mousemove", handleGlobalMouseMove); + window.removeEventListener("mouseup", handleGlobalMouseUp); + }; + + window.addEventListener("mousemove", handleGlobalMouseMove); + window.addEventListener("mouseup", handleGlobalMouseUp); + }, + [range.start, sidebarWidth, pixelsToValue, videoDurationMs, onTimeSelectionChange, timeSelection, currentTimeMs], + ); + const handleTimelineClick = useCallback( (e: React.MouseEvent) => { + // If a drag occurred, swallow the click entirely + if (isDraggingSelectionRef.current) { + isDraggingSelectionRef.current = false; + return; + } + if (!onSeek || videoDurationMs <= 0) return; - // Only clear selection if clicking on empty space (not on items) - // This is handled by event propagation - items stop propagation - onSelectZoom?.(null); - onSelectTrim?.(null); - onSelectAnnotation?.(null); - onSelectSpeed?.(null); - onSelectAudio?.(null); - onSelectCaption?.(null); - onClearBlockSelection?.(); + // Shift+click: the selection was already updated in mousedown — don't seek or clear + if (e.shiftKey) return; + + // Plain click: clear selection and deselect all blocks, then seek + onTimeSelectionChange?.(null); + onSelectZoom?.(null); + onSelectTrim?.(null); + onSelectAnnotation?.(null); + onSelectSpeed?.(null); + onSelectAudio?.(null); + onSelectCaption?.(null); + onClearBlockSelection?.(); const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left - sidebarWidth; @@ -517,10 +618,19 @@ function Timeline({ const relativeMs = pixelsToValue(clickX); const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs)); - const timeInSeconds = absoluteMs / 1000; - onSeek(timeInSeconds); - }, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, onSelectCaption, videoDurationMs, sidebarWidth, range.start, pixelsToValue]); + onSeek(absoluteMs / 1000); + }, + [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, onSelectCaption, videoDurationMs, sidebarWidth, range.start, pixelsToValue, onTimeSelectionChange, onClearBlockSelection], + ); + + useEffect(() => { + if (localTimelineRef.current) { + // Expose handlers for internal components like Axis + (localTimelineRef.current as any).__handleMouseDown = handleMouseDown; + (localTimelineRef.current as any).__handleTimelineClick = handleTimelineClick; + } + }, [handleMouseDown, handleTimelineClick]); const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID); const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID); @@ -534,8 +644,19 @@ function Timeline({ ref={setRefs} style={style} className="select-none bg-[#17171a] h-full min-h-0 relative cursor-pointer group flex flex-col" + onMouseDown={handleMouseDown} onClick={handleTimelineClick} > + {timeSelection && ( +
range.end) ? 'none' : 'block' + }} + /> + )}
))} - + + {captionItems.map((item) => ( loadEditorPreferences(), []); @@ -824,6 +948,7 @@ export default function TimelineEditor({ onSelectAudio(null); }, [selectedAudioId, onAudioDelete, onSelectAudio]); + const clearSelectedBlocks = useCallback(() => { onSelectZoom(null); onSelectTrim?.(null); @@ -896,6 +1021,7 @@ export default function TimelineEditor({ onSelectAudio?.(id); }, [onSelectAudio]); + const handleSelectCaption = useCallback((id: string | null) => { setSelectAllBlocksActive(false); onSelectCaption?.(id); @@ -1046,23 +1172,23 @@ export default function TimelineEditor({ // Always place zoom at playhead const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next zoom region after the playhead const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs); const nextRegion = sorted.find(region => region.startMs > startPos); const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - // Check if playhead is inside any zoom region - const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place zoom here", { - description: "Zoom already exists at this location or not enough space available.", - }); - return; - } + const actualDuration = timeSelection + ? timeSelection.endMs - timeSelection.startMs + : Math.min(defaultRegionDurationMs, gapToNext); - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); - onZoomAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]); + const finalStart = timeSelection ? timeSelection.startMs : startPos; + const finalEnd = timeSelection ? timeSelection.endMs : startPos + actualDuration; + + onZoomAdded({ start: finalStart, end: finalEnd }); + + if (timeSelection) { + onTimeSelectionChange?.(null); + } + }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs, timeSelection, onTimeSelectionChange]); const handleSuggestZooms = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0) { @@ -1174,23 +1300,23 @@ export default function TimelineEditor({ // Always place trim at playhead const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next trim region after the playhead const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs); const nextRegion = sorted.find(region => region.startMs > startPos); const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - // Check if playhead is inside any trim region - const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place trim here", { - description: "Trim already exists at this location or not enough space available.", - }); - return; - } + const actualDuration = timeSelection + ? timeSelection.endMs - timeSelection.startMs + : Math.min(defaultRegionDurationMs, gapToNext); - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); - onTrimAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]); + const finalStart = timeSelection ? timeSelection.startMs : startPos; + const finalEnd = timeSelection ? timeSelection.endMs : startPos + actualDuration; + + onTrimAdded({ start: finalStart, end: finalEnd }); + + if (timeSelection) { + onTimeSelectionChange?.(null); + } + }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs, timeSelection, onTimeSelectionChange]); const handleAddSpeed = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) { @@ -1204,23 +1330,23 @@ export default function TimelineEditor({ // Always place speed region at playhead const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - // Find the next speed region after the playhead const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs); const nextRegion = sorted.find(region => region.startMs > startPos); const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; - // Check if playhead is inside any speed region - const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); - if (isOverlapping || gapToNext <= 0) { - toast.error("Cannot place speed here", { - description: "Speed region already exists at this location or not enough space available.", - }); - return; - } + const actualDuration = timeSelection + ? timeSelection.endMs - timeSelection.startMs + : Math.min(defaultRegionDurationMs, gapToNext); - const actualDuration = Math.min(defaultRegionDurationMs, gapToNext); - onSpeedAdded({ start: startPos, end: startPos + actualDuration }); - }, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]); + const finalStart = timeSelection ? timeSelection.startMs : startPos; + const finalEnd = timeSelection ? timeSelection.endMs : startPos + actualDuration; + + onSpeedAdded({ start: finalStart, end: finalEnd }); + + if (timeSelection) { + onTimeSelectionChange?.(null); + } + }, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs, timeSelection, onTimeSelectionChange]); const handleAddAudio = useCallback(async () => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) { @@ -1281,11 +1407,16 @@ export default function TimelineEditor({ } // Multiple annotations can exist at the same timestamp - const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); - const endPos = Math.min(startPos + defaultDuration, totalMs); + const finalStart = timeSelection ? timeSelection.startMs : Math.max(0, Math.min(currentTimeMs, totalMs)); + const finalEnd = timeSelection ? timeSelection.endMs : Math.min(finalStart + defaultDuration, totalMs); + + onAnnotationAdded({ start: finalStart, end: finalEnd }); + + if (timeSelection) { + onTimeSelectionChange?.(null); + } + }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs, timeSelection, onTimeSelectionChange]); - onAnnotationAdded({ start: startPos, end: endPos }); - }, [videoDuration, totalMs, currentTimeMs, onAnnotationAdded, defaultRegionDurationMs]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1304,9 +1435,6 @@ export default function TimelineEditor({ return; } - if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) { - addKeyframe(); - } if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) { handleAddZoom(); } @@ -1736,6 +1864,8 @@ export default function TimelineEditor({ selectAllBlocksActive={selectAllBlocksActive} onClearBlockSelection={clearSelectedBlocks} keyframes={keyframes} + timeSelection={timeSelection} + onTimeSelectionChange={onTimeSelectionChange} />
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index ac8a7bc7..68b7d310 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -251,6 +251,12 @@ export interface AudioRegion { volume: number; } + +export interface TimeSelection { + startMs: number; + endMs: number; +} + export interface CaptionCue { id: string; startMs: number; @@ -283,6 +289,7 @@ export interface AutoCaptionSettings { textColor: string; inactiveTextColor: string; backgroundOpacity: number; + generationRange: "full" | "selected"; } export const DEFAULT_AUTO_CAPTION_SETTINGS: AutoCaptionSettings = { @@ -298,7 +305,8 @@ export const DEFAULT_AUTO_CAPTION_SETTINGS: AutoCaptionSettings = { boxRadius: 17.5, textColor: "#FFFFFF", inactiveTextColor: "#A3A3A3", - backgroundOpacity: 0.9, + backgroundOpacity: 0.1, + generationRange: "full", }; export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;