diff --git a/frontend/hooks/use-generation.ts b/frontend/hooks/use-generation.ts index eb185117..abda1f66 100644 --- a/frontend/hooks/use-generation.ts +++ b/frontend/hooks/use-generation.ts @@ -25,8 +25,8 @@ interface GenerationProgress { } interface UseGenerationReturn extends GenerationState { - generate: (prompt: string, imagePath: string | null, settings: GenerationSettings, audioPath?: string | null) => Promise - generateImage: (prompt: string, settings: GenerationSettings) => Promise + generate: (prompt: string, imagePath: string | null, settings: GenerationSettings, audioPath?: string | null) => Promise<{ success: boolean; videoUrl: string | null; videoPath: string | null }> + generateImage: (prompt: string, settings: GenerationSettings) => Promise<{ success: boolean }> cancel: () => void reset: () => void } @@ -111,7 +111,7 @@ export function useGeneration(): UseGenerationReturn { imagePath: string | null, settings: GenerationSettings, audioPath?: string | null, - ) => { + ): Promise<{ success: boolean; videoUrl: string | null; videoPath: string | null }> => { const statusMsg = settings.model === 'pro' ? 'Loading Pro model & generating...' : 'Generating video...' @@ -132,6 +132,9 @@ export function useGeneration(): UseGenerationReturn { abortControllerRef.current = new AbortController() let progressInterval: ReturnType | null = null let shouldApplyPollingUpdates = true + let succeeded = false + let resultVideoUrl: string | null = null + let resultVideoPath: string | null = null try { // Prepare JSON body @@ -223,6 +226,8 @@ export function useGeneration(): UseGenerationReturn { const videoPathNormalized = result.video_path.replace(/\\/g, '/') const fileUrl = videoPathNormalized.startsWith('/') ? `file://${videoPathNormalized}` : `file:///${videoPathNormalized}` + resultVideoUrl = fileUrl + resultVideoPath = result.video_path setState({ isGenerating: false, progress: 100, @@ -235,6 +240,7 @@ export function useGeneration(): UseGenerationReturn { imagePaths: [], error: null, }) + succeeded = true } else if (result.status === 'cancelled') { setState(prev => ({ ...prev, @@ -265,6 +271,7 @@ export function useGeneration(): UseGenerationReturn { clearInterval(progressInterval) } } + return { success: succeeded, videoUrl: resultVideoUrl, videoPath: resultVideoPath } }, []) const cancel = useCallback(async () => { @@ -288,7 +295,7 @@ export function useGeneration(): UseGenerationReturn { const generateImage = useCallback(async ( prompt: string, settings: GenerationSettings - ) => { + ): Promise<{ success: boolean }> => { if (forceApiGenerations) { try { const response = await backendFetch('/api/settings') @@ -304,7 +311,7 @@ export function useGeneration(): UseGenerationReturn { blocking: false, }, })) - return + return { success: false } } } } catch { @@ -317,7 +324,7 @@ export function useGeneration(): UseGenerationReturn { blocking: false, }, })) - return + return { success: false } } } } @@ -338,6 +345,7 @@ export function useGeneration(): UseGenerationReturn { }) abortControllerRef.current = new AbortController() + let succeeded = false try { // Skip prompt enhancement for T2I - use original prompt directly @@ -425,6 +433,7 @@ export function useGeneration(): UseGenerationReturn { imagePaths: rawPaths, // All image paths error: null, }) + succeeded = true } } else if (result.status === 'cancelled') { setState(prev => ({ @@ -451,6 +460,7 @@ export function useGeneration(): UseGenerationReturn { })) } } + return { success: succeeded } }, [appSettings.hasFalApiKey, forceApiGenerations, refreshSettings]) const reset = useCallback(() => { diff --git a/frontend/views/GenSpace.tsx b/frontend/views/GenSpace.tsx index 8d36dd1c..99b87f6c 100644 --- a/frontend/views/GenSpace.tsx +++ b/frontend/views/GenSpace.tsx @@ -3,7 +3,7 @@ import { Trash2, Download, Image, Video, X, Heart, Film, Volume2, VolumeX, Sparkles, Clock, Monitor, ChevronUp, Scissors, Music, - ChevronLeft, ChevronRight, Copy, Check + ChevronLeft, ChevronRight, Copy, Check, Layers } from 'lucide-react' import { useProjects } from '../contexts/ProjectContext' import type { GenSpaceRetakeSource } from '../contexts/ProjectContext' @@ -210,22 +210,28 @@ function AssetCard({ } // Dropdown component for settings -function SettingsDropdown({ - trigger, - options, - value, +function SettingsDropdown({ + trigger, + options, + value, onChange, - title -}: { + title, + allowCustomInput, + customInputSuffix, +}: { trigger: React.ReactNode options: { value: string; label: string; disabled?: boolean; tooltip?: string; icon?: React.ReactNode }[] value: string onChange: (value: string) => void title: string + allowCustomInput?: boolean + customInputSuffix?: string }) { const [isOpen, setIsOpen] = useState(false) + const [customInput, setCustomInput] = useState('') const dropdownRef = useRef(null) - + const customInputRef = useRef(null) + useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { @@ -237,16 +243,35 @@ function SettingsDropdown({ } return () => document.removeEventListener('mousedown', handleClickOutside) }, [isOpen]) - + + // Clear custom input when dropdown opens + useEffect(() => { + if (isOpen && allowCustomInput) { + setCustomInput('') + setTimeout(() => customInputRef.current?.focus(), 50) + } + }, [isOpen, allowCustomInput]) + + const commitCustomInput = () => { + const n = parseInt(customInput, 10) + if (!isNaN(n) && n >= 1) { + onChange(String(n)) + setIsOpen(false) + } + setCustomInput('') + } + + const isCustomValue = allowCustomInput && !options.some(o => o.value === value) + return (
- - + {isOpen && (
{title}
@@ -262,8 +287,8 @@ function SettingsDropdown({ }`} > {option.icon && {option.icon}} @@ -282,6 +307,49 @@ function SettingsDropdown({ )}
))} + {allowCustomInput && ( + <> + {isCustomValue && ( +
+ {value}{customInputSuffix} + + + +
+ )} +
+
+ { + const v = e.target.value.replace(/[^0-9]/g, '').slice(0, 5) + setCustomInput(v) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); commitCustomInput() } + if (e.key === 'Escape') { setIsOpen(false) } + }} + className="flex-1 bg-transparent text-sm text-white placeholder:text-zinc-500 focus:outline-none w-0 min-w-0" + /> + {customInputSuffix && customInput && ( + {customInputSuffix} + )} + +
+
+ + )}
)} @@ -340,6 +408,9 @@ function PromptBar({ onIcLoraCondTypeChange, icLoraStrength, onIcLoraStrengthChange, + batchCount, + onBatchCountChange, + batchRemaining, }: { mode: 'image' | 'video' | 'retake' | 'ic-lora' onModeChange: (mode: 'image' | 'video' | 'retake' | 'ic-lora') => void @@ -371,6 +442,9 @@ function PromptBar({ onIcLoraCondTypeChange?: (type: ICLoraConditioningType) => void icLoraStrength?: number onIcLoraStrengthChange?: (strength: number) => void + batchCount: number + onBatchCountChange: (n: number) => void + batchRemaining: number }) { const inputRef = useRef(null) const audioInputRef = useRef(null) @@ -762,7 +836,28 @@ function PromptBar({ )} - + + {/* Batch count dropdown - image/video modes only */} + {!isRetake && !isIcLora && ( + onBatchCountChange(parseInt(v))} + options={[100, 50, 20, 15, 10, 5, 4, 3, 2, 1].map(n => ({ + value: String(n), + label: `${n}×`, + }))} + allowCustomInput + customInputSuffix="×" + trigger={ + <> + + {batchRemaining > 0 ? batchRemaining : batchCount}× + + } + /> + )} + {/* Generate button */}