'use client'; import { useState, useRef, useEffect } from 'react'; import { Header, AppLayout } from '@/components/layout'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { PromptTextarea } from '@/components/forms'; import { Label } from '@/components/ui/label'; import { Card, CardContent } from '@/components/ui/card'; import { ImageInput } from '@/components/ui/image-input'; import { apiClient, type JobInfo } from '@/lib/api'; import { Loader2, Download, X } from 'lucide-react'; import { downloadImage, downloadAuthenticatedImage, fileToBase64 } from '@/lib/utils'; import { useLocalStorage, useMemoryStorage } from '@/lib/storage'; type Img2ImgFormData = { prompt: string; negative_prompt: string; image: string; strength: number; steps: number; cfg_scale: number; seed: string; sampling_method: string; width?: number; height?: number; }; const defaultFormData: Img2ImgFormData = { prompt: '', negative_prompt: '', image: '', strength: 0.75, steps: 20, cfg_scale: 7.5, seed: '', sampling_method: 'euler_a', width: 512, height: 512, }; function Img2ImgForm() { // Store form data without the image to avoid localStorage quota issues const { image: _, ...formDataWithoutImage } = defaultFormData; const [formData, setFormData] = useLocalStorage>( 'img2img-form-data', formDataWithoutImage ); // Store image separately in memory const [imageData, setImageData] = useMemoryStorage(''); // Combined form data with image const fullFormData = { ...formData, image: imageData }; const [loading, setLoading] = useState(false); const [jobInfo, setJobInfo] = useState(null); const [generatedImages, setGeneratedImages] = useState([]); const [previewImage, setPreviewImage] = useState(null); const [loraModels, setLoraModels] = useState([]); const [embeddings, setEmbeddings] = useState([]); const [selectedImage, setSelectedImage] = useState(null); const [imageValidation, setImageValidation] = useState(null); const [originalImage, setOriginalImage] = useState(null); const [isResizing, setIsResizing] = useState(false); const [error, setError] = useState(null); const [pollCleanup, setPollCleanup] = useState<(() => void) | null>(null); // Cleanup polling on unmount useEffect(() => { return () => { if (pollCleanup) { pollCleanup(); } }; }, [pollCleanup]); useEffect(() => { const loadModels = async () => { try { const [loras, embeds] = await Promise.all([ apiClient.getModels('lora'), apiClient.getModels('embedding'), ]); setLoraModels(loras.models.map(m => m.name)); setEmbeddings(embeds.models.map(m => m.name)); } catch (err) { console.error('Failed to load models:', err); } }; loadModels(); }, []); const handleInputChange = ( e: React.ChangeEvent ) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: name === 'prompt' || name === 'negative_prompt' || name === 'seed' || name === 'sampling_method' ? value : Number(value), })); }; const handleImageChange = async (image: File | string | null) => { setSelectedImage(image); setError(null); if (!image) { setImageData(''); setFormData(prev => ({ ...prev, image: '' })); setPreviewImage(null); setImageValidation(null); setOriginalImage(null); return; } try { let imageBase64: string; let previewUrl: string; if (image instanceof File) { // Convert File to base64 imageBase64 = await fileToBase64(image); previewUrl = imageBase64; } else { // Use URL directly imageBase64 = image; previewUrl = image; } // Store original image for resizing setOriginalImage(imageBase64); setImageData(imageBase64); setFormData(prev => ({ ...prev })); // Don't store image in localStorage setPreviewImage(previewUrl); } catch (err) { setError('Failed to process image'); console.error('Image processing error:', err); } }; // Auto-resize image when width or height changes useEffect(() => { const resizeImage = async () => { if (!originalImage || !formData.width || !formData.height) { return; } // Don't resize if we're already resizing if (isResizing) { return; } try { setIsResizing(true); // Validate image data before sending if (!originalImage.startsWith('data:image/')) { console.warn('Invalid image data for resizing'); return; } const result = await apiClient.resizeImage(originalImage, formData.width, formData.height); setImageData(result.image); setFormData(prev => ({ ...prev })); // Don't store image in localStorage setPreviewImage(result.image); } catch (err) { console.error('Failed to resize image:', err); setError('Failed to resize image - you may need to resize manually'); } finally { setIsResizing(false); } }; resizeImage(); }, [formData.width, formData.height, originalImage]); const handleImageValidation = (result: any) => { setImageValidation(result); if (!result.isValid) { setError(result.error || 'Invalid image'); } else { setError(null); // If we have base64 data from URL download, use it for preview if (result.base64Data && selectedImage && typeof selectedImage === 'string') { setPreviewImage(result.base64Data); setFormData(prev => ({ ...prev, image: result.base64Data })); setOriginalImage(result.base64Data); } } }; const pollJobStatus = async (jobId: string) => { const maxAttempts = 300; let attempts = 0; let isPolling = true; const poll = async () => { if (!isPolling) return; try { const status = await apiClient.getJobStatus(jobId); setJobInfo(status); if (status.status === 'completed') { let imageUrls: string[] = []; // Handle both old format (result.images) and new format (outputs) if (status.outputs && status.outputs.length > 0) { // New format: convert output URLs to authenticated image URLs with cache-busting imageUrls = status.outputs.map((output: any) => { const filename = output.filename; return apiClient.getImageUrl(jobId, filename); }); } else if (status.result?.images && status.result.images.length > 0) { // Old format: convert image URLs to authenticated URLs imageUrls = status.result.images.map((imageUrl: string) => { // Extract filename from URL if it's already a full URL if (imageUrl.includes('/output/')) { const parts = imageUrl.split('/output/'); if (parts.length === 2) { const filename = parts[1].split('?')[0]; // Remove query params return apiClient.getImageUrl(jobId, filename); } } // If it's just a filename, convert it directly return apiClient.getImageUrl(jobId, imageUrl); }); } // Create a new array to trigger React re-render setGeneratedImages([...imageUrls]); setLoading(false); isPolling = false; } else if (status.status === 'failed') { setError(status.error || 'Generation failed'); setLoading(false); isPolling = false; } else if (status.status === 'cancelled') { setError('Generation was cancelled'); setLoading(false); isPolling = false; } else if (attempts < maxAttempts) { attempts++; setTimeout(poll, 2000); } else { setError('Job polling timeout'); setLoading(false); isPolling = false; } } catch (err) { if (isPolling) { setError(err instanceof Error ? err.message : 'Failed to check job status'); setLoading(false); isPolling = false; } } }; poll(); // Return cleanup function return () => { isPolling = false; }; }; const handleGenerate = async (e: React.FormEvent) => { e.preventDefault(); if (!fullFormData.image) { setError('Please upload or select an image first'); return; } // Check if image validation passed if (imageValidation && !imageValidation.isValid) { setError('Please fix the image validation errors before generating'); return; } setLoading(true); setError(null); setGeneratedImages([]); setJobInfo(null); try { const requestData = { ...fullFormData, }; const job = await apiClient.img2img(requestData); setJobInfo(job); const jobId = job.request_id || job.id; if (jobId) { const cleanup = pollJobStatus(jobId); setPollCleanup(() => cleanup); } else { setError('No job ID returned from server'); setLoading(false); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to generate image'); setLoading(false); } }; const handleCancel = async () => { const jobId = jobInfo?.request_id || jobInfo?.id; if (jobId) { try { await apiClient.cancelJob(jobId); setLoading(false); setError('Generation cancelled'); // Cleanup polling if (pollCleanup) { pollCleanup(); setPollCleanup(null); } } catch (err) { console.error('Failed to cancel job:', err); } } }; return (
{/* Left Panel - Form */}
setFormData({ ...formData, prompt: value })} placeholder="Describe the transformation you want..." rows={3} loras={loraModels} embeddings={embeddings} />

Tip: Use <lora:name:weight> for LoRAs and embedding names directly

setFormData({ ...formData, negative_prompt: value })} placeholder="What to avoid..." rows={2} loras={loraModels} embeddings={embeddings} />

Lower values preserve more of the original image

{isResizing && (
Resizing image...
)}
{loading && ( )}
{error && (
{error}
)}
{/* Right Panel - Generated Images */}

Generated Images

{generatedImages.length === 0 ? (

{loading ? 'Generating...' : 'Generated images will appear here'}

) : (
{generatedImages.map((image, index) => (
{`Generated
))}
)}
); } export default function Img2ImgPage() { return ; }