import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { createPortal } from 'react-dom' import { Group, Panel, Separator } from 'react-resizable-panels' import { Trash2, Download, Image, Video, X, Heart, Film, Volume2, VolumeX, Sparkles, Sparkle, Clock, Monitor, ChevronUp, Scissors, Music, Undo2, Redo2, Loader2, MoveHorizontal, Wand2, Square, Rows3 } from 'lucide-react' import { useProjects } from '../contexts/ProjectContext' import type { GenSpaceRetakeSource } from '../contexts/ProjectContext' import { useAppSettings } from '../contexts/AppSettingsContext' import { useGeneration, GENERATION_RECOVERY_KEY, type GenerationRecoveryContext } from '../hooks/use-generation' import { useFixedMenu } from '../hooks/use-fixed-menu' import { setActiveGenerationOwner, hasValidBaselineId } from '../lib/generation-recovery' import { withGenerationActive, canCancelLocalJob } from '../lib/generation-active' import { useVideoGenerationModelSpecs } from '../hooks/use-video-generation-model-specs' import { createLocalGenerationError, type GenerationError } from '../lib/generation-errors' import { useRetake, RETAKE_EXTEND_MODELS, retakeExtendModelFromPipeline, type RetakeExtendModel, } from '../hooks/use-retake' import { useExtend, type ExtendDirection, EXTEND_SECONDS, DEFAULT_EXTEND_SECONDS } from '../hooks/use-extend' import { resolutionOptions, videoGenerationResolutionLabel, type ResolutionOption } from '../lib/video-resolution' import { useIcLora, type IcLoraAudioMode } from '../hooks/use-ic-lora' import { useCustomIcLoraEnabled } from '../hooks/use-custom-ic-lora-enabled' import { useDevFlags } from '../contexts/DevFlagsContext' import { type IcLoraListItem } from '../hooks/use-catalog' import { useIcLoraLibrary } from '../hooks/use-ic-lora-library' import { useLoraLibrary } from '../hooks/use-lora-library' import { usePromptEnhancerProvider, type EnhanceProvider } from '../hooks/use-prompt-enhancer-provider' import { useGlobalGenerationLock } from '../hooks/use-global-generation-lock' import type { ICLoraConditioningType } from '../components/ICLoraPanel' import type { Asset } from '../types/project-model' import { AssetPreviewModal } from '../components/AssetPreviewModal' import { GenerationErrorDialog } from '../components/GenerationErrorDialog' import { addVisualAssetToProject } from '../lib/asset-copy' import { pathToFileUrl } from '../lib/file-url' import { prepareGenerationInput } from '../lib/generation-input' import { areVideoGenerationSettingsEquivalent, formatPipelineDisplayName, GENSPACE_MIN_SELECTABLE_DURATION_S, getApiOfferingCapabilities, getVideoGenerationModelSpecs, getLocalOfferingCapabilities, resolvePipelineDisplayName, resolveVideoGenerationOptions, sanitizeVideoGenerationSettings, type VideoGenerationModelSpecItem, type VideoGenerationPipeline, } from '../lib/video-generation-model-specs' import { logger } from '../lib/logger' import { ApiClient, type ApiSuccessOf } from '../lib/api-client' import type { GenerationSettings, LoraSelection } from '../components/SettingsPanel' import { RetakePanel } from '../components/RetakePanel' import { ExtendPanel } from '../components/ExtendPanel' import { KeyframePreview } from '../components/KeyframePreview' import { MultiKeyframePanel } from '../components/MultiKeyframePanel' import { ICLoraPanel, CONDITIONING_TYPES } from '../components/ICLoraPanel' import type { OutpaintPads } from '../components/OutpaintCanvasEditor' import { LoraLibraryModal } from '../components/LoraLibraryModal' import { SelectedLoraInfo } from '../components/SelectedLoraInfo' import { buildIcLoraSelectorOptions, encodeIcLoraSelectorValue, parseIcLoraSelectorValue, toModelsDirRelativeRef, variantDisplayName } from '../lib/lora-library' import { SettingsDropdown } from '../components/SettingsDropdown' import { IcLoraSettingsControls, type IcLoraControlsProps } from '../components/IcLoraSettingsControls' import { IcLoraAdvancedPanel } from '../components/IcLoraAdvancedPanel' import { FreeApiKeyBubble } from '../components/FreeApiKeyBubble' import { shouldShowGeneratingTile } from '../lib/genspace-gallery' import { shouldShowLastFrameChip } from '../lib/genspace-last-frame' import { GEMINI_KEY_REQUIRED_SETTINGS_DETAIL, isEnhanceBlockedByMissingGeminiKey, } from '../lib/enhance-gemini-key' import { autoDurationOptionVisible, canUseMultiKeyframeMode, fallbackGenSpaceMode, genSpaceUsesAudioInput, isGenSpaceLibraryMode, isEnhanceAvailableForMode, modeAfterCompletedGeneration, modeOptionValues, type GenSpaceMode, } from '../lib/genspace-multi-keyframe' import { applyKeyframeImagePaths, enhanceKeyframesPayload, fromPersistedKeyframes, LOCAL_MULTI_KEYFRAME_MAX_COUNT, toPersistedKeyframes, videoGenerationModeFromInputs, type KeyframeItem, } from '../lib/multi-keyframe' import { lastFrameFromDuration, previewKeyframeForPlayhead, retimeKeyframesForSettings, sameDraggedFrame, type DraggedFrame } from '../lib/keyframe-timeline' import { GenSpaceFilterEmptyState } from './genspace/GenSpaceFilterEmptyState' import { GenSpaceGalleryToolbar } from './genspace/GenSpaceGalleryToolbar' import { gallerySizeClasses, type GallerySize } from './genspace/GenSpaceGallerySizeMenu' import { useGenSpaceGallery } from './genspace/useGenSpaceGallery' import { useGenSpacePromptBarHeight } from './genspace/useGenSpacePromptBarHeight' // Asset card with hover overlays function AssetCard({ asset, onDelete, onPlay, onDragStart, onCreateVideo, onEditImage, onRetake, onExtend, onIcLora, onToggleFavorite }: { asset: Asset onDelete: () => void onPlay: () => void onDragStart: (e: React.DragEvent, asset: Asset) => void onCreateVideo?: (asset: Asset) => void onEditImage?: (asset: Asset) => void onRetake?: (asset: Asset) => void onExtend?: (asset: Asset) => void onIcLora?: (asset: Asset) => void onToggleFavorite?: () => void }) { const hoverVideoRef = useRef(null) const [isHovered, setIsHovered] = useState(false) const [currentTime, setCurrentTime] = useState(0) const [isMuted, setIsMuted] = useState(true) const [volume, setVolume] = useState(0.5) const isFavorite = asset.favorite || false useEffect(() => { if (asset.type !== 'video') return if (!isHovered) { setCurrentTime(0) return } if (hoverVideoRef.current) { hoverVideoRef.current.muted = isMuted hoverVideoRef.current.volume = volume hoverVideoRef.current.play().catch(() => {}) } }, [asset.type, isHovered, isMuted, volume]) const handleTimeUpdate = () => { if (hoverVideoRef.current) { setCurrentTime(hoverVideoRef.current.currentTime) } } const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60) const secs = Math.floor(seconds % 60) return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` } const handleDownload = (e: React.MouseEvent) => { e.stopPropagation() const a = document.createElement('a') a.href = pathToFileUrl(asset.path) a.download = asset.path.split('/').pop() || `${asset.type}-${asset.id}` a.click() } return (
setIsHovered(true)} onMouseLeave={() => { setIsHovered(false) setCurrentTime(0) }} onClick={onPlay} draggable={asset.type === 'image'} onDragStart={(e) => asset.type === 'image' && onDragStart(e, asset)} > {asset.type === 'video' ? (
{asset.bigThumbnailPath && ( )} {isHovered && (
) : ( )} {/* Favorite heart - always visible when favorited */} {isFavorite && !isHovered && ( )} {/* Hover overlay */}
{/* Top buttons */}
{asset.type === 'image' && ( <> )} {asset.type === 'video' && ( <> {onRetake && ( )} {onExtend && ( )} {onIcLora && ( )} )}
{/* Tools button hidden for now */}
{/* Bottom controls for video */} {asset.type === 'video' && (
{formatTime(currentTime)}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onChange={(e) => { e.stopPropagation() const next = parseFloat(e.target.value) setVolume(next) if (next === 0) { setIsMuted(true) } else if (isMuted) { setIsMuted(false) } }} className="w-16 h-1 accent-white cursor-pointer" aria-label="Volume" />
)} {/* Delete button (subtle, bottom right) */} {( )}
) } // Lightricks brand icon function LightricksIcon({ className }: { className?: string }) { return ( ) } function ZitIcon({ className }: { className?: string }) { return ( ) } // Square icon for aspect ratio function AspectIcon({ className }: { className?: string }) { return ( ) } const DEFAULT_LORA_SCALE = 1.0 const IMAGE_STEPS_GENERATE = 4 const IMAGE_STEPS_EDIT = 8 // Multi-select LoRA picker with a per-LoRA strength slider. function LoRAPicker({ available, selected, onChange, displayNames, catalogIdsByPath, }: { available: ApiSuccessOf<'listModels'>['models'] selected: LoraSelection[] onChange: (loras: LoraSelection[]) => void // Catalog display names keyed by installed path (cross-checked against the catalog), so a // downloaded catalog LoRA shows its proper name instead of the raw filename. displayNames?: Map // Catalog id keyed by installed path, same cross-check — attached to a new selection so the // prompt enhancer can look up this LoRA's trigger/instructions later. Without it, a LoRA // picked from this dropdown (rather than the "Browse LoRAs" catalog modal) would silently // enhance with no catalog awareness at all. catalogIdsByPath?: Map }) { const { isOpen, setIsOpen, triggerRef, menuRef, style } = useFixedMenu('above') const nameFor = (lora: ApiSuccessOf<'listModels'>['models'][0]) => displayNames?.get(lora.path) ?? lora.name const toggle = (lora: ApiSuccessOf<'listModels'>['models'][0]) => { if (selected.find(s => s.ref === lora.path)) { onChange(selected.filter(s => s.ref !== lora.path)) } else { onChange([...selected, { ref: lora.path, name: nameFor(lora), scale: DEFAULT_LORA_SCALE, catalogId: catalogIdsByPath?.get(lora.path), }]) } } const updateScale = (loraRef: string, scale: number) => { onChange(selected.map(s => s.ref === loraRef ? { ...s, scale } : s)) } const label = selected.length === 0 ? 'LoRA' : selected.length === 1 ? selected[0].name : `${selected.length} LoRAs` return (
{isOpen && createPortal(
Select LoRAs
{available.map(lora => { const sel = selected.find(s => s.ref === lora.path) return (
toggle(lora)} className="accent-white" /> {nameFor(lora)}
{sel && (
updateScale(lora.path, parseFloat(e.target.value))} className="flex-1 accent-white" /> {sel.scale.toFixed(2)}
)}
) })}
, document.body, )}
) } // Resolve a selected resolution option key to {width,height}, or undefined for "original" // (backend then uses the source resolution). function resolveResolution(options: ResolutionOption[], key: string): { width: number; height: number } | undefined { const opt = options.find((o) => o.key === key) if (!opt || opt.width == null || opt.height == null) return undefined return { width: opt.width, height: opt.height } } // Prompt bar component matching the design // Two-row layout: prompt row on top, settings row below function PromptBar({ mode, onModeChange, canUseMultiKeyframe, canUseIcLora, canUseRetake, canUseExtend, canUseUserLoras, prompt, onPromptChange, onGenerate, onStop, isGenerating, isCancelling, inputImage, onInputImageChange, inputLastImage, onInputLastImageChange, inputAudio, onInputAudioChange, keyframes, onKeyframesChange, multiKeyframeMaxCount, playheadFrame, onPlayheadChange, onDragFrameChange, settings, onSettingsChange, videoModelSpecs, videoSettingsMessage, canGenerate, buttonLabel, buttonIcon, extendDirection, onExtendDirectionChange, extendSeconds, onExtendSecondsChange, resolutionOptions: resolutionOpts, selectedResolution, onResolutionChange, retakeExtendModel, onRetakeExtendModelChange, icLoraControls, promptOptional, isLocalMode, imageUsesFalApi, availableLoras, selectedLoras, onSelectedLorasChange, loraDisplayNames, loraCatalogIdsByPath, enhanceAvailableForMode, canEnhancePrompt, enhanceBlockedByMissingGeminiKey, isEnhancingPrompt, enhancePromptError, onEnhancePrompt, enhanceProvider, onEnhanceProviderChange, canUndoPrompt, onUndoPrompt, canRedoPrompt, onRedoPrompt, }: { mode: GenSpaceMode onModeChange: (mode: GenSpaceMode) => void canUseMultiKeyframe: boolean canUseIcLora: boolean canUseRetake: boolean canUseExtend: boolean canUseUserLoras: boolean prompt: string onPromptChange: (prompt: string) => void onGenerate: () => void onStop?: () => void isGenerating: boolean isCancelling?: boolean canGenerate: boolean buttonLabel: string buttonIcon: React.ReactNode extendDirection?: ExtendDirection onExtendDirectionChange?: (direction: ExtendDirection) => void extendSeconds?: number onExtendSecondsChange?: (seconds: number) => void resolutionOptions?: ResolutionOption[] selectedResolution?: string onResolutionChange?: (key: string) => void // Retake/extend, API mode: ltxv-api /v1/retake and /v2/extend accept // ltx-2-pro / ltx-2-3-pro (Desktop pipeline "pro"). retakeExtendModel?: RetakeExtendModel onRetakeExtendModelChange?: (model: RetakeExtendModel) => void inputImage: string | null onInputImageChange: (path: string | null) => void inputLastImage: string | null onInputLastImageChange: (path: string | null) => void inputAudio: string | null onInputAudioChange: (path: string | null) => void keyframes: readonly KeyframeItem[] onKeyframesChange: (keyframes: KeyframeItem[]) => void multiKeyframeMaxCount: number playheadFrame: number onPlayheadChange: (frameIndex: number) => void onDragFrameChange: (drag: DraggedFrame | null) => void settings: { model: string duration: number | null videoResolution: string fps: number aspectRatio: string imageResolution: string variations: number audio?: boolean imageEditStrength?: number } onSettingsChange: (settings: any) => void videoModelSpecs: VideoGenerationModelSpecItem[] videoSettingsMessage?: string | null icLoraControls?: IcLoraControlsProps promptOptional?: boolean isLocalMode?: boolean imageUsesFalApi?: boolean availableLoras?: ApiSuccessOf<'listModels'>['models'] selectedLoras?: LoraSelection[] onSelectedLorasChange?: (loras: LoraSelection[]) => void loraDisplayNames?: Map loraCatalogIdsByPath?: Map // Whether the current mode supports Enhance at all (independent of prompt text / in-flight // generation) — gates the whole Enhance/Undo/Redo cluster's visibility. enhanceAvailableForMode?: boolean canEnhancePrompt?: boolean enhanceBlockedByMissingGeminiKey?: boolean isEnhancingPrompt?: boolean enhancePromptError?: string | null onEnhancePrompt?: () => void // The dropdown is only rendered when this is passed — the parent omits it when local // Enhance isn't available (the button is already API-only). API stays listed even without // a Gemini key so picking it can open Settings. enhanceProvider?: 'local' | 'api' onEnhanceProviderChange?: (provider: 'local' | 'api') => void canUndoPrompt?: boolean onUndoPrompt?: () => void canRedoPrompt?: boolean onRedoPrompt?: () => void }) { const enhanceDisabled = (!canEnhancePrompt && !enhanceBlockedByMissingGeminiKey) || !!isEnhancingPrompt const inputRef = useRef(null) const lastFrameInputRef = useRef(null) const audioInputRef = useRef(null) const [isDragOver, setIsDragOver] = useState(false) const [isLastDragOver, setIsLastDragOver] = useState(false) const [isAudioDragOver, setIsAudioDragOver] = useState(false) const isRetake = mode === 'retake' const isExtend = mode === 'extend' const isIcLora = mode === 'ic-lora' const isEditingImage = mode === 'image' && !!inputImage const availableModeValues = modeOptionValues({ canUseMultiKeyframe, canUseRetake, canUseExtend, canUseIcLora, }) // Resolution selector: local only, and only when there's a lower tier to pick. const showResolution = isLocalMode && !!resolutionOpts && resolutionOpts.length > 1 const resolutionControl = showResolution ? ( onResolutionChange?.(v)} options={resolutionOpts!.map((o) => ({ value: o.key, label: o.label }))} trigger={ <> {(resolutionOpts!.find((o) => o.key === selectedResolution)?.label ?? 'Original').split(' ')[0]} } /> ) : null // Unused while RETAKE_EXTEND_MODELS is only "pro". const showRetakeExtendModel = !isLocalMode && (isRetake || isExtend) && RETAKE_EXTEND_MODELS.length > 1 const retakeExtendModelLabel = (value: RetakeExtendModel) => formatPipelineDisplayName(value) ?? value const modelControl = showRetakeExtendModel ? ( onRetakeExtendModelChange?.(v as RetakeExtendModel)} options={RETAKE_EXTEND_MODELS.map((value) => ({ value, label: retakeExtendModelLabel(value), }))} trigger={ <> {retakeExtendModelLabel(retakeExtendModel ?? 'pro')} } /> ) : null const resolvedVideoOptions = mode === 'video' || mode === 'multi-keyframe' ? resolveVideoGenerationOptions({ settings, modelSpecs: videoModelSpecs, hasAudio: genSpaceUsesAudioInput(mode) && Boolean(inputAudio), minimumDuration: isLocalMode ? undefined : GENSPACE_MIN_SELECTABLE_DURATION_S, }) : null const showVideoFpsControl = Boolean( resolvedVideoOptions && resolvedVideoOptions.hasCompatibleOptions && resolvedVideoOptions.fpsOptions.length > 1, ) const showAutoDurationOption = Boolean( resolvedVideoOptions && autoDurationOptionVisible(mode, resolvedVideoOptions.autoDurationAvailable), ) const selectedDuration = showAutoDurationOption && resolvedVideoOptions?.selectedDuration === null ? null : resolvedVideoOptions?.selectedDuration ?? resolvedVideoOptions?.durationOptions[0] ?? settings.duration const handleDrop = (e: React.DragEvent) => { e.preventDefault() setIsDragOver(false) const assetData = e.dataTransfer.getData('asset') if (assetData) { const asset = JSON.parse(assetData) as Asset if (asset.type === 'image') { onInputImageChange(asset.path) } return } // File drop from the OS (Finder/Explorer) — mirrors handleFileSelect. const file = e.dataTransfer.files?.[0] if (file && file.type.startsWith('image/')) { const filePath = window.electronAPI?.getPathForFile(file) onInputImageChange(filePath || URL.createObjectURL(file)) } } const handleAudioDrop = (e: React.DragEvent) => { e.preventDefault() setIsAudioDragOver(false) const assetData = e.dataTransfer.getData('asset') if (assetData) { const asset = JSON.parse(assetData) as Asset if (asset.type === 'audio') { onInputAudioChange(asset.path) } } // Handle file drops const file = e.dataTransfer.files?.[0] if (file) { const ext = file.name.split('.').pop()?.toLowerCase() if (['mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a'].includes(ext || '')) { const filePath = window.electronAPI?.getPathForFile(file) if (filePath) { onInputAudioChange(filePath) } } } } const handleAudioFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file) { const filePath = window.electronAPI?.getPathForFile(file) if (filePath) { onInputAudioChange(filePath) } } } const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file && file.type.startsWith('image/')) { const filePath = window.electronAPI?.getPathForFile(file) if (filePath) { onInputImageChange(filePath) } else { const url = URL.createObjectURL(file) onInputImageChange(url) } } } const handleLastDrop = (e: React.DragEvent) => { e.preventDefault() setIsLastDragOver(false) const assetData = e.dataTransfer.getData('asset') if (assetData) { const asset = JSON.parse(assetData) as Asset if (asset.type === 'image') { onInputLastImageChange(asset.path) } return } const file = e.dataTransfer.files?.[0] if (file && file.type.startsWith('image/')) { const filePath = window.electronAPI?.getPathForFile(file) if (filePath) { onInputLastImageChange(filePath) } } } const handleLastFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (file && file.type.startsWith('image/')) { const filePath = window.electronAPI?.getPathForFile(file) if (filePath) { onInputLastImageChange(filePath) } } } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey && !isGenerating && canGenerate && !isEnhancingPrompt) { e.preventDefault() onGenerate() } } const showStop = Boolean(isGenerating && onStop) const stopDisabled = Boolean(isCancelling) const generateDisabled = isGenerating || !canGenerate || isEnhancingPrompt return (
{mode === 'multi-keyframe' && ( )} {/* Top row: Image ref | Prompt | Generate */}
{/* Input image drop zone — video mode (I2V) or image mode (edit source) */} {(mode === 'video' || mode === 'image') && !isRetake && !isIcLora && (
{ e.preventDefault(); setIsDragOver(true) }} onDragLeave={() => setIsDragOver(false)} onDrop={handleDrop} onClick={() => inputRef.current?.click()} > {inputImage ? ( <> ) : ( )}
)} {shouldShowLastFrameChip({ mode, hasFirstFrame: Boolean(inputImage), duration: settings.duration, }) && !isRetake && !isIcLora && (
{ e.preventDefault(); setIsLastDragOver(true) }} onDragLeave={() => setIsLastDragOver(false)} onDrop={handleLastDrop} onClick={() => lastFrameInputRef.current?.click()} > {inputLastImage ? ( <> ) : ( )}
)} {/* Audio drop zone — A2V is mutually exclusive with multi-keyframe */} {genSpaceUsesAudioInput(mode) && !isRetake && !isIcLora && (
{ e.preventDefault(); setIsAudioDragOver(true) }} onDragLeave={() => setIsAudioDragOver(false)} onDrop={handleAudioDrop} onClick={() => audioInputRef.current?.click()} title={inputAudio ? 'Audio attached — click to change' : 'Attach audio for A2V'} > {inputAudio ? ( <> ) : ( )}
)} {/* Prompt input - fills remaining width and grows with the panel */}