"use client"; import { useState, useEffect, Suspense, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select } from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { FileInput } from "@/components/ui/file-input"; import { Dialog, DialogContent, DialogHeader, DialogBody, DialogFooter, } from "@/components/ui/dialog"; import { replaceVariables } from "@/lib/prompt-template"; import { Loader2, Sparkles, Wand2, LayoutGrid, Layers, History, Settings, Bell, HelpCircle, Clock, List, Maximize2, Copy, Download, SlidersHorizontal, RotateCcw, X, Wand, Dices, ChevronRight, ChevronDown, ChevronLeft, Plus, ImagePlus, Save, Heart, FolderOpen, FileText, PanelLeftClose, PanelLeft, } from "lucide-react"; import Image from "next/image"; import { ParameterTooltip } from "@/components/ui/tooltip"; import { EmptyState } from "@/components/ui/empty-state"; import { ImageLightbox } from "@/components/ImageLightbox"; import { MediaRenderer } from "@/components/MediaRenderer"; import { useSession } from "@/hooks/use-auth"; import { useSettings } from "@/hooks/use-settings"; import { usePresets, useCreatePreset } from "@/hooks/use-presets"; import { usePrompts, useCreatePrompt } from "@/hooks/use-prompts"; import { useImages, useImage, useToggleFavorite, useCreateVariation } from "@/hooks/use-images"; import { useSubmitJob, useJobStatus } from "@/hooks/use-jobs"; import { useUpload } from "@/hooks/use-upload"; import { useQueryClient } from "@tanstack/react-query"; // Parameter tooltips content const PARAMETER_TOOLTIPS = { aspectRatio: "The width-to-height ratio of the generated image. Square (1:1) works well for icons, while widescreen (16:9) is great for landscapes.", imageCount: "The number of images to generate in one batch. More images give you more options to choose from.", guidance: "Controls how closely the AI follows your prompt. Higher values (10-20) follow the prompt more strictly, while lower values (1-5) give more creative freedom.", steps: "The number of refinement iterations. More steps (50-150) produce higher quality but take longer. 20-30 steps is usually sufficient.", seed: "A number that determines the random starting point. Using the same seed with the same prompt produces identical results, useful for variations.", model: "The AI model to use for generation. Different models have different strengths, speeds, and styles.", negativePrompt: "Things you don't want to appear in the image. For example: 'blurry, low quality, distorted'.", styleModifiers: "Quick-add keywords that enhance your prompt with common quality and style improvements.", cameraModifiers: "Add camera types, lenses, focal lengths, and apertures to achieve specific photographic looks and effects.", depthAngleModifiers: "Control camera angles, shot distances, and perspectives to create compelling compositions and viewpoints.", }; interface GeneratedImage { id: string; fileUrl: string; width: number; height: number; prompt: string; modelId: string; format?: string | null; isFavorite?: boolean; rating?: number | null; parameters?: any; createdAt?: string; negativePrompt?: string; } interface GenerationJob { id: string; status: "pending" | "processing" | "completed" | "failed"; prompt: string; modelId: string; parameters: any; errorMessage?: string; createdAt: Date; startedAt?: Date; completedAt?: Date; images?: GeneratedImage[]; } const MODELS = [ { id: "flux-pro", name: "Flux Pro", description: "Highest quality", supportsTextToImage: true, supportsImageToImage: true, supportsImageToVideo: false, }, { id: "flux-dev", name: "Flux Dev", description: "Balanced speed/quality", supportsTextToImage: true, supportsImageToImage: true, supportsImageToVideo: false, }, { id: "flux-schnell", name: "Flux Schnell", description: "Fast generation", supportsTextToImage: true, supportsImageToImage: true, supportsImageToVideo: false, }, { id: "sdxl", name: "Stable Diffusion XL", description: "Versatile", supportsTextToImage: true, supportsImageToImage: true, supportsImageToVideo: false, }, { id: "wan-25", name: "WAN 2.5", description: "Image to Video", supportsTextToImage: false, supportsImageToImage: false, supportsImageToVideo: true, }, ]; // Helper function to get available models for a generation mode const getAvailableModels = (mode: "text-to-image" | "image-to-image" | "image-to-video") => { return MODELS.filter((model) => { switch (mode) { case "text-to-image": return model.supportsTextToImage; case "image-to-image": return model.supportsImageToImage; case "image-to-video": return model.supportsImageToVideo; default: return false; } }); }; const ASPECT_RATIOS = [ { id: "square", label: "1:1", w: 5, h: 5 }, { id: "portrait_4_3", label: "3:4", w: 3, h: 4 }, { id: "landscape_4_3", label: "4:3", w: 4, h: 3 }, { id: "landscape_16_9", label: "16:9", w: 7, h: 4 }, ]; const STYLE_MODIFIERS = [ "4K", "8K", "Detailed", "Cinematic", "Octane Render", "Ray Tracing", "Ultra realistic", "High quality", "Award winning", "Professional", ]; const CAMERA_MODIFIERS = [ "DSLR", "Mirrorless camera", "Medium format", "Large format", "Film camera", "14mm lens", "24mm lens", "35mm lens", "50mm lens", "85mm lens", "135mm lens", "200mm lens", "Wide angle lens", "Telephoto lens", "Macro lens", "Fisheye lens", "Prime lens", "Zoom lens", "f/1.2", "f/1.4", "f/1.8", "f/2.8", "f/4", "f/5.6", "Shallow depth of field", "Deep depth of field", "Bokeh", "Tilt-shift", "Anamorphic", ]; const DEPTH_ANGLE_MODIFIERS = [ "Extreme close-up", "Close-up shot", "Medium close-up", "Medium shot", "Medium long shot", "Long shot", "Extreme long shot", "Full body shot", "Cowboy shot", "Eye level angle", "High angle", "Low angle", "Bird's eye view", "Worm's eye view", "Dutch angle", "Overhead shot", "Aerial view", "Ground level", "Over-the-shoulder", "Point of view shot", "First-person view", "Third-person view", "Side profile", "Three-quarter view", "Front view", "Back view", "Isometric view", "Forced perspective", "Macro photography", "Micro lens shot", "Tracking shot", "Establishing shot", "Two-shot", ]; function GeneratePageContent() { const router = useRouter(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); // TanStack Query hooks const { data: session, isPending: sessionLoading } = useSession(); const { data: settingsData } = useSettings(); const { data: presetsData } = usePresets(!!session); const { data: promptsData } = usePrompts(!!session); const { data: historyData } = useImages({ limit: 20 }); const createPresetMutation = useCreatePreset(); const createPromptMutation = useCreatePrompt(); const toggleFavoriteMutation = useToggleFavorite(); const createVariationMutation = useCreateVariation(); const submitJobMutation = useSubmitJob(); const uploadMutation = useUpload(); const [prompt, setPrompt] = useState(""); const [negativePrompt, setNegativePrompt] = useState(""); const [model, setModel] = useState("flux-pro"); const [aspectRatio, setAspectRatio] = useState("landscape_16_9"); const [numImages, setNumImages] = useState(1); const [steps, setSteps] = useState(28); const [guidance, setGuidance] = useState(3.5); const [seed, setSeed] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [generatedImages, setGeneratedImages] = useState([]); const [generationTime, setGenerationTime] = useState(null); // Job-based generation state const [jobs, setJobs] = useState([]); const processedJobsRef = useRef>(new Set()); const autoStartTriggeredRef = useRef(false); // Job status polling const pendingJobIds = jobs .filter((j) => j.status === "pending" || j.status === "processing") .map((j) => j.id); const { data: jobStatusData } = useJobStatus(pendingJobIds, { enabled: pendingJobIds.length > 0, }); // UI States const [showNegativePrompt, setShowNegativePrompt] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); // Image-to-image mode const [generationMode, setGenerationMode] = useState< "text-to-image" | "image-to-image" | "image-to-video" >("text-to-image"); const [sourceImage, setSourceImage] = useState(null); // URL or data URL const [sourceImageFile, setSourceImageFile] = useState(null); const [strength, setStrength] = useState(0.75); // 0-1, how much to transform const [isDragging, setIsDragging] = useState(false); // Video generation state (for WAN 2.5) const [resolution, setResolution] = useState<"480p" | "720p" | "1080p">("1080p"); const [duration, setDuration] = useState<5 | 10>(5); // Preset state const [showSavePresetModal, setShowSavePresetModal] = useState(false); const [savePresetData, setSavePresetData] = useState({ name: "", description: "", }); // Save Prompt state const [showSaveModal, setShowSaveModal] = useState(false); const [savePromptData, setSavePromptData] = useState({ title: "", category: "", }); // Template/Prompt loading state const [showLoadPromptModal, setShowLoadPromptModal] = useState(false); const [showTemplateVariablesModal, setShowTemplateVariablesModal] = useState(false); const [selectedTemplate, setSelectedTemplate] = useState(null); const [templateVariableValues, setTemplateVariableValues] = useState< Record >({}); // Style modifiers state const [activeStyles, setActiveStyles] = useState([]); // Camera modifiers state const [activeCameras, setActiveCameras] = useState([]); // Depth/Angle modifiers state const [activeDepthAngles, setActiveDepthAngles] = useState([]); // View mode state const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); // Lightbox state const [lightboxOpen, setLightboxOpen] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); // Derived state from queries const presets = presetsData?.presets || []; const savedPrompts = promptsData?.prompts || []; const historyImages = historyData?.images || []; const handleAddStyleModifier = (style: string) => { // Add the style to active styles if not already there if (!activeStyles.includes(style)) { setActiveStyles([...activeStyles, style]); // Add the style to the prompt if it's not already there const styleText = style.toLowerCase(); if (!prompt.toLowerCase().includes(styleText)) { setPrompt((prev) => (prev ? `${prev}, ${style}` : style)); } } }; const handleRemoveStyleModifier = (style: string) => { setActiveStyles(activeStyles.filter((s) => s !== style)); // Remove the style from the prompt const styleRegex = new RegExp( `(,?\\s*${style}\\s*,?|${style}\\s*,|,\\s*${style})`, "gi" ); const updatedPrompt = prompt .replace(styleRegex, ",") .replace(/,\s*,/g, ",") .replace(/^\s*,\s*/, "") .replace(/\s*,\s*$/, "") .trim(); setPrompt(updatedPrompt); }; const handleAddCameraModifier = (camera: string) => { if (!activeCameras.includes(camera)) { setActiveCameras([...activeCameras, camera]); const cameraText = camera.toLowerCase(); if (!prompt.toLowerCase().includes(cameraText)) { setPrompt((prev) => (prev ? `${prev}, ${camera}` : camera)); } } }; const handleRemoveCameraModifier = (camera: string) => { setActiveCameras(activeCameras.filter((c) => c !== camera)); const cameraRegex = new RegExp( `(,?\\s*${camera}\\s*,?|${camera}\\s*,|,\\s*${camera})`, "gi" ); const updatedPrompt = prompt .replace(cameraRegex, ",") .replace(/,\s*,/g, ",") .replace(/^\s*,\s*/, "") .replace(/\s*,\s*$/, "") .trim(); setPrompt(updatedPrompt); }; const handleAddDepthAngleModifier = (modifier: string) => { if (!activeDepthAngles.includes(modifier)) { setActiveDepthAngles([...activeDepthAngles, modifier]); const modifierText = modifier.toLowerCase(); if (!prompt.toLowerCase().includes(modifierText)) { setPrompt((prev) => (prev ? `${prev}, ${modifier}` : modifier)); } } }; const handleRemoveDepthAngleModifier = (modifier: string) => { setActiveDepthAngles(activeDepthAngles.filter((m) => m !== modifier)); const modifierRegex = new RegExp( `(,?\\s*${modifier}\\s*,?|${modifier}\\s*,|,\\s*${modifier})`, "gi" ); const updatedPrompt = prompt .replace(modifierRegex, ",") .replace(/,\s*,/g, ",") .replace(/^\s*,\s*/, "") .replace(/\s*,\s*$/, "") .trim(); setPrompt(updatedPrompt); }; const handleSourceImageUpload = async ( e: React.ChangeEvent ) => { const file = e.target.files?.[0]; if (!file) return; // Preview the image const reader = new FileReader(); reader.onload = (event) => { setSourceImage(event.target?.result as string); setSourceImageFile(file); }; reader.readAsDataURL(file); }; const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }; const handleImageDragStart = (e: React.DragEvent, imageUrl: string) => { e.dataTransfer.setData("image/url", imageUrl); e.dataTransfer.effectAllowed = "copy"; }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); // Check if dragging from gallery first const imageUrl = e.dataTransfer.getData("image/url"); if (imageUrl) { setSourceImage(imageUrl); setSourceImageFile(null); // Clear file if using existing image return; } // Otherwise handle file drop const files = e.dataTransfer.files; if (files && files.length > 0) { const file = files[0]; // Check if it's an image if (file.type.startsWith("image/")) { const reader = new FileReader(); reader.onload = (event) => { setSourceImage(event.target?.result as string); setSourceImageFile(file); }; reader.readAsDataURL(file); } else { toast.error("Invalid file type", { description: "Please upload an image file", }); } } }; const handleSelectExistingImage = (imageUrl: string) => { setSourceImage(imageUrl); setSourceImageFile(null); // Clear file if using existing image }; const handleDownload = async (image: GeneratedImage) => { try { const response = await fetch(image.fileUrl); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${image.id}.png`; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); toast.success("Image downloaded!", { description: "Image saved to your downloads folder", }); } catch (error) { console.error("Failed to download image:", error); setError("Failed to download image. Please try again."); toast.error("Download failed", { description: "Failed to download image. Please try again.", }); } }; const handleToggleFavorite = async (imageId: string) => { toggleFavoriteMutation.mutate(imageId, { onSuccess: () => { setGeneratedImages((prev) => prev.map((img) => img.id === imageId ? { ...img, isFavorite: true } : img ) ); toast.success("Added to favorites!"); }, onError: (error) => { console.error("Failed to toggle favorite:", error); toast.error("Failed to add to favorites"); }, }); }; const handleGenerateVariation = async (image: GeneratedImage) => { setError(""); createVariationMutation.mutate( { imageId: image.id }, { onSuccess: (data) => { setGeneratedImages((prev) => [data.image, ...prev]); setGenerationTime(data.generationTime || null); toast.success("Variation created!", { description: "New variation added to your gallery", }); }, onError: (err) => { console.error("Failed to generate variation:", err); const errorMessage = err instanceof Error ? err.message : "Failed to generate variation. Please try again."; setError(errorMessage); toast.error("Variation failed", { description: errorMessage, }); }, } ); }; // Load prompt from URL parameters and handle variation/remix/upscale useEffect(() => { const urlPrompt = searchParams.get("prompt"); const urlNegativePrompt = searchParams.get("negativePrompt"); const urlModel = searchParams.get("model"); const variationFrom = searchParams.get("variationFrom"); const remixFrom = searchParams.get("remixFrom"); const upscaleFrom = searchParams.get("upscaleFrom"); if (urlPrompt) setPrompt(urlPrompt); if (urlNegativePrompt) setNegativePrompt(urlNegativePrompt); if (urlModel) { // Validate that it's a valid model ID const validModels = MODELS.map((m) => m.id); if (validModels.includes(urlModel)) { setModel(urlModel); } } // Handle variation from existing image if (variationFrom) { loadImageForVariation(variationFrom); } // Handle remix from existing image if (remixFrom) { loadImageForRemix(remixFrom); } // Handle upscale from existing image if (upscaleFrom) { loadImageForUpscale(upscaleFrom); } }, [searchParams]); // Auto-start generation when variation is loaded useEffect(() => { const autoStart = searchParams.get("autoStart"); if (autoStart === "true" && prompt && !autoStartTriggeredRef.current) { autoStartTriggeredRef.current = true; // Small delay to ensure all state is set setTimeout(() => { handleGenerate(); // Clean up URL to remove query parameters after starting the job router.replace("/generate/canvas", { scroll: false }); }, 100); } }, [prompt, searchParams, router]); const loadImageForVariation = async (imageId: string) => { try { const response = await fetch(`/api/images/${imageId}`); if (response.ok) { const data = await response.json(); const image = data.image; // Populate form with parent image settings setPrompt(image.prompt || ""); setNegativePrompt(image.negativePrompt || ""); setModel(image.modelId || "flux-pro"); if (image.parameters) { if (image.parameters.aspectRatio) setAspectRatio(image.parameters.aspectRatio); if (image.parameters.steps) setSteps(image.parameters.steps); if (image.parameters.guidance) setGuidance(image.parameters.guidance); // Generate a new seed for variation (slightly different from parent) if (image.parameters.seed) { const parentSeed = parseInt(image.parameters.seed) || 0; const newSeed = parentSeed + Math.floor(Math.random() * 1000) + 1; setSeed(newSeed.toString()); } } toast.success("Variation settings loaded!", { description: "Seed has been adjusted. Click Generate to create variation.", }); } } catch (error) { console.error("Failed to load image for variation:", error); setError("Failed to load parent image"); } }; const loadImageForRemix = async (imageId: string) => { try { const response = await fetch(`/api/images/${imageId}`); if (response.ok) { const data = await response.json(); const image = data.image; // Populate form with parent image settings but clear prompt for remix setPrompt(""); // User will enter new prompt setNegativePrompt(image.negativePrompt || ""); setModel(image.modelId || "flux-pro"); if (image.parameters) { if (image.parameters.aspectRatio) setAspectRatio(image.parameters.aspectRatio); if (image.parameters.steps) setSteps(image.parameters.steps); if (image.parameters.guidance) setGuidance(image.parameters.guidance); if (image.parameters.seed) setSeed(image.parameters.seed); } // Set to image-to-image mode and use the original image as the source setGenerationMode("image-to-image"); setSourceImage(image.fileUrl); setSourceImageFile(null); toast.success("Remix settings loaded!", { description: "Enter a new prompt and click Generate to remix with the original image.", }); } } catch (error) { console.error("Failed to load image for remix:", error); setError("Failed to load parent image"); } }; const loadImageForUpscale = async (imageId: string) => { try { const response = await fetch(`/api/images/${imageId}`); if (response.ok) { const data = await response.json(); toast.info("Upscaling feature coming soon!", { description: "For now, you can download and use external upscaling tools.", }); } } catch (error) { console.error("Failed to load image for upscale:", error); setError("Failed to load parent image"); } }; // Process job status updates from the hook useEffect(() => { if (!jobStatusData?.jobs) { return; } const updatedJobs = jobStatusData.jobs; setJobs((prevJobs) => prevJobs.map((job) => { const update = updatedJobs.find((u: any) => u.id === job.id); if (update) { // If job just completed, show toast notification if ( job.status !== "completed" && update.status === "completed" && !processedJobsRef.current.has(job.id) ) { // Mark this job as processed processedJobsRef.current.add(job.id); toast.success( `Generation completed for "${update.prompt.substring( 0, 30 )}..."`, { description: `Generated ${ update.images?.length || 0 } image${update.images?.length !== 1 ? "s" : ""}`, } ); // Add completed images to generatedImages if (update.images && update.images.length > 0) { setGeneratedImages((prev) => [...update.images, ...prev]); } // Refresh history and billing queryClient.invalidateQueries({ queryKey: ["images"] }); queryClient.invalidateQueries({ queryKey: ["billing"] }); } else if ( job.status !== "failed" && update.status === "failed" && !processedJobsRef.current.has(job.id) ) { // Mark this job as processed processedJobsRef.current.add(job.id); toast.error( `Generation failed for "${update.prompt.substring( 0, 30 )}..."`, { description: update.errorMessage || "Unknown error occurred", } ); } return { ...job, ...update, }; } return job; }) ); }, [jobStatusData]); const handleSavePreset = async (e: React.FormEvent) => { e.preventDefault(); createPresetMutation.mutate( { name: savePresetData.name, description: savePresetData.description || null, modelId: model, parameters: { model, width: 0, height: 0, steps, guidanceScale: guidance, aspectRatio, numImages, seed: seed || null, }, }, { onSuccess: () => { setShowSavePresetModal(false); setSavePresetData({ name: "", description: "" }); setError(""); toast.success("Preset saved!", { description: `"${savePresetData.name}" is ready to use`, }); }, onError: (err) => { const errorMessage = err instanceof Error ? err.message : "Failed to save preset"; setError(errorMessage); toast.error("Failed to save preset", { description: errorMessage, }); }, } ); }; const handleLoadPreset = (presetId: string) => { const preset = presets.find((p) => p.id === presetId); if (preset) { setModel(preset.modelId); const params = preset.parameters || {}; setAspectRatio(params.aspectRatio || "landscape_16_9"); setNumImages(params.numImages || 1); setSteps(params.steps || 28); setGuidance(params.guidance || 3.5); setSeed(params.seed || ""); } }; const handleSavePrompt = async (e: React.FormEvent) => { e.preventDefault(); createPromptMutation.mutate( { text: prompt, name: savePromptData.title, category: savePromptData.category || undefined, tags: [], }, { onSuccess: () => { setShowSaveModal(false); setSavePromptData({ title: "", category: "" }); setError(""); toast.success("Prompt saved!", { description: `"${savePromptData.title}" has been saved to your library`, }); }, onError: (err) => { const errorMessage = err instanceof Error ? err.message : "Failed to save prompt"; setError(errorMessage); toast.error("Failed to save prompt", { description: errorMessage, }); }, } ); }; const handleLoadPrompt = (promptItem: any) => { // Check if this is a template with variables if (promptItem.isTemplate && promptItem.templateVariables?.length > 0) { // Open modal to fill in variables setSelectedTemplate(promptItem); // Initialize empty values for all variables const initialValues: Record = {}; promptItem.templateVariables.forEach((varName: string) => { initialValues[varName] = ""; }); setTemplateVariableValues(initialValues); setShowLoadPromptModal(false); setShowTemplateVariablesModal(true); } else { // Regular prompt, just load it setPrompt(promptItem.promptText); setNegativePrompt(promptItem.negativePrompt || ""); setShowLoadPromptModal(false); } }; const handleApplyTemplate = () => { if (!selectedTemplate) return; // Replace variables in the template const filledPrompt = replaceVariables( selectedTemplate.promptText, templateVariableValues ); setPrompt(filledPrompt); if (selectedTemplate.negativePrompt) { const filledNegativePrompt = replaceVariables( selectedTemplate.negativePrompt, templateVariableValues ); setNegativePrompt(filledNegativePrompt); } // Close modal and reset setShowTemplateVariablesModal(false); setSelectedTemplate(null); setTemplateVariableValues({}); }; const handleHistoryItemClick = (image: GeneratedImage) => { router.push(`/generate/images/${image.id}?returnTo=/generate/canvas`); }; const handleGenerate = async () => { if (!session?.user) { setError("Please sign in to generate images"); return; } if (!prompt) return; // Check for source image in img2img mode if (generationMode === "image-to-image" && !sourceImage) { setError("Please select or upload a source image"); return; } // Check for source image in image-to-video mode if (generationMode === "image-to-video" && !sourceImage) { setError("Please select or upload a source image for video generation"); return; } setError(""); // Create optimistic job ID const optimisticJobId = `optimistic-${Date.now()}`; // Create optimistic job immediately for instant UI feedback const optimisticJob: GenerationJob = { id: optimisticJobId, status: "pending", prompt, modelId: model, parameters: { prompt, negativePrompt: negativePrompt || undefined, model, aspectRatio, numImages, steps: steps || undefined, guidance: guidance || undefined, seed: seed ? parseInt(seed) : undefined, generationMode, }, createdAt: new Date(), }; // Add optimistic job to the queue immediately setJobs((prev) => [optimisticJob, ...prev]); // Show toast immediately toast.info(`Generation started for "${prompt.substring(0, 30)}..."`, { description: `Job queued. You can continue working while it processes.`, }); try { let imageUrl = sourceImage; // If using uploaded file, first upload it if ((generationMode === "image-to-image" || generationMode === "image-to-video") && sourceImageFile) { const uploadData = await uploadMutation.mutateAsync(sourceImageFile); imageUrl = uploadData.url; } const requestBody: any = { prompt, negativePrompt: negativePrompt || undefined, model, aspectRatio, numImages, steps: steps || undefined, guidance: guidance || undefined, seed: seed ? parseInt(seed) : undefined, generationMode, }; // Add img2img specific parameters if (generationMode === "image-to-image") { requestBody.imageUrl = imageUrl; requestBody.strength = strength; } // Add video specific parameters for image-to-video mode if (generationMode === "image-to-video") { requestBody.imageUrl = imageUrl; requestBody.resolution = resolution; requestBody.duration = duration; } // Submit job using mutation const data = await submitJobMutation.mutateAsync(requestBody); // Replace optimistic job with real job data setJobs((prev) => prev.map((job) => job.id === optimisticJobId ? { ...job, id: data.jobId, status: data.status, parameters: requestBody, } : job ) ); } catch (err: any) { // Remove optimistic job on error setJobs((prev) => prev.filter((job) => job.id !== optimisticJobId)); const errorMessage = err.message || "Failed to start generation"; setError(errorMessage); toast.error("Generation failed to start", { description: errorMessage, }); } }; return ( <>
{/* MAIN CONTENT (Canvas/Gallery) */}
{/* Page Header */}

Canvas

{/* Mobile Toggle for Right Sidebar */}
{/* Scrollable Area */}
{/* 3. RIGHT SIDEBAR (Controls) */} {/* Mobile Overlay */} {mobileSidebarOpen && (
setMobileSidebarOpen(false)} >
)}