| import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'; |
| import { Upload, Settings2, X, Play, Download, Copy, RefreshCw, Trash2, Film, Image, ChevronDown, ChevronRight, Maximize2, Clock, Sliders, Loader2, Info, ArrowUp } from 'lucide-react'; |
| import { resolveFileUrl } from './resolveFileUrl'; |
| |
| const MOTION_PRESETS = [ |
| { label: 'Subtle', value: 'low', description: 'Gentle, minimal movement' }, |
| { label: 'Medium', value: 'medium', description: 'Balanced motion' }, |
| { label: 'Dynamic', value: 'high', description: 'Strong, dramatic motion' }, |
| ]; |
| |
| const DURATION_PRESETS = [ |
| { label: '2s', value: 2 }, |
| { label: '4s', value: 4 }, |
| { label: '6s', value: 6 }, |
| { label: '8s', value: 8 }, |
| ]; |
| |
| const FPS_PRESETS = [ |
| { label: '8 fps', value: 8 }, |
| { label: '12 fps', value: 12 }, |
| { label: '16 fps', value: 16 }, |
| { label: '24 fps', value: 24 }, |
| ]; |
| |
| const QUALITY_PRESETS = [ |
| { id: 'low', label: 'Low', short: 'Fast', description: 'For 6-8GB VRAM. Fastest generation.' }, |
| { id: 'medium', label: 'Medium', short: 'Balanced', description: 'For 10-12GB VRAM. Good quality/speed balance.' }, |
| { id: 'high', label: 'High', short: 'Quality', description: 'For 16GB+ VRAM. Higher quality output.' }, |
| { id: 'ultra', label: 'Ultra', short: 'Maximum', description: 'For 24GB+ VRAM. Best quality, longest clips.' }, |
| ]; |
| |
| const DEFAULT_ASPECT_RATIOS = [ |
| { id: '16:9', label: 'Widescreen', previewW: 42, previewH: 24 }, |
| { id: '9:16', label: 'Vertical', previewW: 24, previewH: 42 }, |
| { id: '1:1', label: 'Square', previewW: 24, previewH: 24 }, |
| { id: '4:3', label: 'Classic', previewW: 32, previewH: 24 }, |
| { id: '3:4', label: 'Portrait', previewW: 24, previewH: 32 }, |
| ]; |
| |
| |
| const FALLBACK_ADVANCED_PARAMS = { |
| steps: 32, |
| cfg: 4.0, |
| denoise: 0.8, |
| }; |
| function isDim(x) { |
| return x && typeof x.width === 'number' && typeof x.height === 'number'; |
| } |
| |
| function getAutoVideoDims(opts) { |
| const { rawAspectRatioData, aspectRatio, qualityPreset } = opts; |
| const ratioEntry = rawAspectRatioData?.find((ar) => ar.id === aspectRatio); |
| |
| const allDims = ratioEntry?.all_dimensions; |
| if (allDims) { |
| const d = allDims[qualityPreset]; |
| if (isDim(d)) |
| return d; |
| const fallback = allDims['medium']; |
| return isDim(fallback) ? fallback : null; |
| } |
| |
| const flat = ratioEntry?.dimensions; |
| if (isDim(flat)) |
| return flat; |
| return null; |
| } |
| |
| function getOverrideVideoDims(opts) { |
| const { availableResolutions, customResolution } = opts; |
| if (customResolution === 'auto') |
| return null; |
| const hit = availableResolutions?.find((r) => r.id === customResolution); |
| return hit ? { width: hit.width, height: hit.height } : null; |
| } |
| |
| |
| |
| async function postJson(baseUrl, path, body, apiKey) { |
| const url = `${baseUrl.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; |
| const res = await fetch(url, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| ...(apiKey ? { 'x-api-key': apiKey } : {}), |
| }, |
| credentials: 'include', |
| body: JSON.stringify(body), |
| }); |
| if (!res.ok) { |
| const text = await res.text().catch(() => ''); |
| throw new Error(`HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ''}`); |
| } |
| return (await res.json()); |
| } |
| function uid() { |
| return `${Date.now()}-${Math.random().toString(16).slice(2)}`; |
| } |
| function formatDuration(seconds) { |
| const mins = Math.floor(seconds / 60); |
| const secs = seconds % 60; |
| if (mins > 0) |
| return `${mins}m ${secs}s`; |
| return `${secs}s`; |
| } |
| |
| |
| function isAnimatedImage(url) { |
| if (!url) |
| return false; |
| const lower = url.toLowerCase(); |
| |
| return lower.includes('.webp') || lower.includes('.gif'); |
| } |
| |
| |
| |
| export default function AnimateView(props) { |
| const authKey = (props.apiKey || '').trim(); |
| const [prompt, setPrompt] = useState(''); |
| |
| const [items, setItems] = useState(() => { |
| try { |
| const stored = localStorage.getItem('homepilot_animate_items'); |
| if (stored) { |
| const parsed = JSON.parse(stored); |
| if (Array.isArray(parsed)) { |
| |
| |
| |
| |
| |
| |
| const IN_FLIGHT_THRESHOLD = 3 * 60 * 1000; |
| const STALE_THRESHOLD = 10 * 60 * 1000; |
| const now = Date.now(); |
| return parsed.map((item) => { |
| |
| if (!item.status) { |
| return { ...item, status: item.videoUrl ? 'done' : 'failed' }; |
| } |
| |
| if (item.status === 'processing') { |
| const age = now - item.createdAt; |
| |
| if (age > STALE_THRESHOLD) { |
| return { ...item, status: 'failed', error: 'Generation interrupted - please retry' }; |
| } |
| |
| |
| if (age > IN_FLIGHT_THRESHOLD) { |
| return { |
| ...item, |
| status: 'failed', |
| error: 'Generation may have completed - check your gallery or retry' |
| }; |
| } |
| } |
| |
| return item; |
| }); |
| } |
| } |
| } |
| catch (error) { |
| console.error('Failed to load animate items from localStorage:', error); |
| } |
| return []; |
| }); |
| const [isGenerating, setIsGenerating] = useState(false); |
| |
| const [selectedVideo, setSelectedVideo] = useState(null); |
| const [showDetails, setShowDetails] = useState(false); |
| const [showSourceImage, setShowSourceImage] = useState(false); |
| const [lightboxPrompt, setLightboxPrompt] = useState(''); |
| const [isRegenerating, setIsRegenerating] = useState(false); |
| const [regenProgress, setRegenProgress] = useState(null); |
| const [regenAbortController, setRegenAbortController] = useState(null); |
| |
| const [seconds, setSeconds] = useState(props.vidSeconds || 4); |
| const [fps, setFps] = useState(props.vidFps || 8); |
| const [motion, setMotion] = useState(props.vidMotion || 'medium'); |
| const [showSettingsPanel, setShowSettingsPanel] = useState(false); |
| const [qualityPreset, setQualityPreset] = useState(props.vidPreset || 'medium'); |
| |
| useEffect(() => { |
| if (props.vidPreset) { |
| setQualityPreset(props.vidPreset); |
| } |
| }, [props.vidPreset]); |
| const [aspectRatio, setAspectRatio] = useState('16:9'); |
| const [compatibleAspectRatios, setCompatibleAspectRatios] = useState(DEFAULT_ASPECT_RATIOS); |
| const [rawAspectRatioData, setRawAspectRatioData] = useState([]); |
| |
| const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); |
| const [advancedMode, setAdvancedMode] = useState(false); |
| const [customSteps, setCustomSteps] = useState(FALLBACK_ADVANCED_PARAMS.steps); |
| const [customCfg, setCustomCfg] = useState(FALLBACK_ADVANCED_PARAMS.cfg); |
| const [customDenoise, setCustomDenoise] = useState(FALLBACK_ADVANCED_PARAMS.denoise); |
| const [seedLock, setSeedLock] = useState(false); |
| const [customSeed, setCustomSeed] = useState(0); |
| const [customNegativePrompt, setCustomNegativePrompt] = useState(''); |
| const [showNegativePrompt, setShowNegativePrompt] = useState(false); |
| |
| const [customResolution, setCustomResolution] = useState('auto'); |
| const [availableResolutions, setAvailableResolutions] = useState([]); |
| |
| const [vidResolutionMode, setVidResolutionMode] = useState('auto'); |
| |
| const [presetDefaults, setPresetDefaults] = useState(FALLBACK_ADVANCED_PARAMS); |
| |
| const detectedModelType = useMemo(() => { |
| const model = (props.modelVideo || '').toLowerCase(); |
| if (model.includes('ltx')) |
| return 'ltx'; |
| if (model.includes('svd')) |
| return 'svd'; |
| if (model.includes('wan')) |
| return 'wan'; |
| if (model.includes('hunyuan')) |
| return 'hunyuan'; |
| if (model.includes('mochi')) |
| return 'mochi'; |
| if (model.includes('cogvideo')) |
| return 'cogvideo'; |
| return null; |
| }, [props.modelVideo]); |
| |
| useEffect(() => { |
| const fetchPresets = async () => { |
| try { |
| const base = props.backendUrl.replace(/\/+$/, ''); |
| const params = new URLSearchParams(); |
| if (detectedModelType) |
| params.set('model', detectedModelType); |
| |
| |
| |
| const videoPreset = qualityPreset === 'custom' ? 'medium' |
| : qualityPreset === 'med' ? 'medium' |
| : qualityPreset === '4060' ? 'low' |
| : qualityPreset === '4080' ? 'medium' |
| : qualityPreset === 'a100' ? 'high' |
| : qualityPreset; |
| params.set('preset', videoPreset); |
| const res = await fetch(`${base}/video-presets?${params}`, { |
| headers: authKey ? { 'x-api-key': authKey } : undefined, |
| }); |
| if (res.ok) { |
| const data = await res.json(); |
| if (data.ok && data.values) { |
| setPresetDefaults({ |
| steps: data.values.steps ?? FALLBACK_ADVANCED_PARAMS.steps, |
| cfg: data.values.cfg ?? FALLBACK_ADVANCED_PARAMS.cfg, |
| denoise: data.values.denoise ?? FALLBACK_ADVANCED_PARAMS.denoise, |
| fps: data.values.fps, |
| frames: data.values.frames, |
| negativePrompt: data.model_rules?.default_negative_prompt ?? '', |
| defaultAspectRatio: data.default_aspect_ratio, |
| }); |
| } |
| |
| if (data.compatible_aspect_ratios && Array.isArray(data.compatible_aspect_ratios)) { |
| |
| setRawAspectRatioData(data.compatible_aspect_ratios); |
| const mappedRatios = data.compatible_aspect_ratios.map((ar) => ({ |
| id: ar.id, |
| label: ar.label?.replace(/\s*\([^)]*\)/g, '') || ar.id, |
| previewW: getPreviewDimension(ar.id, 'width'), |
| previewH: getPreviewDimension(ar.id, 'height'), |
| })); |
| if (mappedRatios.length > 0) { |
| setCompatibleAspectRatios(mappedRatios); |
| |
| if (!mappedRatios.find((r) => r.id === aspectRatio)) { |
| setAspectRatio(mappedRatios[0].id); |
| } |
| } |
| } |
| } |
| } |
| catch (err) { |
| console.warn('Failed to fetch video presets:', err); |
| |
| setCompatibleAspectRatios(DEFAULT_ASPECT_RATIOS); |
| } |
| }; |
| fetchPresets(); |
| |
| |
| |
| }, [props.backendUrl, authKey, detectedModelType, qualityPreset]); |
| |
| |
| useEffect(() => { |
| if (rawAspectRatioData.length === 0) |
| return; |
| const currentRatioData = rawAspectRatioData.find((ar) => ar.id === aspectRatio); |
| |
| const allDims = currentRatioData?.all_dimensions || currentRatioData?.dimensions; |
| if (allDims && typeof allDims === 'object') { |
| const presetLabels = { |
| test: 'Lowest (6GB)', |
| low: 'Low (8GB)', |
| medium: 'Medium (12GB)', |
| high: 'High (16GB)', |
| ultra: 'Ultra (24GB+)', |
| }; |
| |
| const tierOrder = ['test', 'low', 'medium', 'high', 'ultra']; |
| const resolutions = tierOrder |
| .filter((tier) => allDims[tier]?.width && allDims[tier]?.height) |
| .map((tier) => ({ |
| id: tier, |
| label: `${allDims[tier].width}×${allDims[tier].height} ${presetLabels[tier] || tier}`, |
| width: allDims[tier].width, |
| height: allDims[tier].height, |
| })); |
| setAvailableResolutions(resolutions); |
| |
| if (vidResolutionMode === 'override' && customResolution !== 'auto') { |
| const stillValid = resolutions.some((r) => r.id === customResolution); |
| if (!stillValid) { |
| setCustomResolution('auto'); |
| setVidResolutionMode('auto'); |
| } |
| } |
| else { |
| setCustomResolution('auto'); |
| setVidResolutionMode('auto'); |
| } |
| } |
| }, [aspectRatio, rawAspectRatioData]); |
| |
| function getPreviewDimension(ratioId, dimension) { |
| const previewSizes = { |
| '16:9': { w: 42, h: 24 }, |
| '9:16': { w: 24, h: 42 }, |
| '1:1': { w: 24, h: 24 }, |
| '4:3': { w: 32, h: 24 }, |
| '3:4': { w: 24, h: 32 }, |
| }; |
| const size = previewSizes[ratioId] || { w: 32, h: 24 }; |
| return dimension === 'width' ? size.w : size.h; |
| } |
| |
| const resetAdvancedParams = useCallback(() => { |
| setCustomSteps(presetDefaults.steps ?? FALLBACK_ADVANCED_PARAMS.steps); |
| setCustomCfg(presetDefaults.cfg ?? FALLBACK_ADVANCED_PARAMS.cfg); |
| setCustomDenoise(presetDefaults.denoise ?? FALLBACK_ADVANCED_PARAMS.denoise); |
| setSeedLock(false); |
| setCustomSeed(0); |
| setCustomNegativePrompt(''); |
| setShowNegativePrompt(false); |
| setCustomResolution('auto'); |
| setVidResolutionMode('auto'); |
| }, [presetDefaults]); |
| |
| |
| const resetVideoSettings = useCallback(() => { |
| |
| const defaultRatio = presetDefaults.defaultAspectRatio; |
| if (defaultRatio && compatibleAspectRatios.find(r => r.id === defaultRatio)) { |
| setAspectRatio(defaultRatio); |
| } |
| else if (compatibleAspectRatios.length > 0) { |
| |
| setAspectRatio(compatibleAspectRatios[0].id); |
| } |
| |
| setQualityPreset(props.vidPreset || 'medium'); |
| |
| setMotion('medium'); |
| }, [presetDefaults.defaultAspectRatio, compatibleAspectRatios, props.vidPreset]); |
| |
| |
| useEffect(() => { |
| if (!advancedMode) { |
| |
| setCustomSteps(presetDefaults.steps ?? FALLBACK_ADVANCED_PARAMS.steps); |
| setCustomCfg(presetDefaults.cfg ?? FALLBACK_ADVANCED_PARAMS.cfg); |
| setCustomDenoise(presetDefaults.denoise ?? FALLBACK_ADVANCED_PARAMS.denoise); |
| |
| if (presetDefaults.fps) { |
| setFps(presetDefaults.fps); |
| } |
| |
| if (presetDefaults.frames && presetDefaults.fps) { |
| const calculatedSeconds = Math.round(presetDefaults.frames / presetDefaults.fps); |
| |
| const validDurations = [2, 4, 6, 8]; |
| const closestDuration = validDurations.reduce((prev, curr) => Math.abs(curr - calculatedSeconds) < Math.abs(prev - calculatedSeconds) ? curr : prev); |
| setSeconds(closestDuration); |
| } |
| } |
| }, [presetDefaults, advancedMode]); |
| |
| const referenceInputRef = useRef(null); |
| const [referenceUrl, setReferenceUrl] = useState(null); |
| const [isUploadingReference, setIsUploadingReference] = useState(false); |
| |
| const gridStartRef = useRef(null); |
| |
| useEffect(() => { |
| try { |
| localStorage.setItem('homepilot_animate_items', JSON.stringify(items)); |
| } |
| catch (error) { |
| console.error('Failed to save animate items to localStorage:', error); |
| } |
| }, [items]); |
| |
| |
| useEffect(() => { |
| const processingItems = items.filter(item => item.status === 'processing'); |
| if (processingItems.length === 0) |
| return; |
| |
| const IN_FLIGHT_THRESHOLD = 3 * 60 * 1000; |
| |
| const syncFromLocalStorage = () => { |
| try { |
| const stored = localStorage.getItem('homepilot_animate_items'); |
| if (!stored) |
| return false; |
| const storedItems = JSON.parse(stored); |
| const storedMap = new Map(storedItems.map(item => [item.id, item])); |
| let hasUpdates = false; |
| setItems(prev => { |
| const updated = prev.map(item => { |
| if (item.status !== 'processing') |
| return item; |
| |
| const storedItem = storedMap.get(item.id); |
| if (storedItem && storedItem.status === 'done' && storedItem.videoUrl) { |
| console.log('[Animate] Synced completed item from localStorage:', item.id); |
| hasUpdates = true; |
| return storedItem; |
| } |
| |
| const age = Date.now() - item.createdAt; |
| if (age > IN_FLIGHT_THRESHOLD) { |
| hasUpdates = true; |
| return { |
| ...item, |
| status: 'failed', |
| error: 'Generation may have completed - check your gallery or retry' |
| }; |
| } |
| |
| return { |
| ...item, |
| progress: Math.min(90, (item.progress || 0) + Math.random() * 3 + 1) |
| }; |
| }); |
| return updated; |
| }); |
| return hasUpdates; |
| } |
| catch (e) { |
| console.error('[Animate] Failed to sync from localStorage:', e); |
| return false; |
| } |
| }; |
| |
| const progressInterval = setInterval(() => { |
| syncFromLocalStorage(); |
| }, 800); |
| |
| syncFromLocalStorage(); |
| return () => clearInterval(progressInterval); |
| |
| |
| }, []); |
| |
| useEffect(() => { |
| gridStartRef.current?.scrollIntoView({ block: 'start' }); |
| }, []); |
| |
| useEffect(() => { |
| try { |
| const raw = localStorage.getItem('homepilot_animate_handoff'); |
| if (!raw) |
| return; |
| const data = JSON.parse(raw); |
| const tooOld = Date.now() - (data.createdAt ?? 0) > 2 * 60 * 1000; |
| if (tooOld) { |
| localStorage.removeItem('homepilot_animate_handoff'); |
| return; |
| } |
| if (data.imageUrl) { |
| setReferenceUrl(data.imageUrl); |
| console.log('[Animate] Loaded source image from Imagine handoff:', data.imageUrl); |
| } |
| if (typeof data.prompt === 'string' && data.prompt.trim()) { |
| setPrompt(data.prompt); |
| } |
| |
| localStorage.removeItem('homepilot_animate_handoff'); |
| } |
| catch { |
| localStorage.removeItem('homepilot_animate_handoff'); |
| } |
| }, []); |
| |
| useEffect(() => { |
| if (selectedVideo) { |
| setLightboxPrompt(selectedVideo.finalPrompt || selectedVideo.prompt); |
| } |
| }, [selectedVideo]); |
| |
| const handleUploadReference = useCallback(async (file) => { |
| setIsUploadingReference(true); |
| try { |
| const base = props.backendUrl.replace(/\/+$/, ''); |
| const url = `${base}/upload`; |
| const fd = new FormData(); |
| fd.append('file', file); |
| const res = await fetch(url, { |
| method: 'POST', |
| headers: authKey ? { 'x-api-key': authKey } : undefined, |
| body: fd, |
| }); |
| if (!res.ok) { |
| const text = await res.text().catch(() => `HTTP ${res.status}`); |
| throw new Error(text); |
| } |
| const data = await res.json(); |
| const imageUrl = data?.url || data?.file_url || data?.media_url; |
| if (!imageUrl) { |
| throw new Error('Upload succeeded but no URL returned'); |
| } |
| setReferenceUrl(imageUrl); |
| } |
| catch (err) { |
| console.error('Reference upload failed:', err); |
| alert(`Failed to upload reference: ${err.message || err}`); |
| } |
| finally { |
| setIsUploadingReference(false); |
| } |
| }, [props.backendUrl, authKey]); |
| |
| const mappedPreset = useMemo(() => { |
| return qualityPreset === 'custom' ? 'medium' |
| : qualityPreset === 'med' ? 'medium' |
| : qualityPreset === '4060' ? 'low' |
| : qualityPreset === '4080' ? 'medium' |
| : qualityPreset === 'a100' ? 'high' |
| : qualityPreset; |
| }, [qualityPreset]); |
| const autoDims = useMemo(() => getAutoVideoDims({ rawAspectRatioData, aspectRatio, qualityPreset: mappedPreset }), [rawAspectRatioData, aspectRatio, mappedPreset]); |
| const overrideDims = useMemo(() => getOverrideVideoDims({ availableResolutions, customResolution }), [availableResolutions, customResolution]); |
| const effectiveDims = vidResolutionMode === 'override' && overrideDims ? overrideDims : autoDims; |
| |
| const actualDuration = presetDefaults?.frames && presetDefaults?.fps |
| ? (presetDefaults.frames / presetDefaults.fps).toFixed(1) |
| : null; |
| |
| const isMotionSupported = detectedModelType !== 'svd'; |
| const handleGenerate = useCallback(async () => { |
| |
| const t = prompt.trim(); |
| if (!t && !referenceUrl) |
| return; |
| if (isGenerating) |
| return; |
| |
| const effectivePrompt = t || 'animate this image with natural motion'; |
| setIsGenerating(true); |
| setShowSettingsPanel(false); |
| |
| const placeholderId = uid(); |
| const placeholder = { |
| id: placeholderId, |
| status: 'processing', |
| progress: 0, |
| createdAt: Date.now(), |
| prompt: effectivePrompt, |
| sourceImageUrl: referenceUrl || undefined, |
| posterUrl: referenceUrl || undefined, |
| seconds: seconds, |
| fps: fps, |
| motion: motion, |
| model: props.modelVideo, |
| preset: qualityPreset, |
| aspectRatio: aspectRatio, |
| }; |
| |
| setItems((prev) => [placeholder, ...prev]); |
| setPrompt(''); |
| |
| setTimeout(() => { |
| gridStartRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| }, 100); |
| |
| const progressInterval = setInterval(() => { |
| setItems(prev => prev.map(item => item.id === placeholderId && item.status === 'processing' |
| ? { ...item, progress: Math.min(90, (item.progress || 0) + Math.random() * 8 + 2) } |
| : item)); |
| }, 500); |
| try { |
| |
| const animateMessage = referenceUrl |
| ? `animate ${referenceUrl} ${effectivePrompt}` |
| : `animate ${effectivePrompt}`; |
| |
| const useOverride = vidResolutionMode === 'override' && overrideDims != null; |
| const resolvedVidWidth = useOverride ? overrideDims.width : autoDims?.width; |
| const resolvedVidHeight = useOverride ? overrideDims.height : autoDims?.height; |
| |
| const videoPreset = qualityPreset === 'custom' ? 'medium' |
| : qualityPreset === 'med' ? 'medium' |
| : qualityPreset === '4060' ? 'low' |
| : qualityPreset === '4080' ? 'medium' |
| : qualityPreset === 'a100' ? 'high' |
| : qualityPreset; |
| const requestBody = { |
| message: animateMessage, |
| mode: 'animate', |
| |
| vidSeconds: seconds, |
| vidFps: fps, |
| vidMotion: motion, |
| vidModel: props.modelVideo || undefined, |
| vidPreset: videoPreset, |
| vidAspectRatio: aspectRatio, |
| |
| vidResolutionMode: useOverride ? 'override' : 'auto', |
| ...(useOverride && { |
| vidWidth: resolvedVidWidth, |
| vidHeight: resolvedVidHeight, |
| }), |
| |
| ...(useOverride && { |
| imgWidth: resolvedVidWidth, |
| imgHeight: resolvedVidHeight, |
| }), |
| |
| ...(advancedMode && { |
| vidSteps: customSteps, |
| vidCfg: customCfg, |
| vidDenoise: customDenoise, |
| ...(seedLock && { vidSeed: customSeed }), |
| ...(customNegativePrompt.trim() && { vidNegativePrompt: customNegativePrompt.trim() }), |
| }), |
| |
| provider: props.providerVideo === 'comfyui' ? 'ollama' : props.providerVideo, |
| provider_base_url: props.baseUrlVideo || undefined, |
| provider_model: props.modelVideo || undefined, |
| |
| ollama_base_url: props.providerChat === 'ollama' ? props.baseUrlChat : undefined, |
| ollama_model: props.providerChat === 'ollama' ? props.modelChat : undefined, |
| nsfwMode: props.nsfwMode, |
| promptRefinement: props.promptRefinement ?? true, |
| }; |
| const data = await postJson(props.backendUrl, '/chat', requestBody, authKey); |
| clearInterval(progressInterval); |
| if (!data.media?.video_url) { |
| throw new Error(data.message || data.text || 'No video URL returned'); |
| } |
| |
| const completedItem = { |
| ...placeholder, |
| status: 'done', |
| progress: 100, |
| videoUrl: data.media.video_url, |
| posterUrl: data.media.poster_url || placeholder.posterUrl, |
| finalPrompt: data.media.final_prompt || data.media.prompt, |
| sourceImageUrl: data.media.auto_generated_image || referenceUrl || data.media.source_image || placeholder.sourceImageUrl, |
| seed: data.media.seed ?? (seedLock ? customSeed : undefined), |
| seconds: data.media.duration ?? seconds, |
| frames: data.media.frames, |
| fps: data.media.fps ?? fps, |
| motion: data.media.motion ?? motion, |
| model: data.media.model ?? props.modelVideo, |
| preset: data.media.preset ?? qualityPreset, |
| aspectRatio: aspectRatio, |
| |
| width: data.media.width, |
| height: data.media.height, |
| steps: data.media.steps ?? (advancedMode ? customSteps : undefined), |
| cfg: data.media.cfg ?? (advancedMode ? customCfg : undefined), |
| denoise: data.media.denoise ?? (advancedMode ? customDenoise : undefined), |
| }; |
| |
| |
| |
| try { |
| const stored = localStorage.getItem('homepilot_animate_items'); |
| const currentItems = stored ? JSON.parse(stored) : []; |
| const updatedItems = currentItems.map(item => item.id === placeholderId ? completedItem : item); |
| localStorage.setItem('homepilot_animate_items', JSON.stringify(updatedItems)); |
| console.log('[Animate] Persisted completed video to localStorage:', placeholderId); |
| } |
| catch (e) { |
| console.error('[Animate] Failed to persist to localStorage:', e); |
| } |
| |
| setItems(prev => prev.map(item => item.id === placeholderId ? completedItem : item)); |
| } |
| catch (err) { |
| clearInterval(progressInterval); |
| console.error('Video generation failed:', err); |
| |
| const failedItem = { |
| ...placeholder, |
| status: 'failed', |
| error: err.message || 'Generation failed' |
| }; |
| |
| try { |
| const stored = localStorage.getItem('homepilot_animate_items'); |
| const currentItems = stored ? JSON.parse(stored) : []; |
| const updatedItems = currentItems.map(item => item.id === placeholderId ? failedItem : item); |
| localStorage.setItem('homepilot_animate_items', JSON.stringify(updatedItems)); |
| } |
| catch (e) { |
| console.error('Failed to persist to localStorage:', e); |
| } |
| |
| setItems(prev => prev.map(item => item.id === placeholderId ? failedItem : item)); |
| } |
| finally { |
| setIsGenerating(false); |
| } |
| }, [prompt, referenceUrl, isGenerating, seconds, fps, motion, qualityPreset, aspectRatio, props, authKey, advancedMode, customSteps, customCfg, customDenoise, seedLock, customSeed, customNegativePrompt, vidResolutionMode, autoDims, overrideDims]); |
| const handleDelete = useCallback((item, e) => { |
| e.stopPropagation(); |
| if (!confirm('Delete this video from gallery?')) |
| return; |
| setItems((prev) => prev.filter((x) => x.id !== item.id)); |
| if (selectedVideo?.id === item.id) |
| setSelectedVideo(null); |
| }, [selectedVideo]); |
| const handleDownload = useCallback((url, filename) => { |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = filename || 'animated-video.mp4'; |
| document.body.appendChild(a); |
| a.click(); |
| a.remove(); |
| }, []); |
| const handleCopyPrompt = useCallback(async (text) => { |
| try { |
| await navigator.clipboard.writeText(text); |
| } |
| catch { } |
| }, []); |
| |
| const handleRegenerateInPlace = useCallback(async () => { |
| if (!selectedVideo || !lightboxPrompt.trim() || isRegenerating) |
| return; |
| const abortController = new AbortController(); |
| setRegenAbortController(abortController); |
| setIsRegenerating(true); |
| setRegenProgress(0); |
| |
| const progressInterval = setInterval(() => { |
| setRegenProgress(prev => { |
| if (prev === null) |
| return 0; |
| |
| if (prev >= 90) |
| return prev; |
| return Math.min(90, prev + Math.random() * 8 + 2); |
| }); |
| }, 500); |
| try { |
| |
| const existingSourceImage = selectedVideo.sourceImageUrl; |
| |
| |
| |
| const animateMessage = existingSourceImage |
| ? `animate ${existingSourceImage} ${lightboxPrompt}` |
| : `animate ${lightboxPrompt}`; |
| |
| const regenUseOverride = vidResolutionMode === 'override' && overrideDims != null; |
| const regenVidWidth = regenUseOverride ? overrideDims.width : autoDims?.width; |
| const regenVidHeight = regenUseOverride ? overrideDims.height : autoDims?.height; |
| |
| const videoPreset = qualityPreset === 'custom' ? 'medium' |
| : qualityPreset === 'med' ? 'medium' |
| : qualityPreset === '4060' ? 'low' |
| : qualityPreset === '4080' ? 'medium' |
| : qualityPreset === 'a100' ? 'high' |
| : qualityPreset; |
| const requestBody = { |
| message: animateMessage, |
| mode: 'animate', |
| vidSeconds: seconds, |
| vidFps: fps, |
| vidMotion: motion, |
| vidModel: props.modelVideo || undefined, |
| vidPreset: videoPreset, |
| vidAspectRatio: aspectRatio, |
| |
| |
| ...(existingSourceImage && { skipImageGeneration: true }), |
| |
| vidResolutionMode: regenUseOverride ? 'override' : 'auto', |
| ...(regenUseOverride && { |
| vidWidth: regenVidWidth, |
| vidHeight: regenVidHeight, |
| }), |
| |
| ...(regenUseOverride && { |
| imgWidth: regenVidWidth, |
| imgHeight: regenVidHeight, |
| }), |
| ...(advancedMode && { |
| vidSteps: customSteps, |
| vidCfg: customCfg, |
| vidDenoise: customDenoise, |
| ...(seedLock && { vidSeed: customSeed }), |
| ...(customNegativePrompt.trim() && { vidNegativePrompt: customNegativePrompt.trim() }), |
| }), |
| provider: props.providerVideo === 'comfyui' ? 'ollama' : props.providerVideo, |
| provider_base_url: props.baseUrlVideo || undefined, |
| provider_model: props.modelVideo || undefined, |
| ollama_base_url: props.providerChat === 'ollama' ? props.baseUrlChat : undefined, |
| ollama_model: props.providerChat === 'ollama' ? props.modelChat : undefined, |
| nsfwMode: props.nsfwMode, |
| promptRefinement: props.promptRefinement ?? true, |
| }; |
| const data = await postJson(props.backendUrl, '/chat', requestBody, authKey); |
| |
| if (abortController.signal.aborted) { |
| return; |
| } |
| if (!data.media?.video_url) { |
| throw new Error(data.message || data.text || 'No video URL returned'); |
| } |
| |
| setRegenProgress(100); |
| |
| |
| |
| const newItem = { |
| id: uid(), |
| status: 'done', |
| videoUrl: data.media.video_url, |
| posterUrl: data.media.poster_url, |
| createdAt: Date.now(), |
| prompt: lightboxPrompt, |
| finalPrompt: data.media.final_prompt || data.media.prompt, |
| |
| |
| sourceImageUrl: existingSourceImage || data.media.auto_generated_image || data.media.source_image || undefined, |
| seed: data.media.seed ?? (seedLock ? customSeed : undefined), |
| seconds: data.media.duration ?? seconds, |
| frames: data.media.frames, |
| fps: data.media.fps ?? fps, |
| motion: data.media.motion ?? motion, |
| model: data.media.model ?? props.modelVideo, |
| preset: data.media.preset ?? qualityPreset, |
| aspectRatio: aspectRatio, |
| |
| width: data.media.width, |
| height: data.media.height, |
| steps: data.media.steps ?? (advancedMode ? customSteps : undefined), |
| cfg: data.media.cfg ?? (advancedMode ? customCfg : undefined), |
| denoise: data.media.denoise ?? (advancedMode ? customDenoise : undefined), |
| }; |
| |
| setSelectedVideo(newItem); |
| setLightboxPrompt(newItem.finalPrompt || newItem.prompt); |
| |
| setItems(prev => [newItem, ...prev]); |
| |
| await new Promise(r => setTimeout(r, 300)); |
| } |
| catch (err) { |
| if (abortController.signal.aborted) { |
| return; |
| } |
| console.error('Regeneration failed:', err); |
| alert(`Regeneration failed: ${err.message || err}`); |
| } |
| finally { |
| clearInterval(progressInterval); |
| setIsRegenerating(false); |
| setRegenProgress(null); |
| setRegenAbortController(null); |
| } |
| }, [selectedVideo, lightboxPrompt, isRegenerating, seconds, fps, motion, qualityPreset, aspectRatio, props, authKey, advancedMode, customSteps, customCfg, customDenoise, seedLock, customSeed, customNegativePrompt, vidResolutionMode, autoDims, overrideDims]); |
| |
| const handleCancelRegeneration = useCallback(() => { |
| if (regenAbortController) { |
| regenAbortController.abort(); |
| setIsRegenerating(false); |
| setRegenProgress(null); |
| setRegenAbortController(null); |
| } |
| }, [regenAbortController]); |
| return (<div className="flex flex-col h-full w-full bg-black text-white overflow-hidden relative"> |
| {/* Advanced Settings Panel (top-right) */} |
| {showAdvancedSettings && (<div className="absolute top-20 right-6 z-30 bg-black/95 border border-white/10 rounded-2xl shadow-2xl w-80 backdrop-blur-xl overflow-hidden"> |
| <div className="p-5 border-b border-white/10 flex items-center justify-between"> |
| <h3 className="text-sm font-bold text-white flex items-center gap-2"> |
| <Settings2 size={16}/> |
| PARAMETERS |
| </h3> |
| <div className="flex items-center gap-2"> |
| <button type="button" onClick={resetAdvancedParams} className="text-white/50 hover:text-purple-400 transition-colors flex items-center gap-1 text-xs" title="Reset to recommended defaults"> |
| <RefreshCw size={14}/> |
| Reset |
| </button> |
| <button type="button" onClick={() => setShowAdvancedSettings(false)} className="text-white/50 hover:text-white"> |
| <X size={16}/> |
| </button> |
| </div> |
| </div> |
| |
| <div className="p-5 space-y-6 max-h-[70vh] overflow-y-auto"> |
| {/* Advanced Mode Toggle */} |
| <button onClick={() => setAdvancedMode(!advancedMode)} className={`w-full flex items-center justify-between p-3 rounded-xl border transition-colors ${advancedMode |
| ? 'bg-purple-500/20 border-purple-500/40 text-purple-300' |
| : 'bg-white/5 border-white/10 text-white/60 hover:border-white/20'}`}> |
| <span className="flex items-center gap-2 font-medium text-sm"> |
| <Sliders size={16}/> |
| Advanced Controls |
| </span> |
| {advancedMode ? <ChevronDown size={16}/> : <ChevronRight size={16}/>} |
| </button> |
| |
| {advancedMode && (<div className="space-y-4 animate-in fade-in slide-in-from-top-2 duration-200"> |
| {/* Duration */} |
| <div className="space-y-2"> |
| <span className="uppercase tracking-wider text-white/40 font-semibold text-xs">Duration</span> |
| <div className="flex gap-2"> |
| {DURATION_PRESETS.map((d) => (<button key={d.value} onClick={() => setSeconds(d.value)} className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium transition-colors ${seconds === d.value |
| ? 'bg-purple-500/30 text-purple-200 border border-purple-500/50' |
| : 'bg-white/5 text-white/70 border border-white/10 hover:bg-white/10'}`}> |
| {d.label} |
| </button>))} |
| </div> |
| </div> |
| |
| {/* Frame Rate */} |
| <div className="space-y-2"> |
| <span className="uppercase tracking-wider text-white/40 font-semibold text-xs">Frame Rate</span> |
| <div className="flex gap-2"> |
| {FPS_PRESETS.map((f) => (<button key={f.value} onClick={() => setFps(f.value)} className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium transition-colors ${fps === f.value |
| ? 'bg-purple-500/30 text-purple-200 border border-purple-500/50' |
| : 'bg-white/5 text-white/70 border border-white/10 hover:bg-white/10'}`}> |
| {f.label} |
| </button>))} |
| </div> |
| </div> |
| |
| {/* Resolution: Auto(Preset) vs Override tier */} |
| <div className="space-y-2"> |
| <div className="flex justify-between items-center text-xs"> |
| <span className="uppercase tracking-wider text-white/40 font-semibold">Resolution</span> |
| {effectiveDims && (<span className="text-purple-400/60 font-mono text-[10px]"> |
| {effectiveDims.width}x{effectiveDims.height} |
| </span>)} |
| </div> |
| <div className="flex gap-1.5 mb-1.5"> |
| <button onClick={() => { setVidResolutionMode('auto'); setCustomResolution('auto'); }} className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium transition-colors ${vidResolutionMode === 'auto' |
| ? 'bg-purple-500/30 text-purple-200 border border-purple-500/50' |
| : 'bg-white/5 text-white/70 border border-white/10 hover:bg-white/10'}`}> |
| Auto (Preset) |
| </button> |
| <button onClick={() => setVidResolutionMode('override')} className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium transition-colors ${vidResolutionMode === 'override' |
| ? 'bg-purple-500/30 text-purple-200 border border-purple-500/50' |
| : 'bg-white/5 text-white/70 border border-white/10 hover:bg-white/10'}`}> |
| Override |
| </button> |
| </div> |
| {vidResolutionMode === 'override' && (<div className="grid grid-cols-3 gap-1.5"> |
| {availableResolutions.map((r) => { |
| const isSelected = customResolution === r.id; |
| const isCurrentPreset = r.id === mappedPreset; |
| return (<button key={r.id} onClick={() => setCustomResolution(r.id)} title={`${r.width}x${r.height}${isCurrentPreset ? ' (current preset default)' : ''}`} className={`relative py-1.5 px-2 rounded-lg text-xs font-medium transition-colors ${isSelected |
| ? 'bg-purple-500/30 text-purple-200 border border-purple-500/50' |
| : isCurrentPreset |
| ? 'bg-white/8 text-white/80 border border-white/20 ring-1 ring-purple-500/25' |
| : 'bg-white/5 text-white/70 border border-white/10 hover:bg-white/10'}`}> |
| <div className="font-mono text-[10px]">{r.width}x{r.height}</div> |
| <div className="text-[9px] text-white/40 capitalize">{r.id}</div> |
| {isCurrentPreset && !isSelected && (<div className="absolute -top-1 -right-1 w-1.5 h-1.5 bg-purple-400/60 rounded-full"/>)} |
| </button>); |
| })} |
| </div>)} |
| <p className="text-[10px] text-white/30 leading-relaxed"> |
| Override resolution to test what works best on your GPU. Lower = faster, less VRAM. |
| </p> |
| </div> |
| |
| {/* Steps */} |
| <div className="space-y-2"> |
| <div className="flex justify-between text-xs"> |
| <span className="uppercase tracking-wider text-white/40 font-semibold">Steps</span> |
| <span className="text-white/60">{customSteps}</span> |
| </div> |
| <input type="range" min={10} max={50} value={customSteps} onChange={(e) => setCustomSteps(Number(e.target.value))} className="w-full h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-purple-400 [&::-webkit-slider-thumb]:rounded-full"/> |
| </div> |
| |
| {/* CFG Scale */} |
| <div className="space-y-2"> |
| <div className="flex justify-between text-xs"> |
| <span className="uppercase tracking-wider text-white/40 font-semibold">CFG Scale</span> |
| <span className="text-white/60">{customCfg.toFixed(1)}</span> |
| </div> |
| <input type="range" min={1} max={15} step={0.5} value={customCfg} onChange={(e) => setCustomCfg(Number(e.target.value))} className="w-full h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-purple-400 [&::-webkit-slider-thumb]:rounded-full"/> |
| </div> |
| |
| {/* Creativity / Denoise Strength */} |
| <div className="space-y-2"> |
| <div className="flex justify-between text-xs"> |
| <span className="uppercase tracking-wider text-white/40 font-semibold">Creativity</span> |
| <span className="text-white/60">{customDenoise.toFixed(2)}</span> |
| </div> |
| <div className="flex justify-between text-[10px] text-white/30 mb-1"> |
| <span>More Faithful</span> |
| <span>More Creative</span> |
| </div> |
| <input type="range" min={0.1} max={1.0} step={0.05} value={customDenoise} onChange={(e) => setCustomDenoise(Number(e.target.value))} className="w-full h-1.5 bg-white/10 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-purple-400 [&::-webkit-slider-thumb]:rounded-full"/> |
| </div> |
| |
| {/* Custom Negative Prompt */} |
| <div className="space-y-2"> |
| <div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/10"> |
| <span className="text-sm text-white/80">Custom Negative Prompt</span> |
| <button onClick={() => setShowNegativePrompt(!showNegativePrompt)} className={`w-10 h-5 rounded-full transition-colors relative ${showNegativePrompt ? 'bg-purple-500' : 'bg-white/20'}`}> |
| <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${showNegativePrompt ? 'translate-x-5' : 'translate-x-0.5'}`}/> |
| </button> |
| </div> |
| {showNegativePrompt && (<div className="space-y-2"> |
| <textarea value={customNegativePrompt} onChange={(e) => setCustomNegativePrompt(e.target.value)} placeholder={presetDefaults.negativePrompt || 'text, watermark, logo, low quality, blurry, flicker, jitter, deformed'} className="w-full rounded-xl bg-white/5 border border-white/10 px-3 py-2 text-sm text-white focus:border-purple-500/50 focus:outline-none resize-none h-20"/> |
| <p className="text-[10px] text-white/40"> |
| Leave empty to use model default. Separate terms with commas. |
| </p> |
| </div>)} |
| </div> |
| |
| {/* Lock Seed */} |
| <div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/10"> |
| <span className="text-sm text-white/80">Lock Seed</span> |
| <button onClick={() => { |
| if (!seedLock) |
| setCustomSeed(Math.floor(Math.random() * 2147483647)); |
| setSeedLock(!seedLock); |
| }} className={`w-10 h-5 rounded-full transition-colors relative ${seedLock ? 'bg-purple-500' : 'bg-white/20'}`}> |
| <div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${seedLock ? 'translate-x-5' : 'translate-x-0.5'}`}/> |
| </button> |
| </div> |
| {seedLock && (<input type="number" value={customSeed} onChange={(e) => setCustomSeed(Number(e.target.value))} className="w-full rounded-xl bg-white/5 border border-white/10 px-3 py-2 text-sm text-white font-mono focus:border-purple-500/50 focus:outline-none" placeholder="Seed value"/>)} |
| |
| {/* Final Output Summary */} |
| <div className="p-3 rounded-xl bg-white/[0.03] border border-white/10"> |
| <div className="uppercase tracking-wider text-white/40 font-semibold text-xs mb-2">Final Output</div> |
| <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs"> |
| <div className="flex justify-between"> |
| <span className="text-white/40">Resolution</span> |
| <span className="text-white/70 font-mono"> |
| {effectiveDims ? `${effectiveDims.width}x${effectiveDims.height}` : '\u2014'} |
| </span> |
| </div> |
| <div className="flex justify-between"> |
| <span className="text-white/40">Aspect</span> |
| <span className="text-white/70 font-mono">{aspectRatio}</span> |
| </div> |
| <div className="flex justify-between"> |
| <span className="text-white/40">Frames</span> |
| <span className="text-white/70 font-mono">{presetDefaults?.frames ?? '\u2014'}</span> |
| </div> |
| <div className="flex justify-between"> |
| <span className="text-white/40">FPS</span> |
| <span className="text-white/70 font-mono">{presetDefaults?.fps ?? '\u2014'}</span> |
| </div> |
| <div className="flex justify-between col-span-2"> |
| <span className="text-white/40">Duration</span> |
| <span className="text-white/70 font-mono"> |
| {actualDuration ? `~${actualDuration}s` : '\u2014'} |
| </span> |
| </div> |
| </div> |
| </div> |
| </div>)} |
| |
| <div className="p-4 rounded-xl bg-purple-900/10 border border-purple-500/20 text-xs text-purple-200/70 leading-relaxed"> |
| <span className="font-bold text-purple-400 block mb-1">PRO TIP</span> |
| Enable Advanced Controls to fine-tune generation parameters. Use Lock Seed to regenerate with the same composition. |
| </div> |
| </div> |
| </div>)} |
| |
| {/* Floating Advanced Settings Toggle Button */} |
| <button className={`absolute top-6 right-6 z-20 p-3 rounded-full border shadow-lg transition-all ${showAdvancedSettings |
| ? 'bg-purple-500 text-white border-purple-400' |
| : 'bg-black/80 hover:bg-black border-white/20 text-white/70 hover:text-white backdrop-blur-sm'}`} type="button" onClick={() => setShowAdvancedSettings(!showAdvancedSettings)} title="Advanced Parameters"> |
| <Sliders size={20}/> |
| </button> |
| |
| {/* Grid Gallery */} |
| <div className="flex-1 overflow-y-auto px-4 pb-48 pt-8 scrollbar-hide"> |
| <div className="max-w-[1600px] mx-auto grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 content-start"> |
| {/* Scroll anchor */} |
| <div ref={gridStartRef} className="col-span-full h-1"/> |
| |
| {/* Empty hint */} |
| {items.length === 0 && !isGenerating ? (<div className="col-span-full rounded-2xl border border-white/10 bg-white/5 p-8 text-center"> |
| <Film size={48} className="mx-auto mb-4 text-purple-400/50"/> |
| <div className="text-lg font-semibold text-white/90 mb-2">Your video gallery is empty</div> |
| <div className="text-sm text-white/45 mb-4"> |
| Upload an image and describe the motion you want to see, or just describe a scene. |
| </div> |
| <button onClick={() => referenceInputRef.current?.click()} className="px-4 py-2 bg-purple-500/20 text-purple-200 rounded-xl border border-purple-500/30 hover:bg-purple-500/30 transition-colors"> |
| <span className="inline-flex items-center gap-2"> |
| <Upload size={16}/> |
| Upload Source Image |
| </span> |
| </button> |
| </div>) : null} |
| |
| {/* Video items (including processing placeholders) */} |
| {items.map((item) => (<div key={item.id} onClick={() => item.status === 'done' && setSelectedVideo(item)} className={`relative group rounded-2xl overflow-hidden bg-white/5 border border-white/10 transition-colors aspect-video ${item.status === 'done' ? 'hover:border-purple-500/30 cursor-pointer' : ''}`}> |
| {/* Thumbnail/Video content - show based on status */} |
| {item.status === 'done' && item.videoUrl ? ( |
| // Completed video |
| isAnimatedImage(item.videoUrl) ? (<img src={resolveFileUrl(item.videoUrl)} alt="Generated animation" className="absolute inset-0 w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"/>) : (<video src={resolveFileUrl(item.videoUrl)} poster={item.posterUrl ? resolveFileUrl(item.posterUrl) : undefined} muted loop playsInline preload="metadata" className="absolute inset-0 w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" onMouseEnter={(e) => e.currentTarget.play()} onMouseLeave={(e) => { |
| e.currentTarget.pause(); |
| e.currentTarget.currentTime = 0; |
| }}/>)) : ( |
| // Processing or Failed - show thumbnail placeholder |
| <div className="absolute inset-0 w-full h-full"> |
| {(item.posterUrl || item.sourceImageUrl) ? (<img src={resolveFileUrl(item.posterUrl || item.sourceImageUrl || '')} alt="Generating..." className="w-full h-full object-cover"/>) : (<div className="w-full h-full bg-gradient-to-br from-purple-900/30 to-black"/>)} |
| </div>)} |
| |
| {/* Processing overlay - "Generating video..." */} |
| {item.status === 'processing' && (<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/50 backdrop-blur-sm"> |
| <Loader2 size={32} className="text-purple-400 animate-spin mb-3"/> |
| <div className="text-white/90 text-sm font-medium">Generating video...</div> |
| {typeof item.progress === 'number' && (<div className="mt-2 px-3 py-1 rounded-full bg-black/60 border border-white/10 text-white/90 text-xs"> |
| {Math.round(item.progress)}% |
| </div>)} |
| </div>)} |
| |
| {/* Failed overlay */} |
| {item.status === 'failed' && (<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm"> |
| <X size={32} className="text-red-400 mb-2"/> |
| <div className="text-red-400 text-sm font-medium">Generation failed</div> |
| <div className="text-white/50 text-xs mt-1 px-4 text-center line-clamp-2"> |
| {item.error || 'Unknown error'} |
| </div> |
| <button className="mt-3 px-4 py-1.5 rounded-full bg-white/10 hover:bg-white/20 text-white text-xs font-medium transition-colors" onClick={(e) => { |
| e.stopPropagation(); |
| handleDelete(item, e); |
| }}> |
| Remove |
| </button> |
| </div>)} |
| |
| {/* Play indicator (only for completed videos) */} |
| {item.status === 'done' && (<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> |
| <div className="p-3 bg-black/50 rounded-full backdrop-blur-sm"> |
| <Play size={24} className="text-white" fill="white"/> |
| </div> |
| </div>)} |
| |
| {/* Card overlay (only for completed videos) */} |
| {item.status === 'done' && (<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-4"> |
| <div className="flex gap-2 justify-end transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300"> |
| <button className="bg-white/10 backdrop-blur-md hover:bg-white/20 p-2 rounded-full text-white transition-colors" type="button" title="Copy prompt" onClick={(e) => { |
| e.stopPropagation(); |
| handleCopyPrompt(item.finalPrompt || item.prompt); |
| }}> |
| <Copy size={16}/> |
| </button> |
| <button className="bg-white/10 backdrop-blur-md hover:bg-white/20 p-2 rounded-full text-white transition-colors" type="button" title="Download" onClick={(e) => { |
| e.stopPropagation(); |
| if (item.videoUrl) |
| handleDownload(item.videoUrl); |
| }}> |
| <Download size={16}/> |
| </button> |
| <button className="bg-red-500/20 backdrop-blur-md hover:bg-red-500/40 p-2 rounded-full text-red-400 hover:text-red-300 transition-colors" type="button" title="Delete" onClick={(e) => handleDelete(item, e)}> |
| <Trash2 size={16}/> |
| </button> |
| </div> |
| |
| <div className="mt-3 text-xs text-white/80 line-clamp-2">{item.finalPrompt || item.prompt}</div> |
| <div className="flex items-center gap-2 mt-1 text-[10px] text-white/50"> |
| <Clock size={10}/> |
| {item.seconds}s @ {item.fps}fps |
| </div> |
| </div>)} |
| </div>))} |
| </div> |
| </div> |
| |
| {/* Floating prompt bar */} |
| <div className="absolute bottom-0 left-0 right-0 z-30 p-6 flex justify-center items-end bg-gradient-to-t from-black via-black/90 to-transparent h-48 pointer-events-none"> |
| <div className="w-full max-w-2xl relative pointer-events-auto"> |
| {/* Settings Panel (slides in from bottom) */} |
| {showSettingsPanel && (<div className="absolute bottom-[110%] left-0 right-0 z-40 bg-black/95 border border-white/10 rounded-2xl p-5 shadow-2xl backdrop-blur-xl mb-2"> |
| <div className="flex justify-between items-center mb-4"> |
| <h3 className="text-sm font-bold text-white flex items-center gap-2"> |
| <Sliders size={16} className="text-purple-400"/> |
| Video Settings |
| </h3> |
| <div className="flex items-center gap-1"> |
| <button type="button" className="p-1.5 rounded-lg hover:bg-white/10 text-white/50 hover:text-purple-400 transition-colors" onClick={resetVideoSettings} title="Reset to model defaults"> |
| <RefreshCw size={14}/> |
| </button> |
| <button type="button" className="p-1.5 rounded-lg hover:bg-white/10 text-white/50 hover:text-white transition-colors" onClick={() => setShowSettingsPanel(false)}> |
| <X size={16}/> |
| </button> |
| </div> |
| </div> |
| |
| {/* Aspect Ratio */} |
| <div className="mb-4"> |
| <div className="flex justify-between items-center text-[11px] font-bold text-white/40 uppercase tracking-wider mb-2"> |
| <span>Aspect Ratio</span> |
| {detectedModelType && (<span className="text-purple-400/60 normal-case font-normal"> |
| {compatibleAspectRatios.length} available for {detectedModelType.toUpperCase()} |
| </span>)} |
| </div> |
| <div className="flex gap-2 flex-wrap"> |
| {compatibleAspectRatios.map((r) => (<button key={r.id} onClick={() => setAspectRatio(r.id)} className={`p-2 rounded-lg hover:bg-white/5 transition-colors group flex flex-col items-center gap-1 ${aspectRatio === r.id ? 'bg-white/5 ring-1 ring-purple-500/50' : ''}`} title={r.label}> |
| <div className={`border-2 ${aspectRatio === r.id ? 'border-purple-400' : 'border-white/30 group-hover:border-white/50'} rounded-[2px]`} style={{ width: r.previewW, height: r.previewH }}/> |
| <span className={`text-[10px] ${aspectRatio === r.id ? 'text-purple-300' : 'text-white/50'}`}> |
| {r.id} |
| </span> |
| </button>))} |
| </div> |
| </div> |
| |
| {/* Quality Preset */} |
| <div className="mb-4"> |
| <label className="text-[11px] font-bold text-white/40 uppercase tracking-wider mb-2 block"> |
| Quality Preset |
| </label> |
| <div className="grid grid-cols-4 gap-2"> |
| {QUALITY_PRESETS.map((q) => (<button key={q.id} onClick={() => setQualityPreset(q.id)} title={q.description} className={`py-2 px-2 rounded-lg text-sm font-medium transition-colors ${qualityPreset === q.id |
| ? 'bg-purple-500/30 text-purple-200 border border-purple-500/50' |
| : 'bg-white/5 text-white/70 border border-white/10 hover:bg-white/10'}`}> |
| <div className="text-xs font-bold">{q.label}</div> |
| <div className="text-[9px] text-white/40 mt-0.5">{q.short}</div> |
| </button>))} |
| </div> |
| </div> |
| |
| {/* Motion Strength (model-aware: disabled for SVD) */} |
| <div className={`mb-4 ${!isMotionSupported ? 'opacity-50' : ''}`}> |
| <div className="flex justify-between items-center text-[11px] font-bold text-white/40 uppercase tracking-wider mb-2"> |
| <span>Motion Strength</span> |
| {!isMotionSupported && detectedModelType && (<span className="text-yellow-500/60 normal-case font-normal text-[10px]"> |
| Not supported by {detectedModelType.toUpperCase()} |
| </span>)} |
| </div> |
| <div className="flex gap-2"> |
| {MOTION_PRESETS.map((m) => (<button key={m.value} onClick={() => isMotionSupported && setMotion(m.value)} disabled={!isMotionSupported} className={`flex-1 py-2 px-3 rounded-lg text-sm font-medium transition-colors ${motion === m.value |
| ? 'bg-purple-500/30 text-purple-200 border border-purple-500/50' |
| : 'bg-white/5 text-white/70 border border-white/10 hover:bg-white/10'} ${!isMotionSupported ? 'cursor-not-allowed' : ''}`}> |
| <div>{m.label}</div> |
| <div className="text-[10px] text-white/40 mt-0.5">{m.description}</div> |
| </button>))} |
| </div> |
| </div> |
| |
| <div className="p-3 rounded-xl bg-purple-900/10 border border-purple-500/20 text-xs text-purple-200/70 leading-relaxed"> |
| <span className="font-bold text-purple-400 block mb-1">PRO TIP</span> |
| Start with shorter durations and lower FPS for faster generation. Increase for smoother, longer videos. |
| </div> |
| </div>)} |
| |
| {/* Reference Image Preview Panel */} |
| {referenceUrl && (<div className="absolute bottom-[110%] left-0 right-0 bg-black/95 border border-white/10 rounded-2xl p-4 shadow-2xl mb-2 backdrop-blur-xl"> |
| <div className="flex items-center gap-4"> |
| <img src={resolveFileUrl(referenceUrl)} className="h-20 w-20 rounded-xl object-cover border border-white/20 shadow-lg" alt="Source"/> |
| <div className="flex-1 min-w-0"> |
| <div className="flex items-center justify-between mb-2"> |
| <div className="text-sm font-semibold text-white flex items-center gap-2"> |
| <Image size={14} className="text-purple-400"/> |
| Source Image Ready |
| </div> |
| <button type="button" className="p-1.5 rounded-lg bg-white/5 hover:bg-red-500/20 border border-white/10 hover:border-red-500/30 text-white/50 hover:text-red-400 transition-colors" onClick={() => setReferenceUrl(null)} title="Remove source image"> |
| <X size={14}/> |
| </button> |
| </div> |
| <div className="text-xs text-white/50 leading-relaxed"> |
| Describe the motion you want to apply to this image. Examples: "slow zoom out", "gentle parallax", "camera pan left" |
| </div> |
| </div> |
| </div> |
| </div>)} |
| |
| {/* Settings panel toggle area */} |
| {showSettingsPanel && (<div className="fixed inset-0 z-30" onClick={() => setShowSettingsPanel(false)}/>)} |
| |
| {/* Main input bar */} |
| <div className="group relative"> |
| <div className={`absolute -inset-0.5 bg-gradient-to-r from-purple-500/20 to-blue-500/20 rounded-full opacity-0 transition duration-500 group-hover:opacity-100 blur ${isGenerating ? 'opacity-100 animate-pulse' : ''}`}></div> |
| |
| <div className="relative bg-black border border-white/10 rounded-[32px] p-2 pr-2 flex items-center shadow-2xl transition-all focus-within:border-purple-500/30"> |
| {/* Upload button */} |
| <button className={`p-3 rounded-full transition-colors ${isUploadingReference |
| ? 'text-purple-400 animate-pulse' |
| : referenceUrl |
| ? 'text-purple-400 bg-purple-500/10' |
| : 'text-white/50 hover:text-white hover:bg-white/5'}`} type="button" title="Upload source image" onClick={() => referenceInputRef.current?.click()} disabled={isUploadingReference}> |
| {isUploadingReference ? <Loader2 size={20} className="animate-spin"/> : <Upload size={20}/>} |
| </button> |
| |
| <input ref={referenceInputRef} type="file" accept="image/*" className="hidden" onChange={(e) => { |
| const file = e.target.files?.[0]; |
| if (file) |
| handleUploadReference(file); |
| e.target.value = ''; |
| }}/> |
| |
| <input type="text" value={prompt} onChange={(e) => setPrompt(e.target.value)} onKeyDown={(e) => (e.key === 'Enter' ? void handleGenerate() : undefined)} placeholder={referenceUrl ? "Describe the motion... (slow drift, parallax, zoom)" : "Describe a scene to animate..."} className="flex-1 bg-transparent text-white placeholder-white/35 outline-none px-2 h-12 text-lg"/> |
| |
| <div className="flex items-center gap-2 pl-2"> |
| {/* Settings button */} |
| <button onClick={() => setShowSettingsPanel((v) => !v)} className={`p-2 rounded-full transition-colors ${showSettingsPanel ? 'text-purple-400 bg-purple-500/10' : 'text-white/50 hover:text-white hover:bg-white/5'}`} type="button" title="Video settings"> |
| <Settings2 size={20}/> |
| </button> |
| |
| {/* Quick duration indicator */} |
| <div className="px-2 py-1 rounded-lg bg-white/5 text-[11px] text-white/50 font-mono"> |
| {seconds}s |
| </div> |
| |
| {/* Generate button */} |
| <button onClick={() => void handleGenerate()} disabled={(!prompt.trim() && !referenceUrl) || isGenerating} className={`ml-1 h-10 px-6 rounded-full font-semibold text-sm transition-all flex items-center gap-2 ${(prompt.trim() || referenceUrl) && !isGenerating |
| ? 'bg-gradient-to-r from-purple-500 to-blue-500 text-white hover:opacity-90 hover:scale-[1.02]' |
| : 'bg-white/10 text-white/40 cursor-not-allowed'}`} type="button"> |
| {isGenerating ? (<span className="animate-pulse flex items-center gap-2"> |
| <Loader2 size={16} className="animate-spin"/> |
| Creating... |
| </span>) : (<> |
| <Film size={16}/> |
| Animate |
| </>)} |
| </button> |
| </div> |
| </div> |
| |
| <div className="mt-2 text-[11px] text-white/35 px-2 flex items-center gap-2"> |
| <span>Using</span> |
| <span className="text-white/55 font-semibold">{props.providerVideo}</span> |
| {props.modelVideo ? (<> |
| <span>·</span> |
| <span className="text-white/55 font-semibold truncate max-w-[200px]">{props.modelVideo}</span> |
| </>) : null} |
| <span>·</span> |
| <span className="text-white/55">{aspectRatio}</span> |
| <span>·</span> |
| {effectiveDims && <><span className="text-white/55 font-mono">{effectiveDims.width}x{effectiveDims.height}</span><span>·</span></>} |
| <span>{qualityPreset} · {actualDuration ? `~${actualDuration}s` : `${seconds}s`} @ {fps}fps · {motion} motion</span> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| {} |
| {selectedVideo && (<div className="fixed inset-0 z-50 flex flex-col bg-black animate-in fade-in duration-200" onClick={() => { setSelectedVideo(null); setShowDetails(false); setShowSourceImage(false); }}> |
| {/* Floating Controls - Top Right */} |
| <div className="absolute top-4 right-4 z-50 flex items-center gap-2"> |
| {selectedVideo.sourceImageUrl && (<button className={`p-2.5 rounded-full transition-all ${showSourceImage ? 'bg-white text-black' : 'bg-white/10 text-white/70 hover:bg-white/20 hover:text-white'}`} onClick={(e) => { e.stopPropagation(); setShowSourceImage(!showSourceImage); }} type="button" title="View source image"> |
| <Image size={18}/> |
| </button>)} |
| <button className={`p-2.5 rounded-full transition-all ${showDetails ? 'bg-white text-black' : 'bg-white/10 text-white/70 hover:bg-white/20 hover:text-white'}`} onClick={(e) => { e.stopPropagation(); setShowDetails(!showDetails); }} type="button" title="Toggle details"> |
| <Info size={18}/> |
| </button> |
| <button className="p-2.5 bg-white/10 text-white/70 hover:bg-white/20 hover:text-white rounded-full transition-all" onClick={(e) => { |
| e.stopPropagation(); |
| // Use native fullscreen API - find the media element and fullscreen it |
| const mediaEl = document.querySelector('[data-lightbox-media]'); |
| if (mediaEl?.requestFullscreen) { |
| mediaEl.requestFullscreen().catch(() => { }); |
| } |
| }} type="button" title="View full size"> |
| <Maximize2 size={18}/> |
| </button> |
| <button className="p-2.5 bg-white/10 text-white/70 hover:bg-white/20 hover:text-white rounded-full transition-all" onClick={() => { setSelectedVideo(null); setShowDetails(false); setShowSourceImage(false); }} type="button" title="Close"> |
| <X size={18}/> |
| </button> |
| </div> |
| |
| {/* Main Content Area */} |
| <div className="flex-1 flex" onClick={(e) => e.stopPropagation()}> |
| {/* Hero Video Container - LARGER, fills more space */} |
| <div className="flex-1 flex items-center justify-center p-2 relative group"> |
| {selectedVideo.videoUrl ? (isAnimatedImage(selectedVideo.videoUrl) ? (<img src={resolveFileUrl(selectedVideo.videoUrl)} data-lightbox-media alt="Generated animation" className="max-h-[calc(100vh-120px)] max-w-full object-contain rounded-lg shadow-2xl"/>) : (<video src={resolveFileUrl(selectedVideo.videoUrl)} data-lightbox-media poster={selectedVideo.posterUrl ? resolveFileUrl(selectedVideo.posterUrl) : undefined} controls autoPlay loop className="max-h-[calc(100vh-120px)] max-w-full object-contain rounded-lg shadow-2xl"/>)) : ( |
| // Fallback for items without videoUrl (shouldn't happen but safety) |
| <div className="flex items-center justify-center h-64 w-full max-w-lg bg-white/5 rounded-lg"> |
| <Loader2 size={32} className="text-white/50 animate-spin"/> |
| </div>)} |
| |
| {/* Chips Overlay - Fade in on hover */} |
| <div className="absolute bottom-6 left-6 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300"> |
| {selectedVideo.model && (<span className="px-3 py-1.5 bg-black/60 backdrop-blur-md text-white text-xs font-semibold rounded-full border border-white/10"> |
| {selectedVideo.model.split('.')[0].split('-')[0].toUpperCase()} |
| </span>)} |
| {selectedVideo.seconds && (<span className="px-3 py-1.5 bg-black/60 backdrop-blur-md text-white text-xs font-semibold rounded-full border border-white/10"> |
| {selectedVideo.seconds}s |
| </span>)} |
| {selectedVideo.fps && (<span className="px-3 py-1.5 bg-black/60 backdrop-blur-md text-white text-xs font-semibold rounded-full border border-white/10"> |
| {selectedVideo.fps}fps |
| </span>)} |
| {selectedVideo.motion && (<span className="px-3 py-1.5 bg-black/60 backdrop-blur-md text-white text-xs font-semibold rounded-full border border-white/10 capitalize"> |
| {selectedVideo.motion} |
| </span>)} |
| </div> |
| |
| {/* Grok-style Regeneration Overlay - Blur + Dots + Progress */} |
| {isRegenerating && (<div className="absolute inset-0 z-30 flex items-center justify-center rounded-lg overflow-hidden"> |
| {/* Blur + Dim background */} |
| <div className="absolute inset-0 bg-black/40 backdrop-blur-sm"/> |
| |
| {/* Dotted pattern overlay */} |
| <div className="absolute inset-0 opacity-40 pointer-events-none" style={{ |
| backgroundImage: 'radial-gradient(circle, rgba(255,255,255,0.4) 1px, transparent 1px)', |
| backgroundSize: '24px 24px' |
| }}/> |
| |
| {/* Center controls */} |
| <div className="relative z-10 flex flex-col items-center gap-4"> |
| {/* Progress indicator */} |
| {typeof regenProgress === 'number' && (<div className="px-5 py-2.5 rounded-full bg-black/70 border border-white/20 text-white font-medium text-lg shadow-xl"> |
| {Math.round(regenProgress)}% |
| </div>)} |
| |
| {/* Cancel button */} |
| <button className="px-5 py-2.5 rounded-full bg-black/70 border border-white/20 text-white/90 hover:bg-black/80 hover:text-white transition-colors font-medium shadow-xl" onClick={(e) => { e.stopPropagation(); handleCancelRegeneration(); }} type="button"> |
| Cancel Video |
| </button> |
| </div> |
| </div>)} |
| </div> |
| |
| {/* Source Image Overlay - Shows when toggled */} |
| {showSourceImage && selectedVideo.sourceImageUrl && (<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/90 animate-in fade-in duration-200" onClick={(e) => { e.stopPropagation(); setShowSourceImage(false); }}> |
| <div className="relative" onClick={(e) => e.stopPropagation()}> |
| <img src={resolveFileUrl(selectedVideo.sourceImageUrl || '')} alt="Source image" data-lightbox-media className="max-h-[calc(100vh-80px)] max-w-[90vw] object-contain rounded-lg shadow-2xl"/> |
| <button className="absolute top-4 right-4 p-2.5 bg-black/60 text-white/80 hover:bg-black/80 hover:text-white rounded-full transition-all" onClick={() => setShowSourceImage(false)} type="button" title="Close"> |
| <X size={18}/> |
| </button> |
| <div className="absolute bottom-4 left-4 px-3 py-1.5 bg-black/60 backdrop-blur-md text-white text-xs font-semibold rounded-full border border-white/10"> |
| Source Image |
| </div> |
| </div> |
| </div>)} |
| |
| {/* Details Panel - Slide in from right */} |
| {showDetails && (<div className="w-80 bg-[#0a0a0a] border-l border-white/10 flex flex-col animate-in slide-in-from-right duration-200"> |
| <div className="p-4 border-b border-white/10"> |
| <h3 className="text-sm font-bold text-white flex items-center gap-2"> |
| <Film size={14} className="text-purple-400"/> |
| Video Details |
| </h3> |
| </div> |
| <div className="flex-1 overflow-y-auto p-4 space-y-4"> |
| {/* Prompt - Only visible in details panel */} |
| <div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-2 block">Prompt</label> |
| <p className="text-sm text-white/70 leading-relaxed">{selectedVideo.finalPrompt || selectedVideo.prompt}</p> |
| </div> |
| {/* Source Image Thumbnail */} |
| {selectedVideo.sourceImageUrl && (<div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-2 block">Source Image</label> |
| <img src={resolveFileUrl(selectedVideo.sourceImageUrl || '')} alt="Source" className="w-full h-24 object-cover rounded-lg border border-white/10"/> |
| </div>)} |
| {/* Seed */} |
| {selectedVideo.seed && (<div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">Seed</label> |
| <div className="text-base text-white font-mono font-bold">{selectedVideo.seed}</div> |
| </div>)} |
| {/* Resolution, Aspect Ratio, Preset */} |
| {(selectedVideo.width || selectedVideo.aspectRatio || selectedVideo.preset) && (<div className="grid grid-cols-3 gap-3"> |
| {(selectedVideo.width && selectedVideo.height) && (<div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">Resolution</label> |
| <div className="text-sm text-white/70 font-mono">{selectedVideo.width}×{selectedVideo.height}</div> |
| </div>)} |
| {selectedVideo.aspectRatio && (<div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">Aspect Ratio</label> |
| <div className="text-sm text-white/70 font-mono">{selectedVideo.aspectRatio}</div> |
| </div>)} |
| {selectedVideo.preset && (<div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">Preset</label> |
| <div className="text-sm text-white/70 font-mono capitalize">{selectedVideo.preset}</div> |
| </div>)} |
| </div>)} |
| {/* Duration, FPS, Motion */} |
| <div className="grid grid-cols-3 gap-3"> |
| <div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">Duration</label> |
| <div className="text-sm text-white/70 font-mono">{selectedVideo.seconds || '-'}s</div> |
| </div> |
| <div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">FPS</label> |
| <div className="text-sm text-white/70 font-mono">{selectedVideo.fps || '-'}</div> |
| </div> |
| <div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">Motion</label> |
| <div className="text-sm text-white/70 font-mono capitalize">{selectedVideo.motion || '-'}</div> |
| </div> |
| </div> |
| {/* Steps, CFG, Denoise */} |
| {(selectedVideo.steps || selectedVideo.cfg || selectedVideo.denoise) && (<div className="grid grid-cols-3 gap-3 pt-2 border-t border-white/5"> |
| {selectedVideo.steps && (<div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">Steps</label> |
| <div className="text-sm text-white/70 font-mono">{selectedVideo.steps}</div> |
| </div>)} |
| {selectedVideo.cfg && (<div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">CFG</label> |
| <div className="text-sm text-white/70 font-mono">{selectedVideo.cfg}</div> |
| </div>)} |
| {selectedVideo.denoise && (<div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">Denoise</label> |
| <div className="text-sm text-white/70 font-mono">{selectedVideo.denoise}</div> |
| </div>)} |
| </div>)} |
| {/* Date & Time */} |
| <div className="grid grid-cols-2 gap-3"> |
| <div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">Date</label> |
| <div className="text-xs text-white/60 font-mono">{new Date(selectedVideo.createdAt).toLocaleDateString()}</div> |
| </div> |
| <div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">Time</label> |
| <div className="text-xs text-white/60 font-mono">{new Date(selectedVideo.createdAt).toLocaleTimeString()}</div> |
| </div> |
| </div> |
| {/* Model */} |
| {selectedVideo.model && (<div> |
| <label className="text-[10px] font-bold text-white/40 uppercase tracking-wider mb-1 block">Model</label> |
| <div className="text-xs text-white/50 font-mono break-all">{selectedVideo.model}</div> |
| </div>)} |
| </div> |
| </div>)} |
| </div> |
| |
| {/* Grok-style Prompt Composer Bar */} |
| <div className="bg-[#0a0a0a] border-t border-white/10 px-4 py-3" onClick={(e) => e.stopPropagation()}> |
| <div className="max-w-4xl mx-auto flex items-center gap-3"> |
| |
| {/* Prompt Card Container */} |
| <div className="flex-1 flex items-center gap-3 bg-[#1a1a1a] rounded-2xl px-4 py-2.5 border border-white/10"> |
| {/* Progress indicator in prompt bar during regeneration */} |
| {isRegenerating && typeof regenProgress === 'number' && (<div className="flex items-center gap-2 flex-shrink-0"> |
| <div className="px-2.5 py-1 rounded-full bg-white/10 text-white/80 text-xs font-medium"> |
| {Math.round(regenProgress)}% |
| </div> |
| <ChevronDown size={14} className="text-white/40"/> |
| </div>)} |
| |
| {/* Editable Prompt Input */} |
| <input type="text" value={lightboxPrompt} onChange={(e) => setLightboxPrompt(e.target.value)} onKeyDown={(e) => { |
| if (e.key === 'Enter' && lightboxPrompt.trim() && !isRegenerating) { |
| handleRegenerateInPlace(); |
| } |
| }} placeholder={isRegenerating ? "Generating video..." : "Edit prompt and regenerate..."} className="flex-1 bg-transparent text-white/90 text-sm placeholder-white/30 outline-none min-w-0" disabled={isRegenerating}/> |
| |
| {/* Make Video Button (inside card) - Grok style */} |
| <button onClick={isRegenerating ? handleCancelRegeneration : handleRegenerateInPlace} disabled={!lightboxPrompt.trim() && !isRegenerating} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all flex-shrink-0 ${isRegenerating |
| ? 'bg-white/10 hover:bg-white/20 text-white' |
| : lightboxPrompt.trim() |
| ? 'bg-white/10 hover:bg-white/20 text-white' |
| : 'bg-white/5 text-white/30 cursor-not-allowed'}`} type="button" title={isRegenerating ? "Cancel" : "Make video"}> |
| {isRegenerating ? (<>Make video <ArrowUp size={14}/></>) : (<>Make video <ArrowUp size={14}/></>)} |
| </button> |
| </div> |
| |
| {/* Action Icons (outside card) */} |
| <div className="flex items-center gap-1 flex-shrink-0"> |
| <button className="p-2.5 text-white/60 hover:text-white hover:bg-white/10 rounded-full transition-colors" onClick={() => selectedVideo.videoUrl && handleDownload(selectedVideo.videoUrl)} type="button" title="Download" disabled={!selectedVideo.videoUrl}> |
| <Download size={18}/> |
| </button> |
| <button className="p-2.5 text-white/60 hover:text-white hover:bg-white/10 rounded-full transition-colors" onClick={() => handleCopyPrompt(selectedVideo.finalPrompt || selectedVideo.prompt)} type="button" title="Copy prompt"> |
| <Copy size={18}/> |
| </button> |
| <button className="p-2.5 text-white/60 hover:text-red-400 hover:bg-red-500/10 rounded-full transition-colors" onClick={() => { |
| if (confirm('Delete this video?')) { |
| setItems((prev) => prev.filter((x) => x.id !== selectedVideo.id)); |
| setSelectedVideo(null); |
| setShowDetails(false); |
| setShowSourceImage(false); |
| } |
| }} type="button" title="Delete"> |
| <Trash2 size={18}/> |
| </button> |
| </div> |
| |
| </div> |
| </div> |
| </div>)} |
| </div>); |
| } |
|
|