import { useState, useRef, useEffect, useCallback } from 'react' import { Upload, Loader2, Film, Sparkles, Image as ImageIcon, RefreshCw, Download, AlertCircle, Trash2, } from 'lucide-react' import { ApiClient, type ApiRequestBodyOf, type ApiSuccessOf } from '../lib/api-client' import { logger } from '../lib/logger' import { pathToFileUrl } from '../lib/file-url' import { OutpaintCanvasEditor, type OutpaintPads } from './OutpaintCanvasEditor' export type ICLoraConditioningType = 'canny' | 'depth' | 'custom' interface ICLoraPanelProps { initialVideoPath?: string | null resetKey?: number fillHeight?: boolean isProcessing?: boolean processingStatus?: string // IC-LoRA mode: 'image' accepts a still image and skips canny/depth preprocessing // (the IC-LoRA builds the control video server-side). Default 'video' = today's behavior. inputKind?: 'image' | 'video' // IC-LoRA mode: when an IC-LoRA is selected (via the library modal) the panel switches to // IC-LoRA mode and hides the canny/depth conditioning preview. The catalog/download UI // lives in the modal, not here. selectedIcLoraId?: string | null // Catalog IC-LoRA opt-in: when true the middle column offers an optional reference-image // picker (instead of the canny/depth conditioning preview, which catalog IC-LoRAs hide). allowsReferenceImage?: boolean // Outpainting (position_canvas control): when true the middle column shows the canvas editor. showOutpaintCanvas?: boolean outpaintPads?: OutpaintPads onOutpaintPadsChange?: (pads: OutpaintPads) => void // Library browsing (and catalog IC-LoRA generation in general) is local-only. isLocalMode?: boolean onBrowseLibrary?: () => void conditioningType?: ICLoraConditioningType onConditioningTypeChange?: (type: ICLoraConditioningType) => void conditioningStrength?: number onConditioningStrengthChange?: (strength: number) => void outputVideoPath?: string | null onChange?: (data: { videoPath: string | null conditioningType: ICLoraConditioningType conditioningStrength: number ready: boolean referenceImagePath: string | null // Source pixel dimensions (0 until media loads) — feeds the resolution control. width: number height: number }) => void } export const CONDITIONING_TYPES: { value: ICLoraConditioningType; label: string; desc: string }[] = [ { value: 'canny', label: 'Canny Edges', desc: 'Edge detection' }, { value: 'depth', label: 'Depth Map', desc: 'Estimated depth' }, { value: 'custom', label: 'Custom IC-LoRA', desc: 'Your own weights + control video' }, ] type StartModelDownloadBody = NonNullable> type ModelCheckpointID = NonNullable[number] type DownloadProgress = ApiSuccessOf<'getModelDownloadProgress'> export function ICLoraPanel({ initialVideoPath, resetKey, fillHeight = false, isProcessing = false, processingStatus = '', inputKind = 'video', selectedIcLoraId = null, allowsReferenceImage = false, showOutpaintCanvas = false, outpaintPads, onOutpaintPadsChange, isLocalMode = false, onBrowseLibrary, conditioningType: conditioningTypeProp, onConditioningTypeChange, conditioningStrength: conditioningStrengthProp, onConditioningStrengthChange, outputVideoPath: _outputVideoPath, onChange, }: ICLoraPanelProps) { const inputVideoRef = useRef(null) const [inputVideoPath, setInputVideoPath] = useState(initialVideoPath || null) const inputVideoUrl = inputVideoPath ? pathToFileUrl(inputVideoPath) : null const [inputTime, setInputTime] = useState(0) // Source pixel dimensions, read off the loaded media — drives the outpaint canvas editor. const [sourceDims, setSourceDims] = useState<{ w: number; h: number } | null>(null) // Optional reference image for catalog IC-LoRAs that allow it (frame 0, strength 1.0). const [referenceImagePath, setReferenceImagePath] = useState(null) const referenceImageUrl = referenceImagePath ? pathToFileUrl(referenceImagePath) : null const [isReferenceDragOver, setIsReferenceDragOver] = useState(false) const [internalCondType, setInternalCondType] = useState('canny') const [internalCondStrength, setInternalCondStrength] = useState(1.0) const conditioningType = conditioningTypeProp ?? internalCondType const conditioningStrength = conditioningStrengthProp ?? internalCondStrength // Custom mode: the user supplies their own IC-LoRA + a pre-rendered control video, // so there's no canny/depth preprocessing to preview. const isCustom = conditioningType === 'custom' // Image (IC-LoRA) mode: no canny/depth preprocessing, no bundled-cp download gate. const isImage = inputKind === 'image' // Catalog IC-LoRA mode (any input kind): the IC-LoRA builds its own control video server-side. const isCatalogIcLora = selectedIcLoraId !== null // The conditioning preview only applies to the built-in canny/depth flow. const showConditioningPreview = !isCatalogIcLora && !isCustom const [conditioningPreview, setConditioningPreview] = useState(null) const [isExtracting, setIsExtracting] = useState(false) const [requiredIcLoraCpIds, setRequiredIcLoraCpIds] = useState([]) const [icLoraSupported, setIcLoraSupported] = useState(true) const [isCheckingIcLora, setIsCheckingIcLora] = useState(false) const [isDownloadingIcLora, setIsDownloadingIcLora] = useState(false) const [downloadProgress, setDownloadProgress] = useState(null) const [downloadError, setDownloadError] = useState(null) const [downloadSessionId, setDownloadSessionId] = useState(null) const [extractError, setExtractError] = useState(null) const [isDragOver, setIsDragOver] = useState(false) const icLoraReady = icLoraSupported && requiredIcLoraCpIds.length === 0 // Switching to an entry with a different input.kind (image vs video) invalidates the loaded // input — clear it so a stale video can't be submitted to an image-input entry (backend 400). const prevInputKindRef = useRef(inputKind) useEffect(() => { if (prevInputKindRef.current === inputKind) return prevInputKindRef.current = inputKind setInputVideoPath(null) setInputTime(0) setSourceDims(null) setConditioningPreview(null) setExtractError(null) }, [inputKind]) useEffect(() => { if (resetKey === undefined) return setInputVideoPath(initialVideoPath || null) setInputTime(0) setSourceDims(null) setInternalCondType('canny') setInternalCondStrength(1.0) onConditioningTypeChange?.('canny') onConditioningStrengthChange?.(1.0) setConditioningPreview(null) setExtractError(null) setReferenceImagePath(null) }, [resetKey, initialVideoPath]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { // Custom brings its own IC-LoRA + control video; recipes (any input kind) build the // control video server-side. Neither needs the bundled canny/depth cps. const ready = !!inputVideoPath && (isCustom || isImage || isCatalogIcLora || icLoraReady) onChange?.({ videoPath: inputVideoPath, conditioningType, conditioningStrength, ready, referenceImagePath, width: sourceDims?.w ?? 0, height: sourceDims?.h ?? 0, }) }, [inputVideoUrl, inputVideoPath, conditioningType, conditioningStrength, isCustom, isImage, isCatalogIcLora, icLoraReady, referenceImagePath, sourceDims, onChange]) const checkIcLoraAvailability = useCallback(async () => { setIsCheckingIcLora(true) const result = await ApiClient.getLtxIcLoraRecommendation() if (!result.ok) { logger.warn(`Failed to fetch IC-LoRA model status: ${result.error.message}`) setDownloadError(result.error.message) setIsCheckingIcLora(false) return } const recommendationPayload = result.data setRequiredIcLoraCpIds(recommendationPayload.cps_to_download) setIcLoraSupported(recommendationPayload.supported) const isReady = recommendationPayload.supported && recommendationPayload.cps_to_download.length === 0 if (isReady) { setIsDownloadingIcLora(false) setDownloadProgress(null) setDownloadError(null) setDownloadSessionId(null) } setIsCheckingIcLora(false) }, []) useEffect(() => { void checkIcLoraAvailability() }, [checkIcLoraAvailability]) useEffect(() => { if (icLoraReady || !isDownloadingIcLora || !downloadSessionId) return const pollProgress = async () => { const result = await ApiClient.getModelDownloadProgress({ sessionId: downloadSessionId }) if (!result.ok) { logger.warn(`Failed polling IC-LoRA download progress: ${result.error.message}`) return } const progressPayload = result.data setDownloadProgress(progressPayload) if (progressPayload.status === 'error') { setIsDownloadingIcLora(false) setDownloadError(progressPayload.error || 'Model download failed') return } if (progressPayload.status === 'complete') { setIsDownloadingIcLora(false) await checkIcLoraAvailability() } } void pollProgress() const interval = setInterval(() => { void pollProgress() }, 1000) return () => clearInterval(interval) }, [icLoraReady, isDownloadingIcLora, downloadSessionId, checkIcLoraAvailability]) const handleDownloadIcLora = useCallback(async () => { if (isDownloadingIcLora) return setDownloadError(null) const result = await ApiClient.startModelDownload({ type: 'download', cp_ids: [...requiredIcLoraCpIds], }) if (!result.ok) { logger.warn(`Failed to start IC-LoRA download: ${result.error.message}`) setDownloadError(result.error.message) return } const startedPayload = result.data if (startedPayload.status === 'started') { setDownloadSessionId(startedPayload.sessionId) setIsDownloadingIcLora(true) return } setDownloadError('Unexpected response while starting IC-LoRA download') }, [isDownloadingIcLora, requiredIcLoraCpIds]) const isExtractingRef = useRef(false) const extractConditioning = useCallback(async () => { if (isCustom || isImage || isCatalogIcLora || !inputVideoPath || isExtractingRef.current || !icLoraReady) return isExtractingRef.current = true setIsExtracting(true) setExtractError(null) const result = await ApiClient.extractIcLoraConditioning({ video_path: inputVideoPath, conditioning_type: conditioningType, frame_time: inputTime, }) if (!result.ok) { logger.warn(`Failed to extract conditioning: ${result.error.message}`) setExtractError(result.error.message) isExtractingRef.current = false setIsExtracting(false) return } setConditioningPreview(result.data.conditioning) isExtractingRef.current = false setIsExtracting(false) }, [inputVideoPath, conditioningType, inputTime, icLoraReady, isCustom, isImage, isCatalogIcLora]) const extractTimerRef = useRef | null>(null) useEffect(() => { if (isCustom || isImage || isCatalogIcLora || !inputVideoPath || !icLoraReady) return if (extractTimerRef.current) clearTimeout(extractTimerRef.current) extractTimerRef.current = setTimeout(() => { void extractConditioning() }, 300) return () => { if (extractTimerRef.current) clearTimeout(extractTimerRef.current) } }, [inputTime, conditioningType, inputVideoPath, icLoraReady, extractConditioning]) useEffect(() => { const video = inputVideoRef.current if (!video) return const onTime = () => setInputTime(video.currentTime) const onSeeked = () => setInputTime(video.currentTime) video.addEventListener('timeupdate', onTime) video.addEventListener('seeked', onSeeked) return () => { video.removeEventListener('timeupdate', onTime) video.removeEventListener('seeked', onSeeked) } }, [inputVideoUrl, icLoraReady, isCheckingIcLora]) const handleBrowse = useCallback(async () => { const paths = await window.electronAPI.showOpenFileDialog( isImage ? { title: 'Select Input Image', filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp'] }] } : { title: 'Select Driving Video', filters: [{ name: 'Video', extensions: ['mp4', 'mov', 'avi', 'webm', 'mkv'] }] }, ) if (paths && paths.length > 0) { const filePath = paths[0] setInputVideoPath(filePath) setConditioningPreview(null) setExtractError(null) } }, [isImage]) const handleClear = useCallback(() => { setInputVideoPath(null) setInputTime(0) setSourceDims(null) setConditioningPreview(null) setExtractError(null) }, []) const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault() setIsDragOver(false) const acceptedAssetType = isImage ? 'image' : 'video' const assetData = e.dataTransfer.getData('asset') if (assetData) { try { const asset = JSON.parse(assetData) as { type?: string; path?: string } if (asset.type === acceptedAssetType && asset.path) { setInputVideoPath(asset.path) setConditioningPreview(null) setExtractError(null) return } } catch { // fall through } } const file = e.dataTransfer.files?.[0] if (file) { const filePath = window.electronAPI?.getPathForFile(file) if (filePath) { setInputVideoPath(filePath) setConditioningPreview(null) setExtractError(null) } } }, [isImage]) const handleBrowseReference = useCallback(async () => { const paths = await window.electronAPI.showOpenFileDialog({ title: 'Select Reference Image', filters: [{ name: 'Image', extensions: ['png', 'jpg', 'jpeg', 'webp'] }], }) if (paths && paths.length > 0) { setReferenceImagePath(paths[0]) } }, []) const handleClearReference = useCallback(() => { setReferenceImagePath(null) }, []) const handleDropReference = useCallback((e: React.DragEvent) => { e.preventDefault() setIsReferenceDragOver(false) const assetData = e.dataTransfer.getData('asset') if (assetData) { try { const asset = JSON.parse(assetData) as { type?: string; path?: string } if (asset.type === 'image' && asset.path) { setReferenceImagePath(asset.path) return } } catch { // fall through } } const file = e.dataTransfer.files?.[0] if (file) { const filePath = window.electronAPI?.getPathForFile(file) if (filePath) setReferenceImagePath(filePath) } }, []) // Only the built-in canny/depth flow needs the bundled preprocessing cps. Recipes // (any input kind) and custom build their own control video, so never gate them. const showBuiltinGate = !isCustom && !isImage && !isCatalogIcLora && (isCheckingIcLora || !icLoraReady) const showUnsupportedGate = showBuiltinGate && !isCheckingIcLora && !icLoraSupported const showDownloadGate = showBuiltinGate && !showUnsupportedGate const runningDownloadProgress = downloadProgress?.status === 'downloading' ? downloadProgress : null const gateItemIds = [...new Set([...(requiredIcLoraCpIds ?? []), ...(runningDownloadProgress?.all_files ?? [])])] const gateItems = gateItemIds.map((cpId) => { const downloaded = !requiredIcLoraCpIds.includes(cpId) const isCompleted = runningDownloadProgress?.completed_files?.includes(cpId) ?? false const isCurrentDownload = isDownloadingIcLora && runningDownloadProgress?.current_downloading_file === cpId const progress = downloaded ? 100 : (isCompleted ? 100 : (isCurrentDownload ? (runningDownloadProgress?.current_file_progress ?? 0) : 0)) const status = downloaded ? 'Ready' : (isCompleted ? 'Complete' : (isCurrentDownload ? 'Downloading' : 'Missing')) return { id: cpId, label: cpId, downloaded, progress, status } }) return (
IC-LoRA / Style Transfer
{inputVideoUrl && (
)}
{isLocalMode && (
)} {showUnsupportedGate ? (

Built-in control needs LTX 2.3

Depth and canny Union Control are not available on the active LTX 2.5 model. Switch to an LTX 2.3 local model in Settings, or use a custom / catalog IC-LoRA.

) : showDownloadGate ? (

Download Required: IC-LoRA Resources

Editing is locked until all IC-LoRA preprocessing models are available locally.

{isCheckingIcLora ? (
Checking model availability...
) : ( <>
{gateItems.map(item => (
{item.label} {item.status}
{item.progress}%
))}
{downloadError && (
{downloadError}
)}
)}
) : (
Input {inputVideoPath && ( {inputVideoPath.split(/[\\/]/).pop()} )}
{ e.preventDefault(); setIsDragOver(true) }} onDragLeave={() => setIsDragOver(false)} onDrop={handleDrop} > {inputVideoUrl ? ( isImage ? ( Input setSourceDims({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })} /> ) : (
{showConditioningPreview && (
Conditioning
{isExtracting && (
)} {conditioningPreview ? ( Conditioning preview ) : (

{inputVideoUrl ? 'Scrub the input video to see conditioning preview' : 'Import a video to preview conditioning'}

)}
)} {/* Reference image column: takes the conditioning slot for catalog IC-LoRAs that opt in. */} {allowsReferenceImage && !showConditioningPreview && (
Reference {referenceImagePath && ( {referenceImagePath.split(/[\\/]/).pop()} )} {referenceImagePath ? (
) : ( )}
{ e.preventDefault(); setIsReferenceDragOver(true) }} onDragLeave={() => setIsReferenceDragOver(false)} onDrop={handleDropReference} > {referenceImageUrl ? ( Reference ) : (

Drop or import a reference image

)}
)} {/* Canvas editor column: takes the middle slot for outpainting (position_canvas control). */} {showOutpaintCanvas && !showConditioningPreview && (
Canvas
{sourceDims && outpaintPads ? ( onOutpaintPadsChange?.(p)} /> ) : (

Import a video to set the outpaint canvas

)}
)} {/* Output column */}
Output
{_outputVideoPath ? (
)} {extractError && (
{extractError}
)}
) }