Create standalone browser port of LTX Athena with authenticated native API transport

This commit is contained in:
Mikei386
2026-09-29 17:59:23 +02:00
commit 1333b0c36b
202 changed files with 63054 additions and 0 deletions
+751
View File
@@ -0,0 +1,751 @@
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<ApiRequestBodyOf<'startModelDownload'>>
type ModelCheckpointID = NonNullable<StartModelDownloadBody['cp_ids']>[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<HTMLVideoElement>(null)
const [inputVideoPath, setInputVideoPath] = useState<string | null>(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<string | null>(null)
const referenceImageUrl = referenceImagePath ? pathToFileUrl(referenceImagePath) : null
const [isReferenceDragOver, setIsReferenceDragOver] = useState(false)
const [internalCondType, setInternalCondType] = useState<ICLoraConditioningType>('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<string | null>(null)
const [isExtracting, setIsExtracting] = useState(false)
const [requiredIcLoraCpIds, setRequiredIcLoraCpIds] = useState<ModelCheckpointID[]>([])
const [icLoraSupported, setIcLoraSupported] = useState(true)
const [isCheckingIcLora, setIsCheckingIcLora] = useState(false)
const [isDownloadingIcLora, setIsDownloadingIcLora] = useState(false)
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress | null>(null)
const [downloadError, setDownloadError] = useState<string | null>(null)
const [downloadSessionId, setDownloadSessionId] = useState<string | null>(null)
const [extractError, setExtractError] = useState<string | null>(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<ReturnType<typeof setTimeout> | 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 (
<div className={`bg-zinc-900 border border-zinc-800 rounded-2xl overflow-hidden flex flex-col ${fillHeight ? 'h-full min-h-0' : ''}`}>
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800 flex-shrink-0">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-amber-400" />
<span className="text-sm font-semibold text-white">IC-LoRA / Style Transfer</span>
</div>
{inputVideoUrl && (
<div className="flex items-center gap-2">
<button
onClick={handleClear}
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
title="Clear video"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<button
onClick={handleBrowse}
className="p-1.5 rounded-md hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors"
title="Replace video"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{isLocalMode && (
<div className="px-3 py-2 border-b border-zinc-800 flex-shrink-0">
<button
onClick={onBrowseLibrary}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-medium transition-colors"
>
<Sparkles className="h-3.5 w-3.5 text-amber-400" /> Browse IC-LoRAs
</button>
</div>
)}
{showUnsupportedGate ? (
<div className="flex-1 flex items-center justify-center p-6 min-h-0 overflow-y-auto">
<div className="w-full max-w-xl rounded-xl border border-zinc-700 bg-zinc-800/60 p-6">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-amber-600/20 flex items-center justify-center mt-0.5">
<AlertCircle className="h-4 w-4 text-amber-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-white">Built-in control needs LTX 2.3</h3>
<p className="text-xs text-zinc-400 mt-1">
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.
</p>
</div>
</div>
</div>
</div>
) : showDownloadGate ? (
<div className="flex-1 flex items-center justify-center p-6 min-h-0 overflow-y-auto">
<div className="w-full max-w-xl rounded-xl border border-zinc-700 bg-zinc-800/60 p-6">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-lg bg-blue-600/20 flex items-center justify-center mt-0.5">
<Download className="h-4 w-4 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-white">Download Required: IC-LoRA Resources</h3>
<p className="text-xs text-zinc-400 mt-1">
Editing is locked until all IC-LoRA preprocessing models are available locally.
</p>
</div>
</div>
<div className="mt-5 space-y-3">
{isCheckingIcLora ? (
<div className="flex items-center gap-2 text-xs text-zinc-300">
<Loader2 className="h-4 w-4 animate-spin text-blue-400" />
Checking model availability...
</div>
) : (
<>
<div className="space-y-2">
{gateItems.map(item => (
<div key={item.id} className="rounded-lg border border-zinc-700 bg-zinc-900/60 px-3 py-2">
<div className="flex items-center justify-between text-[11px] mb-1.5">
<span className="text-zinc-300">{item.label}</span>
<span className={item.downloaded ? 'text-blue-400' : 'text-zinc-500'}>
{item.status}
</span>
</div>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full transition-all duration-300 bg-blue-500"
style={{ width: `${item.progress}%` }}
/>
</div>
<div className="mt-1 text-[10px] text-zinc-500">{item.progress}%</div>
</div>
))}
</div>
{downloadError && (
<div className="text-[11px] text-red-400">{downloadError}</div>
)}
<div className="flex items-center gap-2 pt-1">
<button
onClick={handleDownloadIcLora}
disabled={isDownloadingIcLora}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDownloadingIcLora ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
Downloading...
</>
) : (
<>
<Download className="h-3 w-3" />
{downloadError ? 'Retry Download' : 'Download Models'}
</>
)}
</button>
<button
onClick={() => { void checkIcLoraAvailability() }}
disabled={isCheckingIcLora}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-600 text-zinc-300 hover:text-white hover:border-zinc-500 text-xs transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${isCheckingIcLora ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</>
)}
</div>
</div>
</div>
) : (
<div className="flex-1 flex min-h-0 overflow-hidden">
<div className="flex-1 flex flex-col border-r border-zinc-800 min-w-0">
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider shrink-0">Input</span>
{inputVideoPath && (
<span className="text-[10px] text-zinc-500 truncate min-w-0">
{inputVideoPath.split(/[\\/]/).pop()}
</span>
)}
<button
onClick={handleBrowse}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors shrink-0"
>
<Upload className="h-3 w-3" />
Import
</button>
</div>
<div
className={`flex-1 min-h-0 bg-black flex items-center justify-center relative ${!inputVideoUrl ? 'border-2 border-dashed border-zinc-700 m-3 rounded-lg' : ''} ${isDragOver ? 'border-blue-500 bg-blue-500/10' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true) }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
>
{inputVideoUrl ? (
isImage ? (
<img
src={inputVideoUrl}
alt="Input"
className="w-full h-full object-contain"
onLoad={(e) => setSourceDims({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
/>
) : (
<video
ref={inputVideoRef}
src={inputVideoUrl}
className="w-full h-full object-contain"
controls
onLoadedMetadata={(e) => setSourceDims({ w: e.currentTarget.videoWidth, h: e.currentTarget.videoHeight })}
/>
)
) : (
<div className="text-center p-4">
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center mx-auto mb-2">
<Film className="h-6 w-6 text-zinc-600" />
</div>
<p className="text-zinc-400 text-xs">{isImage ? 'Drop or import an image' : isCustom ? 'Drop or import a control video' : 'Drop or import a driving video'}</p>
<button
onClick={handleBrowse}
className="mt-2 px-3 py-1.5 text-[10px] text-blue-400 border border-blue-500/30 rounded-lg hover:bg-blue-600/10 transition-colors"
>
{isImage ? 'Import Image' : 'Import Video'}
</button>
</div>
)}
</div>
</div>
{showConditioningPreview && (
<div className="flex-1 flex flex-col min-w-0">
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Conditioning</span>
<button
onClick={() => { void extractConditioning() }}
disabled={!inputVideoPath || isExtracting}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-3 w-3 ${isExtracting ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="flex-1 bg-black flex items-center justify-center min-h-0 relative">
{isExtracting && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
<Loader2 className="h-5 w-5 text-blue-400 animate-spin" />
</div>
)}
{conditioningPreview ? (
<img src={conditioningPreview} alt="Conditioning preview" className="w-full h-full object-contain" />
) : (
<div className="text-center p-4">
<p className="text-zinc-600 text-xs">
{inputVideoUrl ? 'Scrub the input video to see conditioning preview' : 'Import a video to preview conditioning'}
</p>
</div>
)}
</div>
</div>
)}
{/* Reference image column: takes the conditioning slot for catalog IC-LoRAs that opt in. */}
{allowsReferenceImage && !showConditioningPreview && (
<div className="flex-1 flex flex-col min-w-0">
<div className="px-3 py-2 border-b border-zinc-800 flex items-center justify-between gap-2">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider shrink-0">Reference</span>
{referenceImagePath && (
<span className="text-[10px] text-zinc-500 truncate min-w-0">
{referenceImagePath.split(/[\\/]/).pop()}
</span>
)}
{referenceImagePath ? (
<div className="flex items-center gap-1 shrink-0">
<button
onClick={handleClearReference}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
title="Clear reference image"
>
<Trash2 className="h-3 w-3" />
</button>
<button
onClick={handleBrowseReference}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
title="Replace reference image"
>
<RefreshCw className="h-3 w-3" />
</button>
</div>
) : (
<button
onClick={handleBrowseReference}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors shrink-0"
>
<Upload className="h-3 w-3" />
Import
</button>
)}
</div>
<div
className={`flex-1 min-h-0 bg-black flex items-center justify-center relative ${!referenceImageUrl ? 'border-2 border-dashed border-zinc-700 m-3 rounded-lg' : ''} ${isReferenceDragOver ? 'border-blue-500 bg-blue-500/10' : ''}`}
onDragOver={(e) => { e.preventDefault(); setIsReferenceDragOver(true) }}
onDragLeave={() => setIsReferenceDragOver(false)}
onDrop={handleDropReference}
>
{referenceImageUrl ? (
<img src={referenceImageUrl} alt="Reference" className="w-full h-full object-contain" />
) : (
<div className="text-center p-4">
<div className="w-12 h-12 rounded-full bg-zinc-800 flex items-center justify-center mx-auto mb-2">
<ImageIcon className="h-6 w-6 text-zinc-600" />
</div>
<p className="text-zinc-400 text-xs">Drop or import a reference image</p>
<button
onClick={handleBrowseReference}
className="mt-2 px-3 py-1.5 text-[10px] text-blue-400 border border-blue-500/30 rounded-lg hover:bg-blue-600/10 transition-colors"
>
Import Image
</button>
</div>
)}
</div>
</div>
)}
{/* Canvas editor column: takes the middle slot for outpainting (position_canvas control). */}
{showOutpaintCanvas && !showConditioningPreview && (
<div className="flex-1 flex flex-col min-w-0">
<div className="px-3 py-2 border-b border-zinc-800 flex items-center">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Canvas</span>
</div>
{sourceDims && outpaintPads ? (
<OutpaintCanvasEditor
sourceWidth={sourceDims.w}
sourceHeight={sourceDims.h}
value={outpaintPads}
onChange={(p) => onOutpaintPadsChange?.(p)}
/>
) : (
<div className="flex-1 bg-black flex items-center justify-center">
<p className="text-zinc-600 text-xs">Import a video to set the outpaint canvas</p>
</div>
)}
</div>
)}
{/* Output column */}
<div className="flex-1 flex flex-col border-l border-zinc-800 min-w-0">
<div className="px-3 py-2 border-b border-zinc-800 flex items-center">
<span className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider">Output</span>
</div>
<div className="flex-1 bg-black flex items-center justify-center min-h-0 relative">
{_outputVideoPath ? (
<video
src={pathToFileUrl(_outputVideoPath)}
className="w-full h-full object-contain"
controls
/>
) : isProcessing ? (
<div className="text-center p-4">
<Loader2 className="h-6 w-6 text-blue-400 animate-spin mx-auto mb-2" />
<p className="text-zinc-400 text-xs">{processingStatus || 'Generating...'}</p>
</div>
) : (
<div className="text-center p-4">
<p className="text-zinc-600 text-xs">Output video will appear here</p>
</div>
)}
</div>
</div>
</div>
)}
{extractError && (
<div className="px-4 py-3 border-t border-zinc-800 flex-shrink-0">
<div className="flex items-center gap-2 text-xs text-red-400">
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
<span>{extractError}</span>
</div>
</div>
)}
</div>
)
}