import { useEffect, useRef, useState, type CSSProperties } from 'react' import { Lock, Share2, Unlock } from 'lucide-react' import { Button } from '@/components/ui/button' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger, } from '@/components/ui/context-menu' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { AudioWaveform } from '@/components/widgets/timeline/AudioWaveform' import { mediaContentToViewUrl } from '@/lib/media-url' import { activeTaskImages } from '@/lib/task-image-utils' import { useT } from '@/lib/i18n' import { formatMultiTrackTime, getMultiTrackTaskModeLabel } from '@/lib/multitrack-utils' import { getSegmentTrackPresentation } from '@/lib/multitrack-segment-style' import { captureVideoPosterFrame } from '@/lib/video-utils' import type { SubtitleRecognitionMethod } from '@/lib/subtitle-recognition' import type { MultiTrackSegment, MultiTrackType } from '@/types/multitrack' const RESIZE_ZONE_DEFAULT = 8 const RESIZE_ZONE_SELECTED = 12 const RESIZE_SNAP_DISTANCE = 8 const DRAG_START_DISTANCE = 4 type SegmentBlockStyle = CSSProperties & { '--multitrack-waveform'?: string } interface MultiTrackSegmentBlockProps { trackType: MultiTrackType segmentIndex: number segment: MultiTrackSegment totalLength: number frameRate: number areaWidth: number canvasScale: number selected: boolean onSelect: (segmentId: string, mode?: 'replace' | 'toggle' | 'add') => void onDelete: (segmentId: string) => void onDistribute?: () => void onClone?: (segmentId: string) => void onMerge?: () => void onSplitTask?: (segmentId: string) => void onSmartSplit?: (segmentId: string) => void onSmartSplitTasks?: (segmentId: string) => void onRecognizeSubtitles?: (segmentId: string, method: SubtitleRecognitionMethod) => void cutMode?: boolean onCut?: (segmentId: string, splitFrame: number) => void onResize: (segmentId: string, edge: 'start' | 'end', nextTime: number, brakeDistanceFrames?: number) => void onResizePreview: (segmentId: string, edge: 'start' | 'end', nextTime: number, brakeDistanceFrames?: number) => void onMove: (segmentId: string, nextStartTime: number, clientY: number) => void onDragPreviewChange?: (segmentId: string, nextStartTime: number, clientY: number) => void getDragPreviewStart?: (segmentId: string, nextStartTime: number, clientY: number) => number onDragPreviewEnd?: () => void onDoubleClick?: (segmentId: string, event: React.MouseEvent) => void dimmed?: boolean showTaskIndex?: boolean taskOverview?: boolean audioLocked?: boolean audioLockEnabled?: boolean onAudioLockToggle?: (locked: boolean) => void sharedReference?: boolean onSharedReferenceToggle?: (enabled: boolean) => void } function segmentRect(segment: MultiTrackSegment, totalLength: number, areaWidth: number) { const safeLength = Math.max(totalLength, 1) const left = (segment.start_frame / safeLength) * areaWidth const right = (segment.end_frame / safeLength) * areaWidth return { left, width: Math.max(2, right - left) } } export function MultiTrackSegmentBlock({ trackType, segmentIndex, segment, totalLength, frameRate, areaWidth, canvasScale, selected, onSelect, onDelete, onDistribute, onClone, onMerge, onSplitTask, onSmartSplit, onSmartSplitTasks, onRecognizeSubtitles, cutMode = false, onCut, onResize, onResizePreview, onMove, onDragPreviewChange, getDragPreviewStart, onDragPreviewEnd, onDoubleClick, dimmed = false, showTaskIndex = true, taskOverview = false, audioLocked = false, audioLockEnabled = false, onAudioLockToggle, sharedReference = false, onSharedReferenceToggle, }: Readonly) { const t = useT() const didDragRef = useRef(false) const dragPreviewRef = useRef<{ x: number y: number width: number height: number nextStartTime: number clientY: number } | null>(null) const dragFrameRef = useRef(null) const isDraggingRef = useRef(false) const [isResizing, setIsResizing] = useState(false) const [isDragging, setIsDragging] = useState(false) const [dragPreview, setDragPreview] = useState<{ x: number y: number width: number height: number nextStartTime: number clientY: number } | null>(null) const [posterUrl, setPosterUrl] = useState(null) const [cursorStyle, setCursorStyle] = useState<'ew-resize' | 'grab' | null>(null) const { left, width } = segmentRect(segment, totalLength, areaWidth) const mediaUrl = mediaContentToViewUrl({ source_type: segment.content.source_type ?? 'input', file_path: segment.content.file_path, local_path: segment.content.local_path, url: segment.content.url, }) const segmentDuration = Math.max(0, segment.end_frame - segment.start_frame) const sourceDurationFrames = segment.content.duration && segment.content.duration > 0 ? segment.content.duration * Math.max(frameRate, 1) : null const sourceOriginFrame = segment.origin_start_frame ?? segment.start_frame const waveformStartRatio = sourceDurationFrames ? Math.max(0, Math.min(1, (segment.start_frame - sourceOriginFrame) / sourceDurationFrames)) : 0 const waveformEndRatio = sourceDurationFrames ? Math.max(waveformStartRatio, Math.min(1, (segment.end_frame - sourceOriginFrame) / sourceDurationFrames)) : 1 const sourceStartTime = Math.max( 0, (segment.start_frame - sourceOriginFrame) / Math.max(frameRate, 1), ) const taskModeLabel = getMultiTrackTaskModeLabel(segment.content.task_mode ?? 'default', t) const label = trackType === 'task' ? showTaskIndex ? t('multitrackSegment.taskLabel', { n: segmentIndex + 1, mode: taskModeLabel, }) : taskModeLabel : trackType === 'subtitle' ? segment.content.text ?? t('multitrack.subtitle') : segment.content.file_name ?? segment.id const durationLabel = formatMultiTrackTime(segmentDuration, { frameRate, showFrames: true }) const taskPrompt = segment.content.user_prompt?.trim() ?? '' const taskImageUrls = activeTaskImages(segment.content.images).flatMap((image) => { const url = mediaContentToViewUrl({ source_type: image.source_type ?? 'input', file_path: image.file_path, local_path: image.local_path, url: image.url, slot_name: image.slot_name, }) return url ? [url] : [] }) const tiledTaskImages = Array.from({ length: 24 }, (_, repeatIndex) => ( taskImageUrls.map((url, imageIndex) => ({ key: `${repeatIndex}-${imageIndex}`, url, })) )).flat() const presentation = getSegmentTrackPresentation(trackType) const cloneLabel = trackType === 'video' ? t('multitrack.cloneVideoSegment') : trackType === 'audio' ? t('multitrack.cloneAudioSegment') : t('multitrack.cloneTaskSegment') const borderColor = isResizing ? 'var(--warning)' : selected ? presentation.borderColor : presentation.backgroundColorStrong const blockStyle: SegmentBlockStyle = { left: dragPreview ? 0 : left, top: dragPreview ? dragPreview.y : undefined, bottom: dragPreview ? undefined : undefined, width: dragPreview ? dragPreview.width : width, height: dragPreview ? dragPreview.height : undefined, position: 'absolute', transform: dragPreview ? `translate3d(${dragPreview.x}px, 0, 0)` : undefined, backgroundColor: presentation.backgroundColor, color: presentation.textColor, border: `1px solid ${borderColor}`, '--multitrack-waveform': presentation.waveformColor ?? undefined, cursor: cutMode ? 'text' : cursorStyle ?? 'grab', zIndex: isDragging ? 9999 : selected ? 30 : 1, boxShadow: isDragging ? '0 8px 24px rgb(0 0 0 / 0.35)' : undefined, pointerEvents: dragPreview ? 'none' : undefined, } function updateDragPreview(nextPreview: typeof dragPreviewRef.current) { dragPreviewRef.current = nextPreview if (dragFrameRef.current !== null) return dragFrameRef.current = requestAnimationFrame(() => { dragFrameRef.current = null setDragPreview(dragPreviewRef.current) if (dragPreviewRef.current) { onDragPreviewChange?.(segment.id, dragPreviewRef.current.nextStartTime, dragPreviewRef.current.clientY) } }) } useEffect(() => { if (!presentation.showThumbnail || !mediaUrl) { setPosterUrl(null) return } let cancelled = false let objectUrl: string | null = null captureVideoPosterFrame(mediaUrl, sourceStartTime) .then((nextPosterUrl) => { if (cancelled) { URL.revokeObjectURL(nextPosterUrl) return } objectUrl = nextPosterUrl setPosterUrl(nextPosterUrl) }) .catch((error: unknown) => { console.error('[MultiTrackSegmentBlock] failed to capture video poster:', error) if (!cancelled) setPosterUrl(null) }) return () => { cancelled = true if (objectUrl) URL.revokeObjectURL(objectUrl) } }, [mediaUrl, presentation.showThumbnail, sourceStartTime]) useEffect(() => { return () => { if (dragFrameRef.current !== null) { cancelAnimationFrame(dragFrameRef.current) } } }, []) function resizeZone(): number { return selected ? RESIZE_ZONE_SELECTED : RESIZE_ZONE_DEFAULT } function renderedCoordinateScale(rectWidth: number): number { const measuredScale = rectWidth / Math.max(width, 1) return Number.isFinite(measuredScale) && measuredScale > 0.01 ? measuredScale : Math.max(canvasScale, 0.01) } function resizeDeltaFromClientX(clientX: number, startX: number, coordinateScale: number): number { const adjustedDelta = (clientX - startX) / coordinateScale return (adjustedDelta / Math.max(areaWidth, 1)) * Math.max(totalLength, 1) } function resizeSnapBrakeDistanceFrames(): number { return Math.max(1, Math.round((RESIZE_SNAP_DISTANCE / Math.max(areaWidth, 1)) * Math.max(totalLength, 1))) } function handleMouseMove(event: React.MouseEvent) { if (cutMode) return const rect = event.currentTarget.getBoundingClientRect() const coordinateScale = renderedCoordinateScale(rect.width) const relX = (event.clientX - rect.left) / coordinateScale const zone = resizeZone() setCursorStyle(relX <= zone || relX >= rect.width / coordinateScale - zone ? 'ew-resize' : 'grab') } function handleMouseDown(event: React.MouseEvent) { if (event.button !== 0) return event.preventDefault() event.stopPropagation() if (cutMode) return didDragRef.current = false isDraggingRef.current = false onSelect(segment.id, event.metaKey || event.ctrlKey ? 'toggle' : event.shiftKey || selected ? 'add' : 'replace') const rect = event.currentTarget.getBoundingClientRect() const coordinateScale = renderedCoordinateScale(rect.width) const relX = (event.clientX - rect.left) / coordinateScale const visualWidth = rect.width / coordinateScale const zone = resizeZone() const edge = relX <= zone ? 'start' : relX >= visualWidth - zone ? 'end' : null const startX = event.clientX const startY = event.clientY if (!edge) { const originalStart = segment.start_frame const startRect = event.currentTarget.getBoundingClientRect() const currentTarget = event.currentTarget as HTMLElement const containerRect = currentTarget.offsetParent?.getBoundingClientRect() const scale = coordinateScale const pointerOffsetY = (event.clientY - startRect.top) / scale const previewWidth = startRect.width / scale const previewHeight = startRect.height / scale function handleMove(moveEvent: MouseEvent) { const deltaFrames = resizeDeltaFromClientX(moveEvent.clientX, startX, coordinateScale) const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) if (distance < DRAG_START_DISTANCE) return if (!containerRect) return didDragRef.current = true if (!isDraggingRef.current) { isDraggingRef.current = true setIsDragging(true) } const requestedStartTime = originalStart + deltaFrames const previewStartTime = getDragPreviewStart?.(segment.id, requestedStartTime, moveEvent.clientY) ?? requestedStartTime const x = (previewStartTime / Math.max(totalLength, 1)) * areaWidth const y = (moveEvent.clientY - containerRect.top) / scale - pointerOffsetY updateDragPreview({ x, y, width: previewWidth, height: previewHeight, nextStartTime: previewStartTime, clientY: moveEvent.clientY, }) } function handleUp() { if (dragFrameRef.current !== null) { cancelAnimationFrame(dragFrameRef.current) dragFrameRef.current = null } isDraggingRef.current = false setIsDragging(false) const finalPreview = dragPreviewRef.current if (didDragRef.current && finalPreview) onMove(segment.id, finalPreview.nextStartTime, finalPreview.clientY) dragPreviewRef.current = null setDragPreview(null) onDragPreviewEnd?.() globalThis.removeEventListener('mousemove', handleMove) globalThis.removeEventListener('mouseup', handleUp) } globalThis.addEventListener('mousemove', handleMove) globalThis.addEventListener('mouseup', handleUp) return } const resizeEdge = edge const originalTime = resizeEdge === 'start' ? segment.start_frame : segment.end_frame const resizeAnchorX = resizeEdge === 'start' ? rect.left : rect.right const brakeDistanceFrames = resizeSnapBrakeDistanceFrames() setIsResizing(true) function handleMove(moveEvent: MouseEvent) { const deltaFrames = resizeDeltaFromClientX(moveEvent.clientX, resizeAnchorX, coordinateScale) if (Math.abs(deltaFrames) > 0) didDragRef.current = true onResizePreview(segment.id, resizeEdge, originalTime + deltaFrames, brakeDistanceFrames) } function handleUp(upEvent: MouseEvent) { const deltaFrames = resizeDeltaFromClientX(upEvent.clientX, resizeAnchorX, coordinateScale) if (didDragRef.current) onResize(segment.id, resizeEdge, originalTime + deltaFrames, brakeDistanceFrames) setIsResizing(false) globalThis.removeEventListener('mousemove', handleMove) globalThis.removeEventListener('mouseup', handleUp) } globalThis.addEventListener('mousemove', handleMove) globalThis.addEventListener('mouseup', handleUp) } return ( <>
setCursorStyle(null)} onClick={(event) => { event.stopPropagation() if (cutMode && onCut) { const rect = event.currentTarget.getBoundingClientRect() const visualWidth = rect.width / Math.max(canvasScale, 0.01) const x = (event.clientX - rect.left) / Math.max(canvasScale, 0.01) const ratio = Math.max(0, Math.min(1, x / Math.max(visualWidth, 1))) const splitFrame = Math.round( segment.start_frame + ratio * (segment.end_frame - segment.start_frame), ) onCut(segment.id, splitFrame) return } if (!didDragRef.current) event.preventDefault() }} onContextMenu={(event) => { event.stopPropagation() onSelect(segment.id, selected ? 'add' : 'replace') }} onDoubleClick={(event) => { event.preventDefault() event.stopPropagation() if (cutMode) return onDoubleClick?.(segment.id, event) }} onKeyDown={(event) => { if (event.key === 'Enter') onSelect(segment.id, event.metaKey || event.ctrlKey ? 'toggle' : event.shiftKey ? 'add' : 'replace') if (event.key === 'Delete' || event.key === 'Backspace') onDelete(segment.id) }} >
{trackType === 'task' && taskOverview ? ( <> {tiledTaskImages.length > 0 ? (
{tiledTaskImages.map(({ key, url }) => ( { event.currentTarget.style.display = 'none' }} /> ))}
) : null}
0 ? 'absolute inset-x-0 bottom-0 z-10 flex items-center bg-black px-1 py-0.5 text-white' : 'absolute inset-0 flex items-center justify-center px-1 text-center'} > 0 ? 'truncate' : 'line-clamp-3'}`}> {taskPrompt || label}
) : ( <>
{label} {durationLabel}
{presentation.showThumbnail ? (
) : null} {presentation.showWaveform ? (
) : trackType !== 'audio' ? (
) : null} )}
{trackType === 'audio' || trackType === 'video' ? ( {t('multitrack.sharedReferenceTooltip')} ) : null} {(trackType === 'audio' || trackType === 'video') && audioLockEnabled ? ( <> {t( trackType === 'video' ? 'multitrack.videoAudioLockTooltip' : 'multitrack.audioLockTooltip', )} ) : null}
{trackType === 'video' && onSmartSplit ? ( onSmartSplit(segment.id)}> {t('multitrack.smartSplit')} ) : null} {trackType === 'video' && onSmartSplitTasks ? ( onSmartSplitTasks(segment.id)}> {t('multitrack.smartSplitTasksOnly')} ) : null} {(trackType === 'video' || trackType === 'audio') && onRecognizeSubtitles ? ( {t('multitrack.recognizeSubtitles')} onRecognizeSubtitles(segment.id, 'whisper-large-v3')}> {t('multitrack.recognizeSubtitlesWhisper')} onRecognizeSubtitles(segment.id, 'qwen3-asr')}> {t('multitrack.recognizeSubtitlesQwen3')} ) : null} {trackType === 'task' && onDistribute ? ( {t('multitrack.distributeTaskSegments')} ) : null} {onClone ? ( onClone(segment.id)}> {cloneLabel} ) : null} {trackType === 'task' && onMerge ? ( {t('multitrack.mergeTaskSegments')} ) : null} {trackType === 'task' && onSplitTask ? ( onSplitTask(segment.id)}> {t('multitrack.splitTaskSegment')} ) : null} onDelete(segment.id)}> {t('multitrack.deleteSegment')} ) }