HomePilot / frontend /src /ui /Animate.jsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
95.1 kB
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';
// Motion strength presets
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' },
];
// Duration presets
const DURATION_PRESETS = [
{ label: '2s', value: 2 },
{ label: '4s', value: 4 },
{ label: '6s', value: 6 },
{ label: '8s', value: 8 },
];
// FPS presets
const FPS_PRESETS = [
{ label: '8 fps', value: 8 },
{ label: '12 fps', value: 12 },
{ label: '16 fps', value: 16 },
{ label: '24 fps', value: 24 },
];
// Quality presets
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.' },
];
// Default aspect ratios (used as fallback)
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 },
];
// Fallback default values (used when API is unavailable)
// Matches LTX "high" preset - proven working configuration
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';
}
/** Returns the preset-derived (Auto) resolution for the current aspectRatio + qualityPreset */
function getAutoVideoDims(opts) {
const { rawAspectRatioData, aspectRatio, qualityPreset } = opts;
const ratioEntry = rawAspectRatioData?.find((ar) => ar.id === aspectRatio);
// Prefer all_dimensions (keyed by tier), fall back to legacy dimensions
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;
}
// Legacy path: dimensions was a flat {width, height} for the selected preset
const flat = ratioEntry?.dimensions;
if (isDim(flat))
return flat;
return null;
}
/** Returns the override tier dims from availableResolutions */
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;
}
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
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`;
}
// Check if URL points to an animated image (WEBP/GIF) vs video (MP4/WebM)
// Animated WEBP/GIF must use <img> tag, videos use <video> tag
function isAnimatedImage(url) {
if (!url)
return false;
const lower = url.toLowerCase();
// ComfyUI URLs look like: /view?filename=xxx.webp&subfolder=...
return lower.includes('.webp') || lower.includes('.gif');
}
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
export default function AnimateView(props) {
const authKey = (props.apiKey || '').trim();
const [prompt, setPrompt] = useState('');
// Load items from localStorage on mount
const [items, setItems] = useState(() => {
try {
const stored = localStorage.getItem('homepilot_animate_items');
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
// Thresholds for handling stale processing items:
// - IN_FLIGHT_THRESHOLD: Video generation can take 30s-3min depending on settings.
// If processing item is older than 3 min, generation likely completed but state
// update was lost due to tab switch (component unmount).
// - STALE_THRESHOLD: If processing item is older than this, app was closed during
// generation - mark as definitively failed.
const IN_FLIGHT_THRESHOLD = 3 * 60 * 1000; // 3 minutes - likely completed but lost track
const STALE_THRESHOLD = 10 * 60 * 1000; // 10 minutes - definitively failed
const now = Date.now();
return parsed.map((item) => {
// Migrate: old items without status get 'done' if they have videoUrl
if (!item.status) {
return { ...item, status: item.videoUrl ? 'done' : 'failed' };
}
// Handle processing items based on age
if (item.status === 'processing') {
const age = now - item.createdAt;
// Very old items (app was closed) - mark as failed
if (age > STALE_THRESHOLD) {
return { ...item, status: 'failed', error: 'Generation interrupted - please retry' };
}
// Medium-age items (tab was switched) - likely completed but we lost track
// Mark as failed since we can't verify the result without backend check
if (age > IN_FLIGHT_THRESHOLD) {
return {
...item,
status: 'failed',
error: 'Generation may have completed - check your gallery or retry'
};
}
}
// Keep recent processing items as-is (still in-flight)
return item;
});
}
}
}
catch (error) {
console.error('Failed to load animate items from localStorage:', error);
}
return [];
});
const [isGenerating, setIsGenerating] = useState(false);
// Selection state for Lightbox (Grok-style detail view)
const [selectedVideo, setSelectedVideo] = useState(null);
const [showDetails, setShowDetails] = useState(false); // Immersive mode: details hidden by default
const [showSourceImage, setShowSourceImage] = useState(false); // Source image overlay
const [lightboxPrompt, setLightboxPrompt] = useState(''); // Editable prompt in lightbox
const [isRegenerating, setIsRegenerating] = useState(false); // Regenerating from lightbox
const [regenProgress, setRegenProgress] = useState(null); // Progress 0-100 during regen
const [regenAbortController, setRegenAbortController] = useState(null); // For cancellation
// Video settings
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');
// Sync with global preset when it changes
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([]); // Store raw API response for resolution lookup
// Advanced Controls state
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);
// Resolution override (allows testing different resolutions in Advanced Mode)
const [customResolution, setCustomResolution] = useState('auto'); // 'auto' | 'low' | 'medium' | 'high' | 'ultra'
const [availableResolutions, setAvailableResolutions] = useState([]);
// Resolution mode (additive): "auto" uses preset-derived dims, "override" uses customResolution tier
const [vidResolutionMode, setVidResolutionMode] = useState('auto');
// Preset defaults from API (model-specific)
const [presetDefaults, setPresetDefaults] = useState(FALLBACK_ADVANCED_PARAMS);
// Detect model type from model filename
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; // Unknown model, use base preset
}, [props.modelVideo]);
// Fetch preset defaults when model or quality changes
useEffect(() => {
const fetchPresets = async () => {
try {
const base = props.backendUrl.replace(/\/+$/, '');
const params = new URLSearchParams();
if (detectedModelType)
params.set('model', detectedModelType);
// Map hardware presets to video quality presets
// Hardware: 4060 → low, 4080 → medium, a100 → high, custom → medium
// Also normalise "med" → "medium" (UI shorthand)
const videoPreset = qualityPreset === 'custom' ? 'medium'
: qualityPreset === 'med' ? 'medium'
: qualityPreset === '4060' ? 'low'
: qualityPreset === '4080' ? 'medium'
: qualityPreset === 'a100' ? 'high'
: qualityPreset; // Already a valid video preset (low/medium/high/ultra)
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,
});
}
// Also update compatible aspect ratios from API response
if (data.compatible_aspect_ratios && Array.isArray(data.compatible_aspect_ratios)) {
// Store raw data for resolution lookup
setRawAspectRatioData(data.compatible_aspect_ratios);
const mappedRatios = data.compatible_aspect_ratios.map((ar) => ({
id: ar.id,
label: ar.label?.replace(/\s*\([^)]*\)/g, '') || ar.id, // Strip parenthetical like "(16:9)"
previewW: getPreviewDimension(ar.id, 'width'),
previewH: getPreviewDimension(ar.id, 'height'),
}));
if (mappedRatios.length > 0) {
setCompatibleAspectRatios(mappedRatios);
// If current aspect ratio is not in the compatible list, switch to the first one
if (!mappedRatios.find((r) => r.id === aspectRatio)) {
setAspectRatio(mappedRatios[0].id);
}
}
}
}
}
catch (err) {
console.warn('Failed to fetch video presets:', err);
// Keep using fallback defaults
setCompatibleAspectRatios(DEFAULT_ASPECT_RATIOS);
}
};
fetchPresets();
// Note: aspectRatio intentionally omitted to avoid infinite loop
// (this effect may update aspectRatio if it's incompatible with new model)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.backendUrl, authKey, detectedModelType, qualityPreset]);
// Update available resolutions when aspect ratio or model changes
// Uses all_dimensions (every tier) so the Override grid shows all options
useEffect(() => {
if (rawAspectRatioData.length === 0)
return;
const currentRatioData = rawAspectRatioData.find((ar) => ar.id === aspectRatio);
// Prefer all_dimensions (every tier), fall back to single-preset dimensions
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+)',
};
// Ensure consistent ordering
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);
// Smart reset: keep override tier if still available, otherwise fall back to auto
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]); // eslint-disable-line react-hooks/exhaustive-deps
// Helper to get preview dimensions for aspect ratio thumbnails
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;
}
// Reset advanced parameters to preset defaults (model-specific)
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'); // Reset resolution to use preset default
setVidResolutionMode('auto');
}, [presetDefaults]);
// Reset Video Settings to model-specific defaults
// Resets: Aspect Ratio (model default), Quality Preset (global), Motion (medium)
const resetVideoSettings = useCallback(() => {
// Reset aspect ratio to model's recommended default
const defaultRatio = presetDefaults.defaultAspectRatio;
if (defaultRatio && compatibleAspectRatios.find(r => r.id === defaultRatio)) {
setAspectRatio(defaultRatio);
}
else if (compatibleAspectRatios.length > 0) {
// Fallback to first compatible ratio
setAspectRatio(compatibleAspectRatios[0].id);
}
// Reset quality preset to global setting
setQualityPreset(props.vidPreset || 'medium');
// Reset motion strength to balanced default
setMotion('medium');
}, [presetDefaults.defaultAspectRatio, compatibleAspectRatios, props.vidPreset]);
// Sync slider values when preset defaults change (from API or quality/model change)
// Only sync if user hasn't entered Advanced Mode yet (preserves custom values)
useEffect(() => {
if (!advancedMode) {
// Sync advanced params (steps, cfg, denoise)
setCustomSteps(presetDefaults.steps ?? FALLBACK_ADVANCED_PARAMS.steps);
setCustomCfg(presetDefaults.cfg ?? FALLBACK_ADVANCED_PARAMS.cfg);
setCustomDenoise(presetDefaults.denoise ?? FALLBACK_ADVANCED_PARAMS.denoise);
// Sync FPS from preset (model-specific optimal value)
if (presetDefaults.fps) {
setFps(presetDefaults.fps);
}
// Calculate and sync Duration from preset frames
if (presetDefaults.frames && presetDefaults.fps) {
const calculatedSeconds = Math.round(presetDefaults.frames / presetDefaults.fps);
// Clamp to valid duration options (2, 4, 6, 8)
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]);
// Reference Image state (source image for animation)
const referenceInputRef = useRef(null);
const [referenceUrl, setReferenceUrl] = useState(null);
const [isUploadingReference, setIsUploadingReference] = useState(false);
// Ref for auto-scrolling to top when new videos are added
const gridStartRef = useRef(null);
// Save items to localStorage whenever they change
useEffect(() => {
try {
localStorage.setItem('homepilot_animate_items', JSON.stringify(items));
}
catch (error) {
console.error('Failed to save animate items to localStorage:', error);
}
}, [items]);
// On mount, periodically check localStorage for items that completed while unmounted
// This handles the race condition where API completes after component remounts
useEffect(() => {
const processingItems = items.filter(item => item.status === 'processing');
if (processingItems.length === 0)
return;
// Video generation can take up to 3 minutes, so use longer threshold
const IN_FLIGHT_THRESHOLD = 3 * 60 * 1000; // 3 minutes
// Check localStorage for updates and sync state
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;
// Check if this item was completed in localStorage (by another instance)
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;
}
// Check if this processing item is now too old
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'
};
}
// Continue progress simulation
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;
}
};
// Run sync check periodically
const progressInterval = setInterval(() => {
syncFromLocalStorage();
}, 800); // Slower interval for video (takes longer)
// Also run immediately on mount
syncFromLocalStorage();
return () => clearInterval(progressInterval);
// Only run on mount (empty deps) - items will be stale but that's intentional
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Scroll to top when entering Animate
useEffect(() => {
gridStartRef.current?.scrollIntoView({ block: 'start' });
}, []);
// Load handoff from Imagine (Grok-style "Animate this image")
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; // 2 min TTL
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);
}
// Clean up handoff after use
localStorage.removeItem('homepilot_animate_handoff');
}
catch {
localStorage.removeItem('homepilot_animate_handoff');
}
}, []);
// Initialize lightbox prompt when selecting a video
useEffect(() => {
if (selectedVideo) {
setLightboxPrompt(selectedVideo.finalPrompt || selectedVideo.prompt);
}
}, [selectedVideo]);
// Upload reference image handler
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]);
// --- Compute effective video dimensions (used by callbacks + render) ---
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;
// Actual duration from frames/fps (honest reporting)
const actualDuration = presetDefaults?.frames && presetDefaults?.fps
? (presetDefaults.frames / presetDefaults.fps).toFixed(1)
: null;
// Model capability flags (for conditional UI)
const isMotionSupported = detectedModelType !== 'svd';
const handleGenerate = useCallback(async () => {
// For animate, we need either a prompt or a reference image
const t = prompt.trim();
if (!t && !referenceUrl)
return;
if (isGenerating)
return;
// Use default prompt for reference-only generation
const effectivePrompt = t || 'animate this image with natural motion';
setIsGenerating(true);
setShowSettingsPanel(false);
// Create placeholder item IMMEDIATELY (persists across tab switches)
const placeholderId = uid();
const placeholder = {
id: placeholderId,
status: 'processing',
progress: 0,
createdAt: Date.now(),
prompt: effectivePrompt,
sourceImageUrl: referenceUrl || undefined,
posterUrl: referenceUrl || undefined, // Use reference as thumbnail while processing
seconds: seconds,
fps: fps,
motion: motion,
model: props.modelVideo,
preset: qualityPreset,
aspectRatio: aspectRatio,
};
// Add placeholder to items (will be persisted to localStorage)
setItems((prev) => [placeholder, ...prev]);
setPrompt('');
// Auto-scroll to show new placeholder
setTimeout(() => {
gridStartRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
// Simulate progress while waiting for API
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 {
// Build message for animate mode
const animateMessage = referenceUrl
? `animate ${referenceUrl} ${effectivePrompt}`
: `animate ${effectivePrompt}`;
// Determine video resolution override values (additive + backward compatible)
const useOverride = vidResolutionMode === 'override' && overrideDims != null;
const resolvedVidWidth = useOverride ? overrideDims.width : autoDims?.width;
const resolvedVidHeight = useOverride ? overrideDims.height : autoDims?.height;
// Map hardware presets to video quality presets (normalise "med" → "medium")
const videoPreset = qualityPreset === 'custom' ? 'medium'
: qualityPreset === 'med' ? 'medium'
: qualityPreset === '4060' ? 'low'
: qualityPreset === '4080' ? 'medium'
: qualityPreset === 'a100' ? 'high'
: qualityPreset;
const requestBody = {
message: animateMessage,
mode: 'animate',
// Video generation params
vidSeconds: seconds,
vidFps: fps,
vidMotion: motion,
vidModel: props.modelVideo || undefined,
vidPreset: videoPreset,
vidAspectRatio: aspectRatio,
// Video resolution override (new, preferred fields)
vidResolutionMode: useOverride ? 'override' : 'auto',
...(useOverride && {
vidWidth: resolvedVidWidth,
vidHeight: resolvedVidHeight,
}),
// Legacy compatibility (keep until backend fully switches to vidWidth/vidHeight)
...(useOverride && {
imgWidth: resolvedVidWidth,
imgHeight: resolvedVidHeight,
}),
// Advanced parameters (when enabled)
...(advancedMode && {
vidSteps: customSteps,
vidCfg: customCfg,
vidDenoise: customDenoise,
...(seedLock && { vidSeed: customSeed }),
...(customNegativePrompt.trim() && { vidNegativePrompt: customNegativePrompt.trim() }),
}),
// Provider settings
provider: props.providerVideo === 'comfyui' ? 'ollama' : props.providerVideo,
provider_base_url: props.baseUrlVideo || undefined,
provider_model: props.modelVideo || undefined,
// LLM settings for prompt refinement
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');
}
// Build the completed item
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,
// Resolution for reproducibility
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),
};
// CRITICAL: Directly persist to localStorage BEFORE setItems
// This ensures the result is saved even if component unmounts between
// API response and React's state update
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);
}
// Update React state (may not process if component unmounted, but localStorage is already updated)
setItems(prev => prev.map(item => item.id === placeholderId ? completedItem : item));
}
catch (err) {
clearInterval(progressInterval);
console.error('Video generation failed:', err);
// Build failed item
const failedItem = {
...placeholder,
status: 'failed',
error: err.message || 'Generation failed'
};
// CRITICAL: Directly persist failure to localStorage
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);
}
// Update React state
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 { }
}, []);
// Grok-style in-place regeneration: keeps lightbox open, shows progress overlay
const handleRegenerateInPlace = useCallback(async () => {
if (!selectedVideo || !lightboxPrompt.trim() || isRegenerating)
return;
const abortController = new AbortController();
setRegenAbortController(abortController);
setIsRegenerating(true);
setRegenProgress(0);
// Simulate progress while waiting for sync API (progress increases gradually)
const progressInterval = setInterval(() => {
setRegenProgress(prev => {
if (prev === null)
return 0;
// Slow down as we approach 90% (leave room for completion)
if (prev >= 90)
return prev;
return Math.min(90, prev + Math.random() * 8 + 2);
});
}, 500);
try {
// Check if we have an existing source image to reuse
const existingSourceImage = selectedVideo.sourceImageUrl;
// Build message for animate mode
// If we have a source image, REUSE it (don't generate new) - Grok behavior
// If no source image, let backend generate one from the prompt
const animateMessage = existingSourceImage
? `animate ${existingSourceImage} ${lightboxPrompt}`
: `animate ${lightboxPrompt}`;
// Determine video resolution override values (additive + backward compatible)
const regenUseOverride = vidResolutionMode === 'override' && overrideDims != null;
const regenVidWidth = regenUseOverride ? overrideDims.width : autoDims?.width;
const regenVidHeight = regenUseOverride ? overrideDims.height : autoDims?.height;
// Map hardware presets to video quality presets (normalise "med" → "medium")
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,
// When we have an existing source image, tell backend to skip image generation
// The prompt should only affect the animation, not regenerate the source
...(existingSourceImage && { skipImageGeneration: true }),
// Video resolution override (new, preferred fields)
vidResolutionMode: regenUseOverride ? 'override' : 'auto',
...(regenUseOverride && {
vidWidth: regenVidWidth,
vidHeight: regenVidHeight,
}),
// Legacy compatibility
...(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);
// Check if aborted
if (abortController.signal.aborted) {
return;
}
if (!data.media?.video_url) {
throw new Error(data.message || data.text || 'No video URL returned');
}
// Progress complete
setRegenProgress(100);
// Create new item with updated video
// IMPORTANT: If we had an existing source image, KEEP IT (don't replace with auto_generated)
// Only use auto_generated when we didn't have a source image (text-to-video flow)
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,
// Grok behavior: keep the original source image when regenerating
// Only use auto_generated_image when there was no source (text-to-video)
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,
// Resolution for reproducibility
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),
};
// Update selected video in-place (Grok behavior)
setSelectedVideo(newItem);
setLightboxPrompt(newItem.finalPrompt || newItem.prompt);
// Also prepend to items list
setItems(prev => [newItem, ...prev]);
// Brief delay to show 100% before clearing
await new Promise(r => setTimeout(r, 300));
}
catch (err) {
if (abortController.signal.aborted) {
return; // User cancelled
}
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]);
// Cancel in-place regeneration
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>
{/* Immersive Lightbox - Clean, Video-first Design */}
{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>);
}