refactor: simplify Meta AI video workflow
Some checks are pending
CI / build (18.x) (push) Waiting to run
CI / build (20.x) (push) Waiting to run

- Remove Video button from PromptHero (generate images first, video from gallery)
- Add Meta AI image-to-video in Gallery component
- Redesign lightbox with split panel layout (image left, controls right)
- Add handleGenerateMetaVideo() for Meta AI video from gallery
- Fix reference buttons (all disabled for Meta AI)
- Lightbox now shows: Download, Generate Video, Remix/Edit, Copy Prompt, Delete
This commit is contained in:
Khoa.vo 2026-01-06 14:33:44 +07:00
parent bae4c487da
commit 7aaa4c8166
3 changed files with 179 additions and 164 deletions

View file

@ -37,6 +37,59 @@ export function Gallery() {
setEditModalOpen(true);
};
const [isGeneratingMetaVideo, setIsGeneratingMetaVideo] = React.useState(false);
// Handle Meta AI video generation (image-to-video)
const handleGenerateMetaVideo = async (img: { data: string; prompt: string }) => {
if (!settings.metaCookies) {
alert("Please set your Meta AI Cookies in Settings first!");
return;
}
setIsGeneratingMetaVideo(true);
try {
console.log("[Gallery] Starting Meta AI image-to-video...");
const res = await fetch('/api/meta/video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: img.prompt || "Animate this image with natural movement",
cookies: settings.metaCookies,
imageBase64: img.data
})
});
const data = await res.json();
console.log("[Gallery] Meta video response:", data);
if (data.success && data.videos?.length > 0) {
for (const video of data.videos) {
addVideo({
id: crypto.randomUUID(),
url: video.url,
prompt: video.prompt || img.prompt,
thumbnail: img.data,
createdAt: Date.now()
});
}
alert('🎬 Video generation complete! Scroll up to see your video.');
} else {
throw new Error(data.error || 'No videos generated');
}
} catch (error: any) {
console.error("[Gallery] Meta video error:", error);
let errorMessage = error.message || 'Video generation failed';
if (errorMessage.includes('401') || errorMessage.includes('cookies')) {
errorMessage = '🔐 Your Meta AI cookies have expired. Please go to Settings and update them.';
}
alert(errorMessage);
} finally {
setIsGeneratingMetaVideo(false);
}
};
const handleGenerateVideo = async (prompt: string) => {
if (!videoSource) return;
@ -359,93 +412,175 @@ export function Gallery() {
</AnimatePresence>
</div>
{/* Lightbox Modal */}
{/* Lightbox Modal - Split Panel Design */}
<AnimatePresence>
{selectedIndex !== null && selectedImage && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm p-4 md:p-8"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 backdrop-blur-md p-4 md:p-6"
onClick={() => setSelectedIndex(null)}
>
{/* Close Button */}
<button
className="absolute top-4 right-4 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
onClick={() => setSelectedIndex(null)}
>
<X className="h-6 w-6" />
<X className="h-5 w-5" />
</button>
{/* Navigation Buttons */}
{selectedIndex > 0 && (
<button
className="absolute left-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50 hidden md:block"
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 p-2 md:p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! - 1); }}
>
<ChevronLeft className="h-8 w-8" />
<ChevronLeft className="h-6 w-6 md:h-8 md:w-8" />
</button>
)}
{selectedIndex < gallery.length - 1 && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50 hidden md:block"
className="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 p-2 md:p-3 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors z-50"
onClick={(e) => { e.stopPropagation(); setSelectedIndex(prev => prev! + 1); }}
>
<ChevronRight className="h-8 w-8" />
<ChevronRight className="h-6 w-6 md:h-8 md:w-8" />
</button>
)}
{/* Image Container */}
{/* Split Panel Container */}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="relative max-w-7xl max-h-full flex flex-col items-center"
exit={{ scale: 0.95, opacity: 0 }}
className="relative w-full max-w-6xl max-h-[90vh] flex flex-col md:flex-row gap-4 md:gap-6 overflow-hidden"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
{/* Left: Image */}
<div className="flex-1 flex items-center justify-center min-h-0">
<img
src={getImageSrc(selectedImage.data)}
alt={selectedImage.prompt}
className="max-w-full max-h-[85vh] object-contain rounded-lg shadow-2xl"
className="max-w-full max-h-[50vh] md:max-h-[85vh] object-contain rounded-xl shadow-2xl"
/>
</div>
<div className="mt-4 flex flex-col items-center gap-2 max-w-2xl text-center">
<p className="text-white/90 text-sm md:text-base font-medium line-clamp-2">
{selectedImage.prompt}
{/* Right: Controls Panel */}
<div className="w-full md:w-80 lg:w-96 flex flex-col gap-4 bg-white/5 rounded-xl p-4 md:p-5 border border-white/10 backdrop-blur-sm max-h-[40vh] md:max-h-[85vh] overflow-y-auto">
{/* Provider Badge */}
{selectedImage.provider && (
<div className={cn(
"self-start px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider",
selectedImage.provider === 'meta' ? "bg-blue-500/20 text-blue-300 border border-blue-500/30" :
selectedImage.provider === 'grok' ? "bg-yellow-500/20 text-yellow-300 border border-yellow-500/30" :
"bg-amber-500/20 text-amber-300 border border-amber-500/30"
)}>
{selectedImage.provider}
</div>
)}
{/* Prompt Section */}
<div className="space-y-2">
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider">Prompt</h3>
<p className="text-white/90 text-sm leading-relaxed">
{selectedImage.prompt || "No prompt available"}
</p>
<div className="flex gap-3">
</div>
{/* Divider */}
<div className="border-t border-white/10" />
{/* Actions */}
<div className="space-y-3">
<h3 className="text-xs font-medium text-white/50 uppercase tracking-wider">Actions</h3>
{/* Download */}
<a
href={getImageSrc(selectedImage.data)}
download={"generated-" + selectedIndex + "-" + Date.now() + ".png"}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90 rounded-full font-medium transition-colors"
className="flex items-center gap-3 w-full px-4 py-3 bg-white/10 hover:bg-white/15 rounded-lg text-white font-medium transition-colors"
>
<Download className="h-4 w-4" />
Download Current
<Download className="h-5 w-5 text-green-400" />
<span>Download Image</span>
</a>
{(!selectedImage.provider || selectedImage.provider === 'whisk') && (
{/* Generate Video - Show for all providers */}
<button
onClick={() => {
if (selectedImage) openVideoModal(selectedImage);
if (selectedImage.provider === 'meta') {
handleGenerateMetaVideo(selectedImage);
} else {
openVideoModal(selectedImage);
}
}}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full font-medium transition-colors"
disabled={isGeneratingMetaVideo}
className={cn(
"flex items-center gap-3 w-full px-4 py-3 rounded-lg text-white font-medium transition-all",
isGeneratingMetaVideo
? "bg-gray-600 cursor-wait"
: selectedImage.provider === 'meta'
? "bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500"
: "bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500"
)}
>
<Film className="h-4 w-4" />
Generate Video
{isGeneratingMetaVideo ? (
<>
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Generating Video...</span>
</>
) : (
<>
<Film className="h-5 w-5" />
<span>Generate Video</span>
</>
)}
</button>
{/* Remix/Edit - Only for Whisk */}
{(!selectedImage.provider || selectedImage.provider === 'whisk') && (
<button
onClick={() => openEditModal(selectedImage)}
className="flex items-center gap-3 w-full px-4 py-3 bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 rounded-lg text-white font-medium transition-all"
>
<Wand2 className="h-5 w-5" />
<span>Remix / Edit</span>
</button>
)}
{/* Use Prompt */}
<button
onClick={() => {
setPrompt(selectedImage.prompt);
navigator.clipboard.writeText(selectedImage.prompt);
setSelectedIndex(null); // Close lightbox? Or keep open? User said "reuse", likely wants to edit.
// Let's close it so they can see the input updating.
setSelectedIndex(null);
}}
className="flex items-center gap-2 px-4 py-2 bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-full font-medium transition-colors"
className="flex items-center gap-3 w-full px-4 py-3 bg-white/10 hover:bg-white/15 rounded-lg text-white font-medium transition-colors"
>
<Copy className="h-4 w-4" />
Use Prompt
<Copy className="h-5 w-5 text-purple-400" />
<span>Copy & Use Prompt</span>
</button>
{/* Delete */}
<button
onClick={() => {
if (selectedImage.id) {
removeFromGallery(selectedImage.id);
setSelectedIndex(null);
}
}}
className="flex items-center gap-3 w-full px-4 py-3 bg-red-500/10 hover:bg-red-500/20 rounded-lg text-red-400 font-medium transition-colors border border-red-500/20"
>
<Trash2 className="h-5 w-5" />
<span>Delete Image</span>
</button>
</div>
{/* Image Info */}
<div className="mt-auto pt-3 border-t border-white/10 text-xs text-white/40 space-y-1">
{selectedImage.aspectRatio && (
<p>Aspect Ratio: {selectedImage.aspectRatio}</p>
)}
<p>Image {selectedIndex + 1} of {gallery.length}</p>
</div>
</div>
</motion.div>

View file

@ -3,7 +3,7 @@
import React, { useRef, useState, useEffect } from "react";
import { useStore, ReferenceCategory } from "@/lib/store";
import { cn } from "@/lib/utils";
import { Sparkles, Maximize2, X, Hash, AlertTriangle, Upload, Zap, Brain, Settings, Settings2, Video } from "lucide-react";
import { Sparkles, Maximize2, X, Hash, AlertTriangle, Upload, Zap, Brain, Settings, Settings2 } from "lucide-react";
const IMAGE_COUNTS = [1, 2, 4];
@ -19,7 +19,7 @@ export function PromptHero() {
} = useStore();
const [isGenerating, setLocalIsGenerating] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [uploadingRefs, setUploadingRefs] = useState<Record<string, boolean>>({});
const [errorNotification, setErrorNotification] = useState<{ message: string; type: 'error' | 'warning' } | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -205,85 +205,6 @@ export function PromptHero() {
}
};
// Handle video generation (Meta AI only)
// If a subject reference is set, it will use image-to-video
// Otherwise, it will use text-to-video
const handleGenerateVideo = async () => {
let finalPrompt = prompt.trim();
if (!finalPrompt || isGeneratingVideo || settings.provider !== 'meta') return;
setIsGeneratingVideo(true);
setIsGenerating(true);
try {
// Check if we have a subject reference for image-to-video
const subjectRefs = references.subject || [];
const imageBase64 = subjectRefs.length > 0 ? subjectRefs[0].thumbnail : undefined;
const mode = imageBase64 ? 'image-to-video' : 'text-to-video';
console.log(`[PromptHero] Starting Meta AI ${mode}...`);
const res = await fetch('/api/meta/video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: finalPrompt,
cookies: settings.metaCookies,
imageBase64: imageBase64
})
});
const responseText = await res.text();
let data;
try {
data = JSON.parse(responseText);
} catch (e) {
console.error("API Error (Non-JSON response):", responseText.substring(0, 500));
throw new Error(`Server Error: ${res.status} ${res.statusText}`);
}
if (data.error) throw new Error(data.error);
if (data.videos && data.videos.length > 0) {
// Add videos to store
for (const video of data.videos) {
useStore.getState().addVideo({
id: crypto.randomUUID(),
url: video.url,
prompt: video.prompt || finalPrompt,
thumbnail: imageBase64, // Store the source image as thumbnail
createdAt: Date.now()
});
}
// Show success notification
setErrorNotification({
message: `🎬 Success! Generated ${data.videos.length} video(s) via ${mode}. Check the gallery.`,
type: 'warning' // Using warning for visibility (amber color)
});
setTimeout(() => setErrorNotification(null), 5000);
} else {
throw new Error('No videos were generated');
}
} catch (e: any) {
console.error('[Video Gen]', e);
const errorMessage = e.message || '';
if (errorMessage.includes('401') || errorMessage.includes('cookies')) {
setShowCookieExpired(true);
}
setErrorNotification({
message: `🎬 Video Error: ${errorMessage}`,
type: 'error'
});
setTimeout(() => setErrorNotification(null), 8000);
} finally {
setIsGeneratingVideo(false);
setIsGenerating(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
@ -567,19 +488,14 @@ export function PromptHero() {
<div className="flex flex-col md:flex-row items-center justify-between gap-3 pt-1">
{/* Left Controls: References */}
{/* For Meta AI: Only subject is enabled (for image-to-video), scene/style are disabled */}
<div className="flex flex-wrap gap-2">
{/* For Meta AI: References are disabled (generate images first, video from gallery) */}
<div className={cn("flex flex-wrap gap-2", settings.provider === 'meta' && "opacity-30 pointer-events-none grayscale")}>
{(['subject', 'scene', 'style'] as ReferenceCategory[]).map((cat) => {
const refs = references[cat] || [];
const hasRefs = refs.length > 0;
const isUploading = uploadingRefs[cat];
// For Meta AI: only enable subject (for image-to-video), disable scene/style
const isDisabledForMeta = settings.provider === 'meta' && cat !== 'subject';
return (
<div key={cat} className={cn(
"relative group",
isDisabledForMeta && "opacity-30 pointer-events-none grayscale"
)}>
<div key={cat} className="relative group">
<button
onClick={() => toggleReference(cat)}
onDragOver={handleDragOver}
@ -591,9 +507,6 @@ export function PromptHero() {
: "bg-white/5 text-white/40 border-white/5 hover:bg-white/10 hover:text-white/70 hover:border-white/10",
isUploading && "animate-pulse cursor-wait"
)}
title={settings.provider === 'meta' && cat === 'subject'
? "Upload an image to animate into video"
: undefined}
>
{isUploading ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
@ -715,7 +628,7 @@ export function PromptHero() {
)}
>
<div className="relative z-10 flex items-center gap-1.5">
{isGenerating && !isGeneratingVideo ? (
{isGenerating ? (
<>
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span className="animate-pulse">Dreaming...</span>
@ -732,39 +645,6 @@ export function PromptHero() {
)}
</div>
</button>
{/* Generate Video Button - Only for Meta AI */}
{settings.provider === 'meta' && (
<button
onClick={handleGenerateVideo}
disabled={isGenerating || !prompt.trim()}
className={cn(
"relative overflow-hidden px-4 py-1.5 rounded-lg font-bold text-sm text-white shadow-lg transition-all active:scale-95 group border border-white/10",
isGenerating
? "bg-gray-700 cursor-not-allowed"
: references.subject?.length
? "bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 hover:shadow-purple-500/25"
: "bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-500 hover:to-cyan-500 hover:shadow-cyan-500/25"
)}
title={references.subject?.length
? "Animate the subject image (30-60+ seconds)"
: "Generate video from text prompt (30-60+ seconds)"}
>
<div className="relative z-10 flex items-center gap-1.5">
{isGeneratingVideo ? (
<>
<div className="h-3 w-3 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span className="animate-pulse">Creating...</span>
</>
) : (
<>
<Video className="h-3 w-3 group-hover:scale-110 transition-transform" />
<span>{references.subject?.length ? "Animate" : "Video"}</span>
</>
)}
</div>
</button>
)}
</div>
</div>

View file

@ -1,6 +1,6 @@
{
"last_updated": "2026-01-06T04:34:41.435Z",
"lastSync": 1767674081435,
"last_updated": "2026-01-06T07:32:15.350Z",
"lastSync": 1767684735350,
"categories": {
"style": [
"Illustration",