import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useMemo } from 'react' import { ChevronRight, } from 'lucide-react' import { useProjects } from '../contexts/ProjectContext' import type { PendingIcLoraUpdate, PendingRetakeUpdate } from '../contexts/ProjectContext' import { useKeyboardShortcuts } from '../contexts/KeyboardShortcutsContext' import { useAppSettings } from '../contexts/AppSettingsContext' import { useGeneration } from '../hooks/use-generation' import { createLocalGenerationError } from '../lib/generation-errors' import { logger } from '../lib/logger' import { Tooltip } from '../components/ui/tooltip' import { Group, Panel, Separator, type PanelImperativeHandle } from 'react-resizable-panels' import { ExportModal } from '../components/ExportModal' import { MenuBar, type MenuDefinition } from '../components/MenuBar' import { ImportTimelineModal } from '../components/ImportTimelineModal' import type { Asset, Project, TimelineClip } from '../types/project-model' import { AUTOSAVE_DELAY, type EditorLayout, DEFAULT_LAYOUT, LAYOUT_LIMITS, loadLayout, saveLayout, } from './editor/video-editor-utils' import { createInitialEditorState } from './editor/editor-state' import { applyPendingClipTakeUpdate, } from './editor/editor-actions' import { getEditorModel, updatedProject } from './editor/editor-project-bridging' import { selectActiveFocusArea, selectActiveTimelineInPoint, selectActiveTimelineOutPoint, selectClipPath, selectClips, selectCurrentTime, selectLayout, selectLiveAssetForClip, selectPixelsPerSecond, selectSelectedClipForProperties, selectSelectedClipIds, selectShowExportModal, selectShowImportTimelineModal, selectShowSourceMonitor, selectSourceSplitPercent, selectSubtitles, selectSubtitleTrackStyleIdx, selectTotalDuration, } from './editor/editor-selectors' import { VideoEditorAssetsPanel, type VideoEditorAssetsPanelHandle } from './editor/VideoEditorAssetsPanel' import { VideoEditorTimelineControlPanel } from './editor/VideoEditorTimelineControlPanel' import { ClipPropertiesPanel } from './editor/ClipPropertiesPanel' import { SubtitlePropertiesPanel } from './editor/SubtitlePropertiesPanel' import { VideoEditorSourceMonitor, type SourceKeyboardAction, type VideoEditorSourceMonitorHandle, } from './editor/VideoEditorSourceMonitor' import { ProgramMonitor, type ProgramMonitorHandle } from './editor/ProgramMonitor' import { VideoEditorTimelineEditingPanel } from './editor/VideoEditorTimelineEditingPanel' import { VideoEditorLayoutMenu } from './editor/VideoEditorLayoutMenu' import { useEditorKeyboard } from './editor/useEditorKeyboard' import { useRegeneration } from './editor/useRegeneration' import { useBuildMenuDefinitions } from './editor/buildMenuDefinitions' import { usePlaybackEngine } from './editor/usePlaybackEngine' import { usePlaybackAudioSync } from './editor/usePlaybackAudioSync' import { useSubtitleImportExport } from './editor/useSubtitleImportExport' import { useEditorMediaImport } from './editor/useEditorMediaImport' import { useTimelineXmlExport } from './editor/useTimelineXmlExport' import { createEditorStore, EditorStoreProvider, useEditorActions, useEditorGetState, useEditorStore, type EditorStoreApi, } from './editor/editor-store' import { GenerationErrorDialog } from '../components/GenerationErrorDialog' import { SubtitleTrackStyleEditor } from './editor/SubtitleTrackStyleEditor' interface VideoEditorProps { currentProject: Project saveProject: (project: Project) => void pendingRetakeUpdate: PendingRetakeUpdate | null pendingIcLoraUpdate: PendingIcLoraUpdate | null } interface VideoEditorWithStoreProps { currentProject: Project saveProject: (project: Project) => void } export function VideoEditor(props: VideoEditorProps) { const { currentProject, pendingRetakeUpdate, pendingIcLoraUpdate } = props const editorStoreRef = useRef(null) if (!editorStoreRef.current) { const initialEditorModel = getEditorModel(currentProject) const retakeApplied = applyPendingClipTakeUpdate(initialEditorModel, pendingRetakeUpdate) const editorModel = applyPendingClipTakeUpdate(retakeApplied, pendingIcLoraUpdate) editorStoreRef.current = createEditorStore(createInitialEditorState(editorModel, loadLayout())) } const editorStore = editorStoreRef.current if (!editorStore) throw new Error('Editor store failed to initialize') return ( ) } function VideoEditorWithStore({ currentProject, saveProject, }: VideoEditorWithStoreProps) { const { setCurrentTab, setGenSpaceEditImagePath, setGenSpaceEditMode, setGenSpaceAudioPath, setGenSpaceRetakeSource, setGenSpaceIcLoraSource, } = useProjects() const { activeLayout: kbLayout, isEditorOpen: isKbEditorOpen, setEditorOpen: setKbEditorOpen } = useKeyboardShortcuts() const { shouldVideoGenerateWithLtxApi, forceApiGenerations } = useAppSettings() const kbLayoutRef = useRef(kbLayout) kbLayoutRef.current = kbLayout const isKbEditorOpenRef = useRef(isKbEditorOpen) isKbEditorOpenRef.current = isKbEditorOpen // Generation hook for regenerating shots const { generate: regenGenerate, generateImage: regenGenerateImage, isGenerating: isRegenerating, progress: regenProgress, statusMessage: regenStatusMessage, videoPath: regenVideoPath, imagePath: regenImagePath, error: regenError, cancel: regenCancel, reset: regenReset, canCancel: regenCanCancel, } = useGeneration() const gapGenerationApi = useMemo(() => ({ generate: regenGenerate, generateImage: regenGenerateImage, videoPath: regenVideoPath, imagePath: regenImagePath, isGenerating: isRegenerating, progress: regenProgress, statusMessage: regenStatusMessage, cancel: regenCancel, reset: regenReset, error: regenError, canCancel: regenCanCancel, }), [ isRegenerating, regenCancel, regenCanCancel, regenError, regenGenerate, regenGenerateImage, regenImagePath, regenProgress, regenReset, regenStatusMessage, regenVideoPath, ]) const currentProjectId = currentProject.id const actions = useEditorActions() const getEditorState = useEditorGetState() const editorModel = useEditorStore(state => state.editorModel) const subtitles = useEditorStore(selectSubtitles) const currentTime = useEditorStore(selectCurrentTime) const isPlaying = useEditorStore(state => state.session.transport.isPlaying) const selectedClipIds = useEditorStore(selectSelectedClipIds) const showPropertiesPanel = useEditorStore(state => state.session.ui.showPropertiesPanel) const showImportTimelineModal = useEditorStore(selectShowImportTimelineModal) const showExportModal = useEditorStore(selectShowExportModal) const layout = useEditorStore(selectLayout) const showSourceMonitor = useEditorStore(selectShowSourceMonitor) const activeFocusArea = useEditorStore(selectActiveFocusArea) const sourceSplitPercent = useEditorStore(selectSourceSplitPercent) const shuttleSpeed = useEditorStore(state => state.session.transport.shuttleSpeed) const selectedSubtitleId = useEditorStore(state => state.session.selection.subtitleId) const subtitleTrackStyleIdx = useEditorStore(selectSubtitleTrackStyleIdx) const editorModelRef = useRef(editorModel) editorModelRef.current = editorModel const currentProjectRef = useRef(currentProject) currentProjectRef.current = currentProject const bladeShiftHeldRef = useRef(false) const [bladeShiftHeld, setBladeShiftHeld] = useState(false) useEffect(() => { const onKey = (e: KeyboardEvent) => { const held = e.shiftKey bladeShiftHeldRef.current = held setBladeShiftHeld(held) } window.addEventListener('keydown', onKey) window.addEventListener('keyup', onKey) return () => { window.removeEventListener('keydown', onKey); window.removeEventListener('keyup', onKey) } }, []) // EFFECTS HIDDEN: const [effectsSearchQuery, setEffectsSearchQuery] = useState('') // In/Out points // In/Out points stored per-timeline so they don't bleed across timelines // Timeline marks and play-in-out now come from the temporary root EditorState compatibility layer. const inPoint = useEditorStore(selectActiveTimelineInPoint) const outPoint = useEditorStore(selectActiveTimelineOutPoint) const setInPoint = useCallback((updater: (prev: number | null) => number | null) => { const currentInPoint = selectActiveTimelineInPoint(getEditorState()) actions.setTimelineInPoint(updater(currentInPoint)) }, [actions, getEditorState]) const setOutPoint = useCallback((updater: (prev: number | null) => number | null) => { const currentOutPoint = selectActiveTimelineOutPoint(getEditorState()) actions.setTimelineOutPoint(updater(currentOutPoint)) }, [actions, getEditorState]) // Dragging IN/OUT markers with mouse const [draggingMarker, setDraggingMarker] = useState<'timelineIn' | 'timelineOut' | null>(null) const draggingMarkerRef = useRef(draggingMarker) draggingMarkerRef.current = draggingMarker const markerDragOriginRef = useRef<'timeline' | 'scrubbar' | null>(null) const inPointRef = useRef(inPoint) inPointRef.current = inPoint const outPointRef = useRef(outPoint) outPointRef.current = outPoint // Clip properties panel collapsible sections // Resizable layout const leftPanelResizeRef = useRef(null) const rightPanelResizeRef = useRef(null) const timelinePanelResizeRef = useRef(null) const assetsPanelResizeRef = useRef(null) const assetsPanelActionsRef = useRef(null) const sourceMonitorActionsRef = useRef(null) const programMonitorActionsRef = useRef(null) // JKL shuttle speed: -8, -4, -2, -1, 0, 1, 2, 4, 8 // Timeline tab UI state // Open timeline tabs — only these appear in the tab bar above the timeline. // All timelines are always visible in the library panel on the left. // Dragging state const timelineRef = useRef(null) const trackContainerRef = useRef(null) const trackHeadersRef = useRef(null) const rulerScrollRef = useRef(null) const centerOnPlayheadRef = useRef(false) // Flag: center view on playhead after next zoom change const initialPanelDefaultsRef = useRef<{ leftPanelWidth: number rightPanelWidth: number timelineHeight: number assetsHeight: number | string } | null>(null) if (!initialPanelDefaultsRef.current) { initialPanelDefaultsRef.current = { leftPanelWidth: layout.leftPanelWidth, rightPanelWidth: layout.rightPanelWidth, timelineHeight: layout.timelineHeight, assetsHeight: layout.assetsHeight > 0 ? layout.assetsHeight : '60%', } } // --- Performance refs: allow the rAF playback loop to sync video directly --- // These mirror React state so the hot loop doesn't depend on re-renders. const playbackTimeRef = useRef(0) // authoritative time during playback const autoSaveTimerRef = useRef | null>(null) // Only sync ref ← state when NOT playing (during playback, ref is authoritative) useEffect(() => { if (!isPlaying) playbackTimeRef.current = currentTime }, [currentTime, isPlaying]) const applyLayoutToPanels = useCallback((nextLayout: EditorLayout) => { leftPanelResizeRef.current?.resize(nextLayout.leftPanelWidth) timelinePanelResizeRef.current?.resize(nextLayout.timelineHeight) if (nextLayout.assetsHeight > 0) { assetsPanelResizeRef.current?.resize(nextLayout.assetsHeight) } if (showPropertiesPanel) { rightPanelResizeRef.current?.resize(nextLayout.rightPanelWidth) } }, [showPropertiesPanel]) const updateLayoutField = useCallback(( field: 'leftPanelWidth' | 'rightPanelWidth' | 'timelineHeight' | 'assetsHeight', inPixels: number, ) => { const value = Math.round(inPixels) const currentLayout = selectLayout(getEditorState()) if (currentLayout[field] === value) return const next = { ...currentLayout, [field]: value } saveLayout(next) actions.setLayout(next) }, [actions, getEditorState]) const handleApplyLayout = useCallback((nextLayout: EditorLayout) => { actions.setLayout(nextLayout) saveLayout(nextLayout) requestAnimationFrame(() => applyLayoutToPanels(nextLayout)) }, [actions, applyLayoutToPanels]) const handleResetLayout = useCallback(() => { const nextLayout = { ...DEFAULT_LAYOUT } actions.resetLayout() saveLayout(nextLayout) requestAnimationFrame(() => applyLayoutToPanels(nextLayout)) }, [actions, applyLayoutToPanels]) const hasAppliedInitialLayoutRef = useRef(false) useLayoutEffect(() => { if (hasAppliedInitialLayoutRef.current) return hasAppliedInitialLayoutRef.current = true const currentLayout = selectLayout(getEditorState()) requestAnimationFrame(() => applyLayoutToPanels(currentLayout)) }, [applyLayoutToPanels, getEditorState]) const prevShowPropertiesPanelRef = useRef(showPropertiesPanel) useEffect(() => { if (showPropertiesPanel && !prevShowPropertiesPanelRef.current) { const currentLayout = selectLayout(getEditorState()) requestAnimationFrame(() => { rightPanelResizeRef.current?.resize(currentLayout.rightPanelWidth) }) } prevShowPropertiesPanelRef.current = showPropertiesPanel }, [getEditorState, showPropertiesPanel]) const { subtitleFileInputRef, handleImportSrt, handleExportSrt } = useSubtitleImportExport() const { handleExportTimelineXml } = useTimelineXmlExport() const selectedGapRef = useRef<{ trackIndex: number; startTime: number; endTime: number } | null>(null) const gapGenerateModeRef = useRef<'text-to-video' | 'image-to-video' | 'text-to-image' | null>(null) const clearSelectedGapRef = useRef<() => void>(() => {}) const closeSelectedGapRef = useRef<() => void>(() => {}) const openSourceAsset = useCallback((asset: Asset, opts?: { initialTime?: number; resetMarks?: boolean }) => { actions.setShowSourceMonitor(true) actions.setActiveFocusArea('source') if (sourceMonitorActionsRef.current) { sourceMonitorActionsRef.current.openAsset(asset, opts) return } requestAnimationFrame(() => sourceMonitorActionsRef.current?.openAsset(asset, opts)) }, [actions]) const pauseSourceMonitor = useCallback(() => { sourceMonitorActionsRef.current?.pause() }, []) const handleActivateSourceFocus = useCallback(() => { actions.setActiveFocusArea('source') }, [actions]) const handleActivateTimelineFocus = useCallback(() => { actions.setActiveFocusArea('timeline') }, [actions]) const handleSourcePreviewLayoutChanged = useCallback((nextLayout: Record) => { const nextSourceSplit = nextLayout['editor-source-monitor-panel'] if (typeof nextSourceSplit !== 'number') return if (Math.abs(selectSourceSplitPercent(getEditorState()) - nextSourceSplit) < 0.1) return actions.setSourceSplitPercent(nextSourceSplit) }, [actions, getEditorState]) const handleProgramMonitorMarkerDrag = useCallback((v: React.SetStateAction<'timelineIn' | 'timelineOut' | null>) => { markerDragOriginRef.current = 'scrubbar' setDraggingMarker(v) }, []) const handleActivateTimelinePanelFocus = useCallback(() => { if (selectActiveFocusArea(getEditorState()) !== 'timeline') { pauseSourceMonitor() actions.setActiveFocusArea('timeline') } }, [actions, getEditorState, pauseSourceMonitor]) useEffect(() => { if (isPlaying) pauseSourceMonitor() }, [isPlaying, pauseSourceMonitor]) const { fileInputRef, handleImportFile } = useEditorMediaImport({ currentProjectId, }) const selectedClip = useEditorStore(selectSelectedClipForProperties) const totalDuration = useEditorStore(selectTotalDuration) const pixelsPerSecond = useEditorStore(selectPixelsPerSecond) // Global mousemove/mouseup for dragging IN/OUT markers useEffect(() => { if (!draggingMarker) return const handleMouseMove = (e: MouseEvent) => { const marker = draggingMarkerRef.current if (!marker) return if (marker === 'timelineIn' || marker === 'timelineOut') { let time = 0 const origin = markerDragOriginRef.current const rulerEl = timelineRef.current const progScrub = document.getElementById('program-scrub-bar') if (origin === 'scrubbar' && progScrub) { const rect = progScrub.getBoundingClientRect() const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) time = pct * totalDuration } else if (rulerEl) { const rect = rulerEl.getBoundingClientRect() const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0 const px = e.clientX - rect.left + scrollLeft time = Math.max(0, px / pixelsPerSecond) } else if (progScrub) { const rect = progScrub.getBoundingClientRect() const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) time = pct * totalDuration } if (marker === 'timelineIn' && outPointRef.current !== null) { time = Math.min(time, outPointRef.current - 0.01) } if (marker === 'timelineOut' && inPointRef.current !== null) { time = Math.max(time, inPointRef.current + 0.01) } time = Math.max(0, Math.min(time, totalDuration)) if (marker === 'timelineIn') { setInPoint(() => time) } else { setOutPoint(() => time) } } } const handleMouseUp = () => { markerDragOriginRef.current = null setDraggingMarker(null) } window.addEventListener('mousemove', handleMouseMove) window.addEventListener('mouseup', handleMouseUp) return () => { window.removeEventListener('mousemove', handleMouseMove) window.removeEventListener('mouseup', handleMouseUp) } }, [draggingMarker, pixelsPerSecond, totalDuration, setInPoint, setOutPoint]) // Dynamic minimum zoom: at min zoom the whole timeline fits in view // Falls back to 0.05 if container isn't mounted yet const getMinZoom = useCallback(() => { const container = trackContainerRef.current if (!container || totalDuration <= 0) return 0.05 const containerWidth = container.clientWidth - 20 return Math.min(0.5, Math.max(0.01, containerWidth / (totalDuration * 100))) }, [totalDuration]) const getMinZoomRef = useRef(getMinZoom) getMinZoomRef.current = getMinZoom const hasMountedRef = useRef(false) useEffect(() => { if (!hasMountedRef.current) { hasMountedRef.current = true return } if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current) autoSaveTimerRef.current = setTimeout(() => { saveProject(updatedProject(currentProjectRef.current, editorModelRef.current)) }, AUTOSAVE_DELAY) return () => { if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current) } }, [editorModel, saveProject]) useEffect(() => { return () => { if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current) saveProject(updatedProject(currentProjectRef.current, editorModelRef.current)) } }, [saveProject]) // --- Core timeline logic --- const deleteAssetActionRef = useRef<() => void>(() => {}) const sourceDispatchRef = useRef<(action: SourceKeyboardAction) => void>(() => {}) const sourcePauseRef = useRef<() => void>(() => {}) const fitToViewRef = useRef<() => void>(() => {}) const toggleFullscreenRef = useRef<() => void>(() => {}) const insertEditRef = useRef<() => void>(() => {}) const overwriteEditRef = useRef<() => void>(() => {}) const matchFrameRef = useRef<() => void>(() => {}) // Regeneration hook (state + logic extracted) const { regeneratingAssetId, handleRegenerate, handleCancelRegeneration, regenerationPreError, dismissRegenerationPreError, } = useRegeneration({ projectId: currentProjectId, regenGenerate, regenGenerateImage, regenVideoPath, regenImagePath, isRegenerating, regenCancel, regenReset, regenError, canCancelInFlight: regenCanCancel, shouldVideoGenerateWithLtxApi, }) const canUseIcLora = !forceApiGenerations sourceDispatchRef.current = (action) => sourceMonitorActionsRef.current?.dispatchKeyboardAction(action) sourcePauseRef.current = () => sourceMonitorActionsRef.current?.pause() useEditorKeyboard({ refs: { kbLayoutRef, isKbEditorOpenRef, getState: getEditorState, playbackTimeRef, sourceDispatchRef, sourcePauseRef, centerOnPlayheadRef, getMinZoomRef, gapGenerateModeRef, clearSelectedGapRef, closeSelectedGapRef, fitToViewRef, toggleFullscreenRef, insertEditRef, overwriteEditRef, matchFrameRef, }, context: { deleteAssetActionRef, }, }) // Playback engine (extracted hook) usePlaybackEngine({ playbackTimeRef, }) usePlaybackAudioSync({ playbackTimeRef, }) deleteAssetActionRef.current = () => { assetsPanelActionsRef.current?.deleteAsset() } insertEditRef.current = () => sourceDispatchRef.current('edit.insertEdit') overwriteEditRef.current = () => sourceDispatchRef.current('edit.overwriteEdit') // --- Match Frame: load clip under playhead into source monitor at corresponding frame --- const handleMatchFrame = useCallback(() => { const editorState = getEditorState() const ct = selectCurrentTime(editorState) const timelineClips = selectClips(editorState) const currentSelectedClipIds = selectSelectedClipIds(editorState) // Find clips under the playhead const clipsUnderPlayhead = timelineClips.filter(c => ct >= c.startTime && ct < c.startTime + c.duration && (c.type === 'video' || c.type === 'audio' || c.type === 'image') ) if (clipsUnderPlayhead.length === 0) return // Prefer the selected clip if it's under the playhead, otherwise pick the topmost (lowest trackIndex = highest video track) let targetClip = clipsUnderPlayhead.find(c => currentSelectedClipIds.has(c.id)) if (!targetClip) { targetClip = clipsUnderPlayhead.sort((a, b) => a.trackIndex - b.trackIndex)[0] } // Find the source asset const asset = selectLiveAssetForClip(editorState, targetClip) if (!asset) return // Compute source time accounting for trim and speed const clipOffset = ct - targetClip.startTime const speed = targetClip.speed || 1 let srcTime: number if (targetClip.reversed) { const assetDuration = asset.duration || targetClip.duration srcTime = assetDuration - (targetClip.trimEnd || 0) - clipOffset * speed } else { srcTime = (targetClip.trimStart || 0) + clipOffset * speed } srcTime = Math.max(0, Math.min(srcTime, asset.duration || Infinity)) openSourceAsset(asset, { initialTime: srcTime, resetMarks: true }) }, [getEditorState, openSourceAsset]) matchFrameRef.current = handleMatchFrame const handleTimelineTabContextMenu = (e: React.MouseEvent, timelineId: string) => { e.preventDefault() window.dispatchEvent(new CustomEvent('video-editor:open-timeline-menu', { detail: { timelineId, x: e.clientX, y: e.clientY }, })) } // Extract a frame from a clip at the current playhead position // Returns a file:// URL (videos: extracted via ffmpeg, images: already on disk) const extractCurrentFrame = useCallback(async (clip: TimelineClip): Promise => { try { const editorState = getEditorState() const clipPath = selectClipPath(editorState, clip) if (!clipPath) return null if (clip.type === 'video') { const seekTime = Math.max(0, selectCurrentTime(editorState) - clip.startTime) * clip.speed + clip.trimStart const result = await window.electronAPI.extractVideoFrame({ videoPath: clipPath, seekTime, width: 1024, quality: 2 }) return result.path } return clipPath } catch (err) { logger.error(`Failed to extract frame: ${err}`) return null } }, [getEditorState]) // Capture a frame and send to Gen Space for video generation (I2V) const handleCaptureFrameForVideo = useCallback(async (clip: TimelineClip) => { const framePath = await extractCurrentFrame(clip) if (!framePath) return setGenSpaceEditMode('video') setGenSpaceEditImagePath(framePath) setCurrentTab('gen-space') }, [extractCurrentFrame, setGenSpaceEditImagePath, setGenSpaceEditMode, setCurrentTab]) // Send an image clip directly to Gen Space I2V mode const handleCreateVideoFromImage = useCallback((clip: TimelineClip) => { const imagePath = selectClipPath(getEditorState(), clip) if (!imagePath) return setGenSpaceEditMode('video') setGenSpaceEditImagePath(imagePath) setCurrentTab('gen-space') }, [getEditorState, setCurrentTab, setGenSpaceEditImagePath, setGenSpaceEditMode]) // Navigate to Gen Space with audio pre-populated for A2V const handleCreateVideoFromAudio = useCallback((clip: TimelineClip) => { const audioPath = selectClipPath(getEditorState(), clip) if (!audioPath) return setGenSpaceAudioPath(audioPath) setCurrentTab('gen-space') }, [getEditorState, setCurrentTab, setGenSpaceAudioPath]) const handleRetakeClip = useCallback((clip: TimelineClip) => { const liveAsset = selectLiveAssetForClip(getEditorState(), clip) if (!liveAsset) return const takeIndex = clip.takeIndex ?? liveAsset.activeTakeIndex let takePath = liveAsset.path if (liveAsset.takes && liveAsset.takes.length > 0 && takeIndex !== undefined) { const idx = Math.max(0, Math.min(takeIndex, liveAsset.takes.length - 1)) takePath = liveAsset.takes[idx].path } const linkedIds = new Set(clip.linkedClipIds || []) linkedIds.add(clip.id) setGenSpaceRetakeSource({ videoPath: takePath, clipId: clip.id, assetId: liveAsset.id, linkedClipIds: [...linkedIds], duration: clip.duration || liveAsset.duration, }) setCurrentTab('gen-space') }, [getEditorState, setCurrentTab, setGenSpaceRetakeSource]) const handleICLoraClip = useCallback((clip: TimelineClip) => { if (!canUseIcLora) return const liveAsset = selectLiveAssetForClip(getEditorState(), clip) if (!liveAsset) return const takeIndex = clip.takeIndex ?? liveAsset.activeTakeIndex let takePath = liveAsset.path if (liveAsset.takes && liveAsset.takes.length > 0 && takeIndex !== undefined) { const idx = Math.max(0, Math.min(takeIndex, liveAsset.takes.length - 1)) takePath = liveAsset.takes[idx].path } const linkedIds = new Set(clip.linkedClipIds || []) linkedIds.add(clip.id) setGenSpaceIcLoraSource({ videoPath: takePath, clipId: clip.id, assetId: liveAsset.id, linkedClipIds: [...linkedIds], }) setCurrentTab('gen-space') }, [canUseIcLora, getEditorState, setCurrentTab, setGenSpaceIcLoraSource]) // Populate fullscreen ref for keyboard handler toggleFullscreenRef.current = () => { programMonitorActionsRef.current?.toggleFullscreen() } // Menu bar definitions (extracted) const menuDefinitions: MenuDefinition[] = useBuildMenuDefinitions({ kbLayout, fileInputRef, subtitleFileInputRef, handleExportTimelineXml, handleExportSrt, handleInsertEdit: () => insertEditRef.current(), handleOverwriteEdit: () => overwriteEditRef.current(), handleMatchFrame: () => matchFrameRef.current(), setKbEditorOpen, fitToViewRef, canUseIcLora, onICLoraClip: handleICLoraClip, handleResetLayout, }) const renderProgramMonitorPane = (showHeader: boolean) => (
{showHeader && (
Timeline Viewer
)}
) // --- Render --- return (
{/* Menu Bar */} )} /> {/* Main Content */}
{ if (!prev) return updateLayoutField('leftPanelWidth', size.inPixels) }} > { if (!prev) return updateLayoutField('assetsHeight', size.inPixels) }} className="min-h-0" > {/* Main Editor Area */}
{/* Preview Area (optionally split into Clip Viewer + Timeline Viewer) */}
{showSourceMonitor ? (
{renderProgramMonitorPane(true)}
) : ( renderProgramMonitorPane(false) )}
{/* end split preview area */} {/* Timeline Info Bar (shuttle indicator only — timecode moved to ruler area) */} {shuttleSpeed !== 0 && (
{shuttleSpeed < 0 ? '◀' : '▶'}{' '}{Math.abs(shuttleSpeed)}x
)}
{ if (!prev) return updateLayoutField('timelineHeight', size.inPixels) }} className="min-h-0" >
assetsPanelActionsRef.current?.revealAsset(assetId)} onCreateVideoFromImage={handleCreateVideoFromImage} onCaptureFrameForVideo={handleCaptureFrameForVideo} onCreateVideoFromAudio={handleCreateVideoFromAudio} handleRegenerate={handleRegenerate} handleRetakeClip={handleRetakeClip} handleCancelRegeneration={handleCancelRegeneration} canCancelInFlight={regenCanCancel} isRegenerating={isRegenerating} regenProgress={regenProgress} />
{showPropertiesPanel && ( <> { if (!prev) return updateLayoutField('rightPanelWidth', size.inPixels) }} className="min-w-0" >
{/* Subtitle properties */} {selectedSubtitleId && selectedClipIds.size === 0 && (() => { const selectedSub = subtitles.find(s => s.id === selectedSubtitleId) if (!selectedSub) return null return ( ) })()} {/* Clip properties */} {selectedClip ? ( ) : !selectedSubtitleId ? (
No clip selected
) : null}
)}
{/* Export Modal */} {showExportModal && ( )} {showImportTimelineModal && ( )} {subtitleTrackStyleIdx !== null && ( )} {(regenError || regenerationPreError) && ( { if (regenError) regenReset() if (regenerationPreError) dismissRegenerationPreError() }} /> )}
) }