import React, { useState, useRef, useEffect, useCallback } from 'react' import { VideoPreviewPanel, formatTimecode, type VideoPreviewContext } from './VideoPreviewPanel' import { validateVideoSource } from '../lib/video-constraints' interface RetakePanelProps { initialVideoPath?: string | null initialDuration?: number resetKey?: number isProcessing?: boolean processingStatus?: string fillHeight?: boolean enforceApiConstraints?: boolean onChange?: (data: { videoPath: string | null startTime: number duration: number videoDuration: number width: number height: number ready: boolean }) => void } const MIN_DURATION = 2 export function RetakePanel({ initialVideoPath, initialDuration, resetKey, isProcessing = false, processingStatus = '', fillHeight = false, enforceApiConstraints = true, onChange, }: RetakePanelProps) { const videoRef = useRef(null) const filmstripRef = useRef(null) const [videoPath, setVideoPath] = useState(initialVideoPath || null) const [videoDuration, setVideoDuration] = useState(initialDuration || 0) const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 }) const [selStart, setSelStart] = useState(0) const [selEnd, setSelEnd] = useState(Math.min(initialDuration || 0, 5)) const [draggingHandle, setDraggingHandle] = useState<'start' | 'end' | 'range' | null>(null) // Read synchronously by the filmstrip click guard. Stays true through the click the // browser fires right after a drag-release mouseup (cleared on the next tick), so // releasing a trim drag over the strip doesn't jump the playhead. const draggingRef = useRef(false) const dragStartRef = useRef<{ mouseX: number; selStart: number; selEnd: number } | null>(null) const initialSelectionAppliedRef = useRef(false) useEffect(() => { if (resetKey === undefined) return setSelStart(0) setSelEnd(Math.min(initialDuration || 0, 5)) initialSelectionAppliedRef.current = false }, [resetKey, initialDuration]) const handleSourceChange = useCallback((data: { videoPath: string | null; videoDuration: number; width: number; height: number }) => { setVideoPath(data.videoPath) setVideoDuration(data.videoDuration) setDimensions({ width: data.width, height: data.height }) if (!data.videoPath) { setSelStart(0) setSelEnd(0) initialSelectionAppliedRef.current = false } }, []) const sourceError = enforceApiConstraints && videoPath ? validateVideoSource({ width: dimensions.width, height: dimensions.height, duration: videoDuration }) : null // Apply the default selection once the source duration is known. useEffect(() => { if (!videoPath || videoDuration <= 0 || initialSelectionAppliedRef.current) return setSelStart(0) setSelEnd(Math.min(videoDuration, 5)) initialSelectionAppliedRef.current = true }, [videoDuration, videoPath]) useEffect(() => { const ready = !!videoPath && (selEnd - selStart) >= MIN_DURATION && !sourceError onChange?.({ videoPath, startTime: selStart, duration: selEnd - selStart, videoDuration, width: dimensions.width, height: dimensions.height, ready, }) }, [videoPath, selStart, selEnd, videoDuration, dimensions, sourceError, onChange]) // Retake-specific keyboard: set in/out points to the playhead. useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return const key = e.key.toLowerCase() const video = videoRef.current if (!video) return if (key === 'i') { e.preventDefault() e.stopPropagation() const t = video.currentTime if (t < selEnd - MIN_DURATION) setSelStart(t) } else if (key === 'o') { e.preventDefault() e.stopPropagation() const t = video.currentTime if (t > selStart + MIN_DURATION) setSelEnd(t) } } window.addEventListener('keydown', handler, true) return () => window.removeEventListener('keydown', handler, true) }, [selStart, selEnd]) const selStartRef = useRef(selStart) selStartRef.current = selStart const selEndRef = useRef(selEnd) selEndRef.current = selEnd const handleFilmstripMouseDown = useCallback((e: React.MouseEvent, handle: 'start' | 'end' | 'range') => { e.preventDefault() e.stopPropagation() dragStartRef.current = { mouseX: e.clientX, selStart: selStartRef.current, selEnd: selEndRef.current } draggingRef.current = true setDraggingHandle(handle) }, []) useEffect(() => { if (!draggingHandle) return const handleMouseMove = (e: MouseEvent) => { const strip = filmstripRef.current const origin = dragStartRef.current if (!strip || !origin) return const rect = strip.getBoundingClientRect() if (draggingHandle === 'range') { const dx = e.clientX - origin.mouseX const dtSeconds = (dx / rect.width) * videoDuration const rangeDuration = origin.selEnd - origin.selStart let newStart = origin.selStart + dtSeconds let newEnd = origin.selEnd + dtSeconds if (newStart < 0) { newStart = 0; newEnd = rangeDuration } if (newEnd > videoDuration) { newEnd = videoDuration; newStart = videoDuration - rangeDuration } setSelStart(Math.max(0, newStart)) setSelEnd(Math.min(videoDuration, newEnd)) } else { const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) const time = fraction * videoDuration if (draggingHandle === 'start') { const maxStart = selEndRef.current - MIN_DURATION setSelStart(Math.max(0, Math.min(maxStart, time))) } else { const minEnd = selStartRef.current + MIN_DURATION setSelEnd(Math.min(videoDuration, Math.max(minEnd, time))) } } } const handleMouseUp = () => { setDraggingHandle(null) dragStartRef.current = null // Clear on the next tick so the click synthesized from this mouseup still sees the // drag in progress and is suppressed by the filmstrip seek guard. setTimeout(() => { draggingRef.current = false }, 0) } window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', handleMouseUp) return () => { window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) } }, [draggingHandle, videoDuration]) const renderTrimOverlay = useCallback((ctx: VideoPreviewContext) => { const dur = ctx.videoDuration const selStartFrac = dur > 0 ? selStart / dur : 0 const selEndFrac = dur > 0 ? selEnd / dur : 1 return ( <>
handleFilmstripMouseDown(e, 'range')} />
handleFilmstripMouseDown(e, 'start')} >
handleFilmstripMouseDown(e, 'end')} >
{formatTimecode(selEnd - selStart)}
) }, [selStart, selEnd, draggingHandle, handleFilmstripMouseDown]) const renderLabels = useCallback(() => (
{formatTimecode(selStart)} Duration: {formatTimecode(selEnd - selStart)} {formatTimecode(selEnd)}
), [selStart, selEnd]) return ( draggingRef.current} filmstripOverlay={renderTrimOverlay} belowFilmstrip={renderLabels} /> ) }