import { useState, useEffect, useCallback, useRef } from 'react' import { logger } from '../lib/logger' import { addGenericAssetToProject, addVisualAssetToProject } from '../lib/asset-copy' import { X, FileVideo, FileAudio, Image, Check, AlertTriangle, FolderOpen, RefreshCw, Loader2, FileText, Link2, ChevronDown, ChevronRight, Upload } from 'lucide-react' import type { ParsedTimeline, ParsedMediaRef } from '../lib/timeline-import' import { parseTimelineXml } from '../lib/timeline-import' import { selectShowImportTimelineModal } from '../views/editor/editor-selectors' import { useEditorActions, useEditorStore } from '../views/editor/editor-store' interface ImportTimelineModalProps { projectId: string | null } type ImportStep = 'select' | 'parsing' | 'relink' | 'error' type MediaImportStatus = 'missing' | 'ready' | 'copying' | 'copied' | 'skipped' | 'error' interface ImportProgressState { total: number completed: number skipped: number currentName: string } function getFilenameFromPath(filePath: string): string { const parts = filePath.split(/[/\\]/) return parts[parts.length - 1] || '' } export function ImportTimelineModal({ projectId }: ImportTimelineModalProps) { const { closeImportTimelineModal, importParsedTimeline } = useEditorActions() const isOpen = useEditorStore(selectShowImportTimelineModal) const [step, setStep] = useState('select') const [parsedTimeline, setParsedTimeline] = useState(null) const [mediaRefs, setMediaRefs] = useState([]) const [mediaStatus, setMediaStatus] = useState>({}) const [error, setError] = useState('') const [importError, setImportError] = useState('') const [expandedInfo, setExpandedInfo] = useState(true) const [expandedMedia, setExpandedMedia] = useState(true) const [isChecking, setIsChecking] = useState(false) const [isSearching, setIsSearching] = useState(false) const [isImporting, setIsImporting] = useState(false) const [importProgress, setImportProgress] = useState({ total: 0, completed: 0, skipped: 0, currentName: '', }) const fileInputRef = useRef(null) const closeModal = useCallback(() => { closeImportTimelineModal() }, [closeImportTimelineModal]) // Reset when modal opens useEffect(() => { if (!isOpen) return setStep('select') setParsedTimeline(null) setMediaRefs([]) setMediaStatus({}) setError('') setImportError('') setIsChecking(false) setIsSearching(false) setIsImporting(false) setImportProgress({ total: 0, completed: 0, skipped: 0, currentName: '' }) }, [isOpen]) const applyAvailabilityToStatus = useCallback((refs: ParsedMediaRef[], availabilityById: Record) => { const nextStatus: Record = {} for (const ref of refs) { nextStatus[ref.id] = availabilityById[ref.id] ? 'ready' : 'missing' } setMediaStatus(nextStatus) }, []) // Check file existence for all media refs const checkMediaFiles = useCallback(async (refs: ParsedMediaRef[]): Promise> => { const availabilityById: Record = {} if (!window.electronAPI?.checkFilesExist) { refs.forEach(ref => { availabilityById[ref.id] = false }) return availabilityById } setIsChecking(true) try { const uniquePaths = Array.from(new Set( refs .map(r => r.path?.trim()) .filter((p): p is string => Boolean(p)) )) const results = await window.electronAPI.checkFilesExist({ filePaths: uniquePaths }) refs.forEach(ref => { const path = ref.path?.trim() availabilityById[ref.id] = path ? (results[path] || false) : false }) return availabilityById } catch (err) { logger.error(`Error checking files: ${err}`) refs.forEach(ref => { availabilityById[ref.id] = false }) return availabilityById } finally { setIsChecking(false) } }, []) // Handle file selection const handleFileSelected = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return const filename = file.name const ext = filename.split('.').pop()?.toLowerCase() || '' // Check for AAF if (ext === 'aaf') { setError( 'AAF files cannot be imported directly.\n\n' + 'Please export your timeline as FCP 7 XML from your editing software:\n\n' + ' Premiere Pro: File → Export → Final Cut Pro XML\n' + ' DaVinci Resolve: File → Export Timeline → FCP 7 XML\n' + ' Avid Media Composer: File → Export → FCP 7 XML' ) setStep('error') return } setStep('parsing') setImportError('') try { const content = await file.text() const timeline = parseTimelineXml(content, filename) if (!timeline) { throw new Error('Could not parse timeline from file') } setParsedTimeline(timeline) setMediaRefs(timeline.mediaRefs) const availabilityById = await checkMediaFiles(timeline.mediaRefs) applyAvailabilityToStatus(timeline.mediaRefs, availabilityById) setStep('relink') } catch (err) { setError(err instanceof Error ? err.message : String(err)) setStep('error') } // Reset input if (fileInputRef.current) fileInputRef.current.value = '' }, [applyAvailabilityToStatus, checkMediaFiles]) // Relink a single media file const handleRelinkFile = useCallback(async (mediaRefId: string) => { if (isImporting || !window.electronAPI?.showOpenFileDialog) return const filePaths = await window.electronAPI.showOpenFileDialog({ title: 'Relink Media File', filters: [ { name: 'Media Files', extensions: ['mp4', 'mov', 'avi', 'mkv', 'mxf', 'mp3', 'wav', 'aac', 'flac', 'jpg', 'jpeg', 'png', 'tiff', 'exr', 'dpx'] }, { name: 'All Files', extensions: ['*'] }, ], }) if (!filePaths || filePaths.length === 0) return const newPath = filePaths[0] setImportError('') setMediaRefs(prev => prev.map(r => r.id === mediaRefId ? { ...r, path: newPath } : r )) setMediaStatus(prev => ({ ...prev, [mediaRefId]: 'ready' })) }, [isImporting]) // Search a directory for all missing files const handleSearchDirectory = useCallback(async () => { if (isImporting || !window.electronAPI?.showOpenDirectoryDialog) return const dir = await window.electronAPI.showOpenDirectoryDialog({ title: 'Select folder to search for media files' }) if (!dir) return setIsSearching(true) setImportError('') try { const missingRefs = mediaRefs.filter(r => mediaStatus[r.id] === 'missing') if (missingRefs.length === 0) return // Build list of filenames to search for const filenames: string[] = [] for (const ref of missingRefs) { const filename = ref.name || getFilenameFromPath(ref.path) if (filename) filenames.push(filename) } if (filenames.length === 0) return // Use recursive directory search (searches subdirectories up to 10 levels deep) if (window.electronAPI.searchDirectoryForFiles) { const results = await window.electronAPI.searchDirectoryForFiles({ directory: dir, filenames }) // results is { "filename.mp4" (lowercase): "C:\\full\\path\\filename.mp4" } setMediaRefs(prev => prev.map(ref => { if (mediaStatus[ref.id] !== 'missing') return ref const filename = ref.name || getFilenameFromPath(ref.path) const foundPath = filename ? results[filename.toLowerCase()] : undefined if (foundPath) { return { ...ref, path: foundPath } } return ref })) setMediaStatus(prev => { const next = { ...prev } for (const ref of missingRefs) { const filename = ref.name || getFilenameFromPath(ref.path) const foundPath = filename ? results[filename.toLowerCase()] : undefined if (foundPath) { next[ref.id] = 'ready' } } return next }) } else { // Fallback: check direct paths only (no recursive search) const searchPaths: string[] = [] for (const filename of filenames) { const separator = dir.includes('\\') ? '\\' : '/' searchPaths.push(`${dir}${separator}${filename}`) } const results = await window.electronAPI.checkFilesExist({ filePaths: searchPaths }) setMediaRefs(prev => prev.map(ref => { if (mediaStatus[ref.id] !== 'missing') return ref const filename = ref.name || getFilenameFromPath(ref.path) const separator = dir.includes('\\') ? '\\' : '/' const testPath = filename ? `${dir}${separator}${filename}` : '' if (testPath && results[testPath]) { return { ...ref, path: testPath } } return ref })) setMediaStatus(prev => { const next = { ...prev } for (const ref of missingRefs) { const filename = ref.name || getFilenameFromPath(ref.path) const separator = dir.includes('\\') ? '\\' : '/' const testPath = filename ? `${dir}${separator}${filename}` : '' if (testPath && results[testPath]) { next[ref.id] = 'ready' } } return next }) } } catch (err) { logger.error(`Error searching directory: ${err}`) } finally { setIsSearching(false) } }, [isImporting, mediaRefs, mediaStatus]) // Recheck all paths const handleRecheckAll = useCallback(async () => { if (isImporting) return const availabilityById = await checkMediaFiles(mediaRefs) applyAvailabilityToStatus(mediaRefs, availabilityById) }, [applyAvailabilityToStatus, checkMediaFiles, isImporting, mediaRefs]) // Confirm import const handleConfirmImport = useCallback(async () => { if (!parsedTimeline || isImporting) return if (!projectId) { setImportError('Cannot import timeline: no active project.') return } setImportError('') setIsImporting(true) const refsToImport = mediaRefs.map(ref => ({ ...ref })) const copiedAssetBySource = new Map() const total = refsToImport.length let completed = 0 let skipped = 0 setImportProgress({ total, completed: 0, skipped: 0, currentName: '' }) try { for (const ref of refsToImport) { const sourcePath = ref.path?.trim() || '' const displayName = ref.name || getFilenameFromPath(sourcePath) || 'Unnamed media' const currentStatus = mediaStatus[ref.id] || 'missing' setImportProgress({ total, completed, skipped, currentName: displayName, }) if (!sourcePath || currentStatus === 'missing') { skipped += 1 completed += 1 setMediaStatus(prev => ({ ...prev, [ref.id]: 'skipped' })) setImportProgress({ total, completed, skipped, currentName: displayName, }) continue } const existingCopiedAsset = copiedAssetBySource.get(sourcePath) if (existingCopiedAsset) { ref.path = existingCopiedAsset.path ref.bigThumbnailPath = existingCopiedAsset.bigThumbnailPath ref.smallThumbnailPath = existingCopiedAsset.smallThumbnailPath ref.width = existingCopiedAsset.width ref.height = existingCopiedAsset.height completed += 1 setMediaStatus(prev => ({ ...prev, [ref.id]: 'copied' })) setMediaRefs(prev => prev.map(item => ( item.id === ref.id ? { ...item, path: existingCopiedAsset.path, bigThumbnailPath: existingCopiedAsset.bigThumbnailPath, smallThumbnailPath: existingCopiedAsset.smallThumbnailPath, width: existingCopiedAsset.width, height: existingCopiedAsset.height, } : item ))) setImportProgress({ total, completed, skipped, currentName: displayName, }) continue } setMediaStatus(prev => ({ ...prev, [ref.id]: 'copying' })) let copiedAsset: { path: string bigThumbnailPath?: string smallThumbnailPath?: string width?: number height?: number } | null = null if (ref.type === 'video' || ref.type === 'image') { const copied = await addVisualAssetToProject(sourcePath, projectId, ref.type) if (!copied) { setMediaStatus(prev => ({ ...prev, [ref.id]: 'error' })) throw new Error(`Failed to copy media into project assets: ${displayName}`) } copiedAsset = copied } else { const copied = await addGenericAssetToProject(sourcePath, projectId) if (!copied?.path) { setMediaStatus(prev => ({ ...prev, [ref.id]: 'error' })) throw new Error(`Failed to copy media into project assets: ${displayName}`) } copiedAsset = { path: copied.path } } ref.path = copiedAsset.path ref.bigThumbnailPath = copiedAsset.bigThumbnailPath ref.smallThumbnailPath = copiedAsset.smallThumbnailPath ref.width = copiedAsset.width ref.height = copiedAsset.height copiedAssetBySource.set(sourcePath, copiedAsset) completed += 1 setMediaStatus(prev => ({ ...prev, [ref.id]: 'copied' })) setMediaRefs(prev => prev.map(item => ( item.id === ref.id ? { ...item, path: copiedAsset.path, bigThumbnailPath: copiedAsset.bigThumbnailPath, smallThumbnailPath: copiedAsset.smallThumbnailPath, width: copiedAsset.width, height: copiedAsset.height, } : item ))) setImportProgress({ total, completed, skipped, currentName: displayName, }) } // Update timeline refs with copied/updated paths const updatedTimeline: ParsedTimeline = { ...parsedTimeline, mediaRefs: refsToImport, } importParsedTimeline(updatedTimeline) closeModal() } catch (err) { const message = err instanceof Error ? err.message : String(err) setImportError(message) logger.error(`Import timeline copy failed: ${message}`) } finally { setIsImporting(false) } }, [closeModal, importParsedTimeline, isImporting, mediaRefs, mediaStatus, parsedTimeline, projectId]) const readyCount = mediaRefs.filter(r => (mediaStatus[r.id] || 'missing') !== 'missing').length const totalCount = mediaRefs.length const allFound = readyCount === totalCount && totalCount > 0 const interactionDisabled = isImporting const importPercent = importProgress.total > 0 ? (importProgress.completed / importProgress.total) * 100 : 0 return (
{/* Header */}

Import Timeline

Premiere Pro XML, DaVinci Resolve XML, Final Cut Pro XML/FCPXML

{/* Body */}
{/* Step 1: File Selection */} {step === 'select' && (
fileInputRef.current?.click()} className="border-2 border-dashed border-zinc-700 hover:border-blue-500/50 rounded-xl p-10 text-center cursor-pointer transition-colors group" >

Click to select timeline file

Supports .xml (FCP 7 XML), .fcpxml

How to export from your NLE:

Premiere Pro: File → Export → Final Cut Pro XML

DaVinci Resolve: File → Export Timeline → FCP 7 XML (.xml)

Final Cut Pro: File → Export XML

AAF files are binary and cannot be imported directly. Please export as XML instead.

)} {/* Parsing indicator */} {step === 'parsing' && (

Parsing timeline...

)} {/* Error */} {step === 'error' && (

Import Error

{error}

)} {/* Step 2: Relink media */} {step === 'relink' && parsedTimeline && (
{/* Timeline info */}
{expandedInfo && (
Name: {parsedTimeline.name}
Format: {parsedTimeline.format === 'fcp7xml' ? 'FCP 7 XML' : parsedTimeline.format === 'fcpxml' ? 'FCPXML' : parsedTimeline.format}
FPS: {parsedTimeline.fps.toFixed(2)}
Duration: {parsedTimeline.duration.toFixed(1)}s
{parsedTimeline.width && parsedTimeline.height && (
Resolution: {parsedTimeline.width}x{parsedTimeline.height}
)}
Clips: {parsedTimeline.clips.length}
Video Tracks: {parsedTimeline.videoTrackCount}
Audio Tracks: {parsedTimeline.audioTrackCount}
)}
{/* Media files */}
{/* Availability bar */}
0 ? 'bg-amber-500' : 'bg-red-500'}`} style={{ width: `${totalCount > 0 ? (readyCount / totalCount) * 100 : 0}%` }} />
{expandedMedia && (
{mediaRefs.map((ref, i) => { const TypeIcon = ref.type === 'video' ? FileVideo : ref.type === 'audio' ? FileAudio : Image const status = mediaStatus[ref.id] || 'missing' const isPositive = status === 'ready' || status === 'copied' const isMissing = status === 'missing' const isCopying = status === 'copying' const isSkipped = status === 'skipped' const isErrored = status === 'error' return (
{isCopying ? ( ) : isPositive ? ( ) : isSkipped ? ( ) : ( )} {ref.name}

{ref.path || '(no path)'}

{isMissing && ( )}
) })} {mediaRefs.length === 0 && (
No media files referenced in this timeline.
)}
)}
)}
{/* Footer */} {step === 'relink' && ( <>
{importError && ( {importError} )} {!importError && !allFound && totalCount > 0 && ( {totalCount - readyCount} missing file{totalCount - readyCount !== 1 ? 's' : ''} — clips with missing media will be placeholders )} {!importError && allFound && totalCount > 0 && ( All media files found )} {!importError && totalCount === 0 && ( No media references to link )}
{isImporting && (
Copying media {importProgress.completed}/{importProgress.total} {importProgress.skipped} skipped
{importProgress.currentName && (

{importProgress.currentName}

)}
)} )}
) }