Create standalone browser port of LTX Athena with authenticated native API transport
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user