1012 lines
40 KiB
TypeScript
1012 lines
40 KiB
TypeScript
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<EditorStoreApi | null>(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 (
|
|
<EditorStoreProvider store={editorStore}>
|
|
<VideoEditorWithStore
|
|
currentProject={props.currentProject}
|
|
saveProject={props.saveProject}
|
|
/>
|
|
</EditorStoreProvider>
|
|
)
|
|
}
|
|
|
|
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<PanelImperativeHandle | null>(null)
|
|
const rightPanelResizeRef = useRef<PanelImperativeHandle | null>(null)
|
|
const timelinePanelResizeRef = useRef<PanelImperativeHandle | null>(null)
|
|
const assetsPanelResizeRef = useRef<PanelImperativeHandle | null>(null)
|
|
const assetsPanelActionsRef = useRef<VideoEditorAssetsPanelHandle | null>(null)
|
|
const sourceMonitorActionsRef = useRef<VideoEditorSourceMonitorHandle | null>(null)
|
|
const programMonitorActionsRef = useRef<ProgramMonitorHandle | null>(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<HTMLDivElement>(null)
|
|
const trackContainerRef = useRef<HTMLDivElement>(null)
|
|
const trackHeadersRef = useRef<HTMLDivElement>(null)
|
|
const rulerScrollRef = useRef<HTMLDivElement>(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<ReturnType<typeof setTimeout> | 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<string, number>) => {
|
|
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<string | null> => {
|
|
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) => (
|
|
<div
|
|
className={`flex h-full min-h-0 min-w-0 flex-col ${activeFocusArea === 'timeline' ? 'ring-2 ring-blue-500 ring-inset' : ''}`}
|
|
onMouseDown={handleActivateTimelineFocus}
|
|
>
|
|
{showHeader && (
|
|
<div className="h-7 bg-zinc-900 border-b border-zinc-800 flex items-center px-3 flex-shrink-0">
|
|
<span className="text-[11px] font-semibold text-zinc-400 tracking-wide">Timeline Viewer</span>
|
|
</div>
|
|
)}
|
|
<ProgramMonitor
|
|
ref={programMonitorActionsRef}
|
|
playbackTimeRef={playbackTimeRef}
|
|
setDraggingMarker={handleProgramMonitorMarkerDrag}
|
|
kbLayout={kbLayout}
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
// --- Render ---
|
|
|
|
return (
|
|
<div className="h-full flex flex-col overflow-hidden">
|
|
{/* Menu Bar */}
|
|
<MenuBar
|
|
menus={menuDefinitions}
|
|
rightContent={(
|
|
<VideoEditorLayoutMenu
|
|
currentLayout={layout}
|
|
onApplyLayout={handleApplyLayout}
|
|
onResetLayout={handleResetLayout}
|
|
/>
|
|
)}
|
|
/>
|
|
{/* Main Content */}
|
|
<div className="flex-1 overflow-hidden min-h-0">
|
|
<Group orientation="horizontal" className="h-full w-full">
|
|
<Panel
|
|
id="editor-left-panel"
|
|
panelRef={leftPanelResizeRef}
|
|
defaultSize={initialPanelDefaultsRef.current.leftPanelWidth}
|
|
minSize={LAYOUT_LIMITS.leftPanelWidth.min}
|
|
maxSize={LAYOUT_LIMITS.leftPanelWidth.max}
|
|
groupResizeBehavior="preserve-pixel-size"
|
|
onResize={(size, _id, prev) => {
|
|
if (!prev) return
|
|
updateLayoutField('leftPanelWidth', size.inPixels)
|
|
}}
|
|
>
|
|
<Group orientation="vertical" className="h-full">
|
|
<Panel
|
|
id="editor-assets-panel"
|
|
panelRef={assetsPanelResizeRef}
|
|
defaultSize={initialPanelDefaultsRef.current.assetsHeight}
|
|
minSize={LAYOUT_LIMITS.assetsHeight.min}
|
|
maxSize={LAYOUT_LIMITS.assetsHeight.max}
|
|
groupResizeBehavior="preserve-pixel-size"
|
|
onResize={(size, _id, prev) => {
|
|
if (!prev) return
|
|
updateLayoutField('assetsHeight', size.inPixels)
|
|
}}
|
|
className="min-h-0"
|
|
>
|
|
<VideoEditorAssetsPanel
|
|
ref={assetsPanelActionsRef}
|
|
openSourceAsset={openSourceAsset}
|
|
handleImportFile={handleImportFile}
|
|
handleRegenerate={handleRegenerate}
|
|
handleCancelRegeneration={handleCancelRegeneration}
|
|
canCancelInFlight={regenCanCancel}
|
|
isRegenerating={isRegenerating}
|
|
regeneratingAssetId={regeneratingAssetId}
|
|
regenProgress={regenProgress}
|
|
regenStatusMessage={regenStatusMessage}
|
|
/>
|
|
</Panel>
|
|
<Separator className="h-1 flex-shrink-0 cursor-row-resize bg-transparent hover:bg-blue-500/40 active:bg-blue-500/60 transition-colors relative z-10" />
|
|
<Panel minSize={LAYOUT_LIMITS.assetsHeight.min} className="min-h-0">
|
|
<VideoEditorTimelineControlPanel
|
|
handleTimelineTabContextMenu={handleTimelineTabContextMenu}
|
|
/>
|
|
</Panel>
|
|
</Group>
|
|
</Panel>
|
|
<Separator className="w-1 flex-shrink-0 cursor-col-resize bg-transparent hover:bg-blue-500/40 active:bg-blue-500/60 transition-colors relative z-10" />
|
|
<Panel id="editor-main-panel" className="min-w-0">
|
|
{/* Main Editor Area */}
|
|
<div className="h-full min-w-0 flex flex-col overflow-hidden">
|
|
<Group orientation="vertical" className="h-full min-h-0">
|
|
<Panel minSize={20} className="min-h-0">
|
|
<div className="h-full min-h-0 flex flex-col">
|
|
{/* Preview Area (optionally split into Clip Viewer + Timeline Viewer) */}
|
|
<div className="flex-1 min-h-0 min-w-0">
|
|
{showSourceMonitor ? (
|
|
<Group
|
|
id="editor-preview-group"
|
|
orientation="horizontal"
|
|
className="h-full w-full"
|
|
defaultLayout={{
|
|
'editor-source-monitor-panel': sourceSplitPercent,
|
|
'editor-program-monitor-panel': 100 - sourceSplitPercent,
|
|
}}
|
|
onLayoutChanged={handleSourcePreviewLayoutChanged}
|
|
>
|
|
<Panel
|
|
id="editor-source-monitor-panel"
|
|
defaultSize={`${sourceSplitPercent}%`}
|
|
minSize="20%"
|
|
maxSize="80%"
|
|
className="min-h-0 min-w-0"
|
|
>
|
|
<div
|
|
className={`flex h-full min-h-0 ${activeFocusArea === 'source' ? 'ring-2 ring-blue-500 ring-inset' : 'border-r border-zinc-800'}`}
|
|
onMouseDown={handleActivateSourceFocus}
|
|
>
|
|
<VideoEditorSourceMonitor
|
|
ref={sourceMonitorActionsRef}
|
|
/>
|
|
</div>
|
|
</Panel>
|
|
<Separator className="w-1.5 cursor-col-resize bg-transparent hover:bg-blue-500/40 active:bg-blue-500/60 transition-colors relative z-10" />
|
|
<Panel
|
|
id="editor-program-monitor-panel"
|
|
defaultSize={`${100 - sourceSplitPercent}%`}
|
|
minSize="20%"
|
|
className="min-h-0 min-w-0"
|
|
>
|
|
{renderProgramMonitorPane(true)}
|
|
</Panel>
|
|
</Group>
|
|
) : (
|
|
renderProgramMonitorPane(false)
|
|
)}
|
|
</div> {/* end split preview area */}
|
|
|
|
{/* Timeline Info Bar (shuttle indicator only — timecode moved to ruler area) */}
|
|
{shuttleSpeed !== 0 && (
|
|
<div className="h-6 bg-zinc-900 border-t border-zinc-800 flex items-center px-4">
|
|
<div className={`px-2 py-0.5 rounded text-xs font-mono font-bold ${
|
|
shuttleSpeed < 0 ? 'bg-orange-600/20 text-orange-400' : 'bg-blue-600/20 text-blue-400'
|
|
}`}>
|
|
{shuttleSpeed < 0 ? '◀' : '▶'}{' '}{Math.abs(shuttleSpeed)}x
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
<Separator className="h-1 flex-shrink-0 cursor-row-resize bg-transparent hover:bg-blue-500/40 active:bg-blue-500/60 transition-colors relative z-10" />
|
|
<Panel
|
|
id="editor-timeline-panel"
|
|
panelRef={timelinePanelResizeRef}
|
|
defaultSize={initialPanelDefaultsRef.current.timelineHeight}
|
|
minSize={LAYOUT_LIMITS.timelineHeight.min}
|
|
maxSize={LAYOUT_LIMITS.timelineHeight.max}
|
|
groupResizeBehavior="preserve-pixel-size"
|
|
onResize={(size, _id, prev) => {
|
|
if (!prev) return
|
|
updateLayoutField('timelineHeight', size.inPixels)
|
|
}}
|
|
className="min-h-0"
|
|
>
|
|
<div className="h-full min-h-0" onMouseDown={handleActivateTimelinePanelFocus}>
|
|
<VideoEditorTimelineEditingPanel
|
|
currentProjectId={currentProjectId}
|
|
playbackTimeRef={playbackTimeRef}
|
|
centerOnPlayheadRef={centerOnPlayheadRef}
|
|
getMinZoom={getMinZoom}
|
|
canUseIcLora={canUseIcLora}
|
|
handleICLoraClip={handleICLoraClip}
|
|
kbLayout={kbLayout}
|
|
handleExportTimelineXml={handleExportTimelineXml}
|
|
subtitleFileInputRef={subtitleFileInputRef}
|
|
handleImportSrt={handleImportSrt}
|
|
handleExportSrt={handleExportSrt}
|
|
gapGenerationApi={gapGenerationApi}
|
|
selectedGapRefBridge={selectedGapRef}
|
|
gapGenerateModeRefBridge={gapGenerateModeRef}
|
|
clearSelectedGapRefBridge={clearSelectedGapRef}
|
|
closeSelectedGapRefBridge={closeSelectedGapRef}
|
|
timelineRefBridge={timelineRef}
|
|
trackContainerRefBridge={trackContainerRef}
|
|
trackHeadersRefBridge={trackHeadersRef}
|
|
rulerScrollRefBridge={rulerScrollRef}
|
|
markerDragOriginRef={markerDragOriginRef}
|
|
setDraggingMarker={setDraggingMarker}
|
|
bladeShiftHeld={bladeShiftHeld}
|
|
onTimelinePanelContextMenu={handleTimelineTabContextMenu}
|
|
fitToViewRef={fitToViewRef}
|
|
onRevealAsset={(assetId) => assetsPanelActionsRef.current?.revealAsset(assetId)}
|
|
onCreateVideoFromImage={handleCreateVideoFromImage}
|
|
onCaptureFrameForVideo={handleCaptureFrameForVideo}
|
|
onCreateVideoFromAudio={handleCreateVideoFromAudio}
|
|
handleRegenerate={handleRegenerate}
|
|
handleRetakeClip={handleRetakeClip}
|
|
handleCancelRegeneration={handleCancelRegeneration}
|
|
canCancelInFlight={regenCanCancel}
|
|
isRegenerating={isRegenerating}
|
|
regenProgress={regenProgress}
|
|
/>
|
|
</div>
|
|
</Panel>
|
|
</Group>
|
|
</div>
|
|
</Panel>
|
|
{showPropertiesPanel && (
|
|
<>
|
|
<Separator className="w-1 flex-shrink-0 cursor-col-resize bg-transparent hover:bg-blue-500/40 active:bg-blue-500/60 transition-colors relative z-10" />
|
|
<Panel
|
|
id="editor-properties-panel"
|
|
panelRef={rightPanelResizeRef}
|
|
defaultSize={initialPanelDefaultsRef.current.rightPanelWidth}
|
|
minSize={LAYOUT_LIMITS.rightPanelWidth.min}
|
|
maxSize={LAYOUT_LIMITS.rightPanelWidth.max}
|
|
groupResizeBehavior="preserve-pixel-size"
|
|
onResize={(size, _id, prev) => {
|
|
if (!prev) return
|
|
updateLayoutField('rightPanelWidth', size.inPixels)
|
|
}}
|
|
className="min-w-0"
|
|
>
|
|
<div className="relative h-full group">
|
|
<Tooltip content="Collapse Properties Panel" side="left">
|
|
<button
|
|
className="absolute top-1/2 -translate-y-1/2 -left-3 w-6 h-8 bg-zinc-800 border border-zinc-700 rounded-l-md flex items-center justify-center text-zinc-400 hover:text-white hover:bg-zinc-700 transition-colors opacity-0 group-hover:opacity-100 z-20 cursor-pointer"
|
|
onClick={() => actions.setShowPropertiesPanel(false)}
|
|
>
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</button>
|
|
</Tooltip>
|
|
{/* Subtitle properties */}
|
|
{selectedSubtitleId && selectedClipIds.size === 0 && (() => {
|
|
const selectedSub = subtitles.find(s => s.id === selectedSubtitleId)
|
|
if (!selectedSub) return null
|
|
return (
|
|
<SubtitlePropertiesPanel
|
|
/>
|
|
)
|
|
})()}
|
|
{/* Clip properties */}
|
|
{selectedClip ? (
|
|
<ClipPropertiesPanel
|
|
onCreateVideoFromImage={handleCreateVideoFromImage}
|
|
/>
|
|
) : !selectedSubtitleId ? (
|
|
<div className="h-full bg-zinc-950 border-l border-zinc-800 flex flex-col items-center justify-center text-zinc-600 text-[12px]">
|
|
<span>No clip selected</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</Panel>
|
|
</>
|
|
)}
|
|
</Group>
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="video/*,audio/*,image/*"
|
|
multiple
|
|
onChange={handleImportFile}
|
|
className="hidden"
|
|
/>
|
|
|
|
{/* Export Modal */}
|
|
|
|
{showExportModal && (
|
|
<ExportModal
|
|
projectName={currentProject?.name || 'Untitled'}
|
|
/>
|
|
)}
|
|
|
|
{showImportTimelineModal && (
|
|
<ImportTimelineModal
|
|
projectId={currentProjectId}
|
|
/>
|
|
)}
|
|
|
|
{subtitleTrackStyleIdx !== null && (
|
|
<SubtitleTrackStyleEditor />
|
|
)}
|
|
|
|
{(regenError || regenerationPreError) && (
|
|
<GenerationErrorDialog
|
|
error={(regenError || (regenerationPreError ? createLocalGenerationError(regenerationPreError) : null))!}
|
|
onDismiss={() => {
|
|
if (regenError) regenReset()
|
|
if (regenerationPreError) dismissRegenerationPreError()
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|