|
@@ -1,18 +1,23 @@
|
|
|
-'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';
|
|
|
|
|
|
|
+"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, type JobDetailsResponse } from "@/lib/api";
|
|
|
|
|
+import {
|
|
|
|
|
+ downloadImage,
|
|
|
|
|
+ downloadAuthenticatedImage,
|
|
|
|
|
+ fileToBase64,
|
|
|
|
|
+} from "@/lib/utils";
|
|
|
|
|
+import { useLocalStorage, useMemoryStorage } from "@/lib/storage";
|
|
|
|
|
+import { type ImageValidationResult } from "@/lib/image-validation";
|
|
|
|
|
+import { Loader2, Download, X } from "lucide-react";
|
|
|
|
|
|
|
|
type Img2ImgFormData = {
|
|
type Img2ImgFormData = {
|
|
|
prompt: string;
|
|
prompt: string;
|
|
@@ -28,30 +33,28 @@ type Img2ImgFormData = {
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
const defaultFormData: Img2ImgFormData = {
|
|
const defaultFormData: Img2ImgFormData = {
|
|
|
- prompt: '',
|
|
|
|
|
- negative_prompt: '',
|
|
|
|
|
- image: '',
|
|
|
|
|
|
|
+ prompt: "",
|
|
|
|
|
+ negative_prompt: "",
|
|
|
|
|
+ image: "",
|
|
|
strength: 0.75,
|
|
strength: 0.75,
|
|
|
steps: 20,
|
|
steps: 20,
|
|
|
cfg_scale: 7.5,
|
|
cfg_scale: 7.5,
|
|
|
- seed: '',
|
|
|
|
|
- sampling_method: 'euler_a',
|
|
|
|
|
|
|
+ seed: "",
|
|
|
|
|
+ sampling_method: "euler_a",
|
|
|
width: 512,
|
|
width: 512,
|
|
|
height: 512,
|
|
height: 512,
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
function Img2ImgForm() {
|
|
function Img2ImgForm() {
|
|
|
-
|
|
|
|
|
// Store form data without the image to avoid localStorage quota issues
|
|
// Store form data without the image to avoid localStorage quota issues
|
|
|
const { image: _, ...formDataWithoutImage } = defaultFormData;
|
|
const { image: _, ...formDataWithoutImage } = defaultFormData;
|
|
|
- const [formData, setFormData] = useLocalStorage<Omit<Img2ImgFormData, 'image'>>(
|
|
|
|
|
- 'img2img-form-data',
|
|
|
|
|
- formDataWithoutImage
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const [formData, setFormData] = useLocalStorage<
|
|
|
|
|
+ Omit<Img2ImgFormData, "image">
|
|
|
|
|
+ >("img2img-form-data", formDataWithoutImage);
|
|
|
|
|
+
|
|
|
// Store image separately in memory
|
|
// Store image separately in memory
|
|
|
- const [imageData, setImageData] = useMemoryStorage<string>('');
|
|
|
|
|
-
|
|
|
|
|
|
|
+ const [imageData, setImageData] = useMemoryStorage<string>("");
|
|
|
|
|
+
|
|
|
// Combined form data with image
|
|
// Combined form data with image
|
|
|
const fullFormData = { ...formData, image: imageData };
|
|
const fullFormData = { ...formData, image: imageData };
|
|
|
|
|
|
|
@@ -61,12 +64,16 @@ function Img2ImgForm() {
|
|
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
|
|
const [loraModels, setLoraModels] = useState<string[]>([]);
|
|
const [loraModels, setLoraModels] = useState<string[]>([]);
|
|
|
const [embeddings, setEmbeddings] = useState<string[]>([]);
|
|
const [embeddings, setEmbeddings] = useState<string[]>([]);
|
|
|
- const [selectedImage, setSelectedImage] = useState<File | string | null>(null);
|
|
|
|
|
- const [imageValidation, setImageValidation] = useState<any>(null);
|
|
|
|
|
|
|
+ const [selectedImage, setSelectedImage] = useState<File | string | null>(
|
|
|
|
|
+ null,
|
|
|
|
|
+ );
|
|
|
|
|
+ const [imageValidation, setImageValidation] =
|
|
|
|
|
+ useState<ImageValidationResult | null>(null);
|
|
|
const [originalImage, setOriginalImage] = useState<string | null>(null);
|
|
const [originalImage, setOriginalImage] = useState<string | null>(null);
|
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
const [pollCleanup, setPollCleanup] = useState<(() => void) | null>(null);
|
|
const [pollCleanup, setPollCleanup] = useState<(() => void) | null>(null);
|
|
|
|
|
+ const [imageLoadingFromUrl, setImageLoadingFromUrl] = useState(false);
|
|
|
|
|
|
|
|
// Cleanup polling on unmount
|
|
// Cleanup polling on unmount
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
@@ -81,28 +88,33 @@ function Img2ImgForm() {
|
|
|
const loadModels = async () => {
|
|
const loadModels = async () => {
|
|
|
try {
|
|
try {
|
|
|
const [loras, embeds] = await Promise.all([
|
|
const [loras, embeds] = await Promise.all([
|
|
|
- apiClient.getModels('lora'),
|
|
|
|
|
- apiClient.getModels('embedding'),
|
|
|
|
|
|
|
+ apiClient.getModels("lora"),
|
|
|
|
|
+ apiClient.getModels("embedding"),
|
|
|
]);
|
|
]);
|
|
|
- setLoraModels(loras.models.map(m => m.name));
|
|
|
|
|
- setEmbeddings(embeds.models.map(m => m.name));
|
|
|
|
|
|
|
+ setLoraModels(loras.models.map((m) => m.name));
|
|
|
|
|
+ setEmbeddings(embeds.models.map((m) => m.name));
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
- console.error('Failed to load models:', err);
|
|
|
|
|
|
|
+ console.error("Failed to load models:", err);
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
loadModels();
|
|
loadModels();
|
|
|
}, []);
|
|
}, []);
|
|
|
|
|
|
|
|
-
|
|
|
|
|
const handleInputChange = (
|
|
const handleInputChange = (
|
|
|
- e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
|
|
|
|
|
|
+ e: React.ChangeEvent<
|
|
|
|
|
+ HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
|
|
|
|
+ >,
|
|
|
) => {
|
|
) => {
|
|
|
const { name, value } = e.target;
|
|
const { name, value } = e.target;
|
|
|
setFormData((prev) => ({
|
|
setFormData((prev) => ({
|
|
|
...prev,
|
|
...prev,
|
|
|
- [name]: name === 'prompt' || name === 'negative_prompt' || name === 'seed' || name === 'sampling_method'
|
|
|
|
|
- ? value
|
|
|
|
|
- : Number(value),
|
|
|
|
|
|
|
+ [name]:
|
|
|
|
|
+ name === "prompt" ||
|
|
|
|
|
+ name === "negative_prompt" ||
|
|
|
|
|
+ name === "seed" ||
|
|
|
|
|
+ name === "sampling_method"
|
|
|
|
|
+ ? value
|
|
|
|
|
+ : Number(value),
|
|
|
}));
|
|
}));
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -111,8 +123,8 @@ function Img2ImgForm() {
|
|
|
setError(null);
|
|
setError(null);
|
|
|
|
|
|
|
|
if (!image) {
|
|
if (!image) {
|
|
|
- setImageData('');
|
|
|
|
|
- setFormData(prev => ({ ...prev, image: '' }));
|
|
|
|
|
|
|
+ setImageData("");
|
|
|
|
|
+ setFormData((prev) => ({ ...prev, image: "" }));
|
|
|
setPreviewImage(null);
|
|
setPreviewImage(null);
|
|
|
setImageValidation(null);
|
|
setImageValidation(null);
|
|
|
setOriginalImage(null);
|
|
setOriginalImage(null);
|
|
@@ -121,76 +133,154 @@ function Img2ImgForm() {
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
let imageBase64: string;
|
|
let imageBase64: string;
|
|
|
- let previewUrl: string;
|
|
|
|
|
|
|
+ let previewUrl: string | null;
|
|
|
|
|
|
|
|
if (image instanceof File) {
|
|
if (image instanceof File) {
|
|
|
// Convert File to base64
|
|
// Convert File to base64
|
|
|
imageBase64 = await fileToBase64(image);
|
|
imageBase64 = await fileToBase64(image);
|
|
|
previewUrl = imageBase64;
|
|
previewUrl = imageBase64;
|
|
|
} else {
|
|
} else {
|
|
|
- // Use URL directly
|
|
|
|
|
- imageBase64 = image;
|
|
|
|
|
- previewUrl = image;
|
|
|
|
|
|
|
+ // For URLs, don't set preview immediately - wait for validation
|
|
|
|
|
+ // The validation will provide base64 data for preview and processing
|
|
|
|
|
+ imageBase64 = ""; // Will be set by validation
|
|
|
|
|
+ previewUrl = null; // Will be set by validation
|
|
|
|
|
+ setImageLoadingFromUrl(true);
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ "Image URL provided, waiting for validation to process:",
|
|
|
|
|
+ image,
|
|
|
|
|
+ );
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Store original image for resizing
|
|
|
|
|
- setOriginalImage(imageBase64);
|
|
|
|
|
- setImageData(imageBase64);
|
|
|
|
|
- setFormData(prev => ({ ...prev })); // Don't store image in localStorage
|
|
|
|
|
- setPreviewImage(previewUrl);
|
|
|
|
|
|
|
+ // Store original image for resizing (will be updated by validation if URL)
|
|
|
|
|
+ if (image instanceof File) {
|
|
|
|
|
+ setOriginalImage(imageBase64);
|
|
|
|
|
+ setImageData(imageBase64);
|
|
|
|
|
+ setPreviewImage(previewUrl);
|
|
|
|
|
+ }
|
|
|
|
|
+ setFormData((prev) => ({ ...prev })); // Don't store image in localStorage
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
- setError('Failed to process image');
|
|
|
|
|
- console.error('Image processing error:', err);
|
|
|
|
|
|
|
+ setError("Failed to process image");
|
|
|
|
|
+ console.error("Image processing error:", err);
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // Auto-resize image when width or height changes
|
|
|
|
|
|
|
+ // Auto-resize image when width or height changes, but only if we have valid image data
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const resizeImage = async () => {
|
|
const resizeImage = async () => {
|
|
|
if (!originalImage || !formData.width || !formData.height) {
|
|
if (!originalImage || !formData.width || !formData.height) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Don't resize if we're already resizing
|
|
|
|
|
- if (isResizing) {
|
|
|
|
|
|
|
+ // Don't resize if we're already resizing or still loading from URL
|
|
|
|
|
+ if (isResizing || imageLoadingFromUrl) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Check if we have valid image data (data URL or HTTP URL)
|
|
|
|
|
+ const isValidImageData =
|
|
|
|
|
+ originalImage.startsWith("data:image/") ||
|
|
|
|
|
+ originalImage.startsWith("http://") ||
|
|
|
|
|
+ originalImage.startsWith("https://");
|
|
|
|
|
+
|
|
|
|
|
+ if (!isValidImageData) {
|
|
|
|
|
+ console.warn(
|
|
|
|
|
+ "Invalid image data format for resizing:",
|
|
|
|
|
+ originalImage.substring(0, 50),
|
|
|
|
|
+ );
|
|
|
|
|
+ return; // Don't show error for timing issues, just skip resize
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
try {
|
|
try {
|
|
|
setIsResizing(true);
|
|
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);
|
|
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ "Attempting to resize image, data type:",
|
|
|
|
|
+ originalImage.startsWith("data:image/") ? "data URL" : "HTTP URL",
|
|
|
|
|
+ );
|
|
|
|
|
+ console.log("Image data length:", originalImage.length);
|
|
|
|
|
+ console.log("Resize dimensions:", formData.width, "x", formData.height);
|
|
|
|
|
+
|
|
|
|
|
+ const result = await apiClient.resizeImage(
|
|
|
|
|
+ originalImage,
|
|
|
|
|
+ formData.width,
|
|
|
|
|
+ formData.height,
|
|
|
|
|
+ );
|
|
|
setImageData(result.image);
|
|
setImageData(result.image);
|
|
|
- setFormData(prev => ({ ...prev })); // Don't store image in localStorage
|
|
|
|
|
|
|
+ setFormData((prev) => ({ ...prev })); // Don't store image in localStorage
|
|
|
setPreviewImage(result.image);
|
|
setPreviewImage(result.image);
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
- console.error('Failed to resize image:', err);
|
|
|
|
|
- setError('Failed to resize image - you may need to resize manually');
|
|
|
|
|
|
|
+ console.error("Failed to resize image:", err);
|
|
|
|
|
+ const errorMessage =
|
|
|
|
|
+ err instanceof Error ? err.message : "Unknown error";
|
|
|
|
|
+ setError(
|
|
|
|
|
+ `Failed to resize image: ${errorMessage}. You may need to resize the image manually or use different dimensions.`,
|
|
|
|
|
+ );
|
|
|
} finally {
|
|
} finally {
|
|
|
setIsResizing(false);
|
|
setIsResizing(false);
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
resizeImage();
|
|
resizeImage();
|
|
|
- }, [formData.width, formData.height, originalImage]);
|
|
|
|
|
-
|
|
|
|
|
- const handleImageValidation = (result: any) => {
|
|
|
|
|
|
|
+ }, [
|
|
|
|
|
+ formData.width,
|
|
|
|
|
+ formData.height,
|
|
|
|
|
+ originalImage,
|
|
|
|
|
+ imageLoadingFromUrl,
|
|
|
|
|
+ isResizing,
|
|
|
|
|
+ ]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleImageValidation = (result: ImageValidationResult) => {
|
|
|
setImageValidation(result);
|
|
setImageValidation(result);
|
|
|
|
|
+ setImageLoadingFromUrl(false);
|
|
|
if (!result.isValid) {
|
|
if (!result.isValid) {
|
|
|
- setError(result.error || 'Invalid image');
|
|
|
|
|
|
|
+ setError(result.error || "Invalid image");
|
|
|
} else {
|
|
} else {
|
|
|
setError(null);
|
|
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);
|
|
|
|
|
|
|
+ // If we have temporary URL or base64 data from URL download, use it for preview and processing
|
|
|
|
|
+ if (selectedImage && typeof selectedImage === "string") {
|
|
|
|
|
+ if (result.tempUrl) {
|
|
|
|
|
+ // Use temporary URL for preview and processing
|
|
|
|
|
+ const fullTempUrl = result.tempUrl.startsWith("/")
|
|
|
|
|
+ ? `${window.location.origin}${result.tempUrl}`
|
|
|
|
|
+ : result.tempUrl;
|
|
|
|
|
+
|
|
|
|
|
+ setPreviewImage(fullTempUrl);
|
|
|
|
|
+ setOriginalImage(fullTempUrl);
|
|
|
|
|
+
|
|
|
|
|
+ // For processing, we still need to convert to base64 or use the URL directly
|
|
|
|
|
+ // The resize endpoint can handle URLs, so we can use the temp URL
|
|
|
|
|
+ setImageData(fullTempUrl);
|
|
|
|
|
+
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ "Using temporary URL from URL validation for image processing:",
|
|
|
|
|
+ fullTempUrl,
|
|
|
|
|
+ );
|
|
|
|
|
+ console.log("Temporary filename:", result.tempFilename);
|
|
|
|
|
+ } else if (result.base64Data) {
|
|
|
|
|
+ // Fallback to base64 data if no temporary URL
|
|
|
|
|
+ if (
|
|
|
|
|
+ result.base64Data.startsWith("data:image/") &&
|
|
|
|
|
+ result.base64Data.includes("base64,")
|
|
|
|
|
+ ) {
|
|
|
|
|
+ setPreviewImage(result.base64Data);
|
|
|
|
|
+ setImageData(result.base64Data);
|
|
|
|
|
+ setOriginalImage(result.base64Data);
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ "Using base64 data from URL validation for image processing",
|
|
|
|
|
+ );
|
|
|
|
|
+ console.log("Base64 data length:", result.base64Data.length);
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ "Base64 data preview:",
|
|
|
|
|
+ result.base64Data.substring(0, 100) + "...",
|
|
|
|
|
+ );
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.error(
|
|
|
|
|
+ "Invalid base64 data format received from server:",
|
|
|
|
|
+ result.base64Data.substring(0, 100),
|
|
|
|
|
+ );
|
|
|
|
|
+ setError("Invalid image data format received from server");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
@@ -199,32 +289,36 @@ function Img2ImgForm() {
|
|
|
const maxAttempts = 300;
|
|
const maxAttempts = 300;
|
|
|
let attempts = 0;
|
|
let attempts = 0;
|
|
|
let isPolling = true;
|
|
let isPolling = true;
|
|
|
|
|
+ let timeoutId: NodeJS.Timeout | null = null;
|
|
|
|
|
|
|
|
const poll = async () => {
|
|
const poll = async () => {
|
|
|
if (!isPolling) return;
|
|
if (!isPolling) return;
|
|
|
-
|
|
|
|
|
|
|
+
|
|
|
try {
|
|
try {
|
|
|
- const status = await apiClient.getJobStatus(jobId);
|
|
|
|
|
- setJobInfo(status);
|
|
|
|
|
|
|
+ const status: JobDetailsResponse = await apiClient.getJobStatus(jobId);
|
|
|
|
|
+ setJobInfo(status.job);
|
|
|
|
|
|
|
|
- if (status.status === 'completed') {
|
|
|
|
|
|
|
+ if (status.job.status === "completed") {
|
|
|
let imageUrls: string[] = [];
|
|
let imageUrls: string[] = [];
|
|
|
|
|
|
|
|
// Handle both old format (result.images) and new format (outputs)
|
|
// Handle both old format (result.images) and new format (outputs)
|
|
|
- if (status.outputs && status.outputs.length > 0) {
|
|
|
|
|
|
|
+ if (status.job.outputs && status.job.outputs.length > 0) {
|
|
|
// New format: convert output URLs to authenticated image URLs with cache-busting
|
|
// New format: convert output URLs to authenticated image URLs with cache-busting
|
|
|
- imageUrls = status.outputs.map((output: any) => {
|
|
|
|
|
|
|
+ imageUrls = status.job.outputs.map((output: any) => {
|
|
|
const filename = output.filename;
|
|
const filename = output.filename;
|
|
|
return apiClient.getImageUrl(jobId, filename);
|
|
return apiClient.getImageUrl(jobId, filename);
|
|
|
});
|
|
});
|
|
|
- } else if (status.result?.images && status.result.images.length > 0) {
|
|
|
|
|
|
|
+ } else if (
|
|
|
|
|
+ status.job.result?.images &&
|
|
|
|
|
+ status.job.result.images.length > 0
|
|
|
|
|
+ ) {
|
|
|
// Old format: convert image URLs to authenticated URLs
|
|
// Old format: convert image URLs to authenticated URLs
|
|
|
- imageUrls = status.result.images.map((imageUrl: string) => {
|
|
|
|
|
|
|
+ imageUrls = status.job.result.images.map((imageUrl: string) => {
|
|
|
// Extract filename from URL if it's already a full URL
|
|
// Extract filename from URL if it's already a full URL
|
|
|
- if (imageUrl.includes('/output/')) {
|
|
|
|
|
- const parts = imageUrl.split('/output/');
|
|
|
|
|
|
|
+ if (imageUrl.includes("/output/")) {
|
|
|
|
|
+ const parts = imageUrl.split("/output/");
|
|
|
if (parts.length === 2) {
|
|
if (parts.length === 2) {
|
|
|
- const filename = parts[1].split('?')[0]; // Remove query params
|
|
|
|
|
|
|
+ const filename = parts[1].split("?")[0]; // Remove query params
|
|
|
return apiClient.getImageUrl(jobId, filename);
|
|
return apiClient.getImageUrl(jobId, filename);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -237,25 +331,27 @@ function Img2ImgForm() {
|
|
|
setGeneratedImages([...imageUrls]);
|
|
setGeneratedImages([...imageUrls]);
|
|
|
setLoading(false);
|
|
setLoading(false);
|
|
|
isPolling = false;
|
|
isPolling = false;
|
|
|
- } else if (status.status === 'failed') {
|
|
|
|
|
- setError(status.error || 'Generation failed');
|
|
|
|
|
|
|
+ } else if (status.job.status === "failed") {
|
|
|
|
|
+ setError(status.job.error || "Generation failed");
|
|
|
setLoading(false);
|
|
setLoading(false);
|
|
|
isPolling = false;
|
|
isPolling = false;
|
|
|
- } else if (status.status === 'cancelled') {
|
|
|
|
|
- setError('Generation was cancelled');
|
|
|
|
|
|
|
+ } else if (status.job.status === "cancelled") {
|
|
|
|
|
+ setError("Generation was cancelled");
|
|
|
setLoading(false);
|
|
setLoading(false);
|
|
|
isPolling = false;
|
|
isPolling = false;
|
|
|
} else if (attempts < maxAttempts) {
|
|
} else if (attempts < maxAttempts) {
|
|
|
attempts++;
|
|
attempts++;
|
|
|
- setTimeout(poll, 2000);
|
|
|
|
|
|
|
+ timeoutId = setTimeout(poll, 2000);
|
|
|
} else {
|
|
} else {
|
|
|
- setError('Job polling timeout');
|
|
|
|
|
|
|
+ setError("Job polling timeout");
|
|
|
setLoading(false);
|
|
setLoading(false);
|
|
|
isPolling = false;
|
|
isPolling = false;
|
|
|
}
|
|
}
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
if (isPolling) {
|
|
if (isPolling) {
|
|
|
- setError(err instanceof Error ? err.message : 'Failed to check job status');
|
|
|
|
|
|
|
+ setError(
|
|
|
|
|
+ err instanceof Error ? err.message : "Failed to check job status",
|
|
|
|
|
+ );
|
|
|
setLoading(false);
|
|
setLoading(false);
|
|
|
isPolling = false;
|
|
isPolling = false;
|
|
|
}
|
|
}
|
|
@@ -267,6 +363,9 @@ function Img2ImgForm() {
|
|
|
// Return cleanup function
|
|
// Return cleanup function
|
|
|
return () => {
|
|
return () => {
|
|
|
isPolling = false;
|
|
isPolling = false;
|
|
|
|
|
+ if (timeoutId) {
|
|
|
|
|
+ clearTimeout(timeoutId);
|
|
|
|
|
+ }
|
|
|
};
|
|
};
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -274,13 +373,13 @@ function Img2ImgForm() {
|
|
|
e.preventDefault();
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
if (!fullFormData.image) {
|
|
if (!fullFormData.image) {
|
|
|
- setError('Please upload or select an image first');
|
|
|
|
|
|
|
+ setError("Please upload or select an image first");
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Check if image validation passed
|
|
// Check if image validation passed
|
|
|
if (imageValidation && !imageValidation.isValid) {
|
|
if (imageValidation && !imageValidation.isValid) {
|
|
|
- setError('Please fix the image validation errors before generating');
|
|
|
|
|
|
|
+ setError("Please fix the image validation errors before generating");
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -301,11 +400,11 @@ function Img2ImgForm() {
|
|
|
const cleanup = pollJobStatus(jobId);
|
|
const cleanup = pollJobStatus(jobId);
|
|
|
setPollCleanup(() => cleanup);
|
|
setPollCleanup(() => cleanup);
|
|
|
} else {
|
|
} else {
|
|
|
- setError('No job ID returned from server');
|
|
|
|
|
|
|
+ setError("No job ID returned from server");
|
|
|
setLoading(false);
|
|
setLoading(false);
|
|
|
}
|
|
}
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
- setError(err instanceof Error ? err.message : 'Failed to generate image');
|
|
|
|
|
|
|
+ setError(err instanceof Error ? err.message : "Failed to generate image");
|
|
|
setLoading(false);
|
|
setLoading(false);
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
@@ -316,21 +415,24 @@ function Img2ImgForm() {
|
|
|
try {
|
|
try {
|
|
|
await apiClient.cancelJob(jobId);
|
|
await apiClient.cancelJob(jobId);
|
|
|
setLoading(false);
|
|
setLoading(false);
|
|
|
- setError('Generation cancelled');
|
|
|
|
|
|
|
+ setError("Generation cancelled");
|
|
|
// Cleanup polling
|
|
// Cleanup polling
|
|
|
if (pollCleanup) {
|
|
if (pollCleanup) {
|
|
|
pollCleanup();
|
|
pollCleanup();
|
|
|
setPollCleanup(null);
|
|
setPollCleanup(null);
|
|
|
}
|
|
}
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
- console.error('Failed to cancel job:', err);
|
|
|
|
|
|
|
+ console.error("Failed to cancel job:", err);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<AppLayout>
|
|
<AppLayout>
|
|
|
- <Header title="Image to Image" description="Transform images with AI using text prompts" />
|
|
|
|
|
|
|
+ <Header
|
|
|
|
|
+ title="Image to Image"
|
|
|
|
|
+ description="Transform images with AI using text prompts"
|
|
|
|
|
+ />
|
|
|
<div className="container mx-auto p-6">
|
|
<div className="container mx-auto p-6">
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
|
{/* Left Panel - Form */}
|
|
{/* Left Panel - Form */}
|
|
@@ -355,22 +457,27 @@ function Img2ImgForm() {
|
|
|
<Label htmlFor="prompt">Prompt *</Label>
|
|
<Label htmlFor="prompt">Prompt *</Label>
|
|
|
<PromptTextarea
|
|
<PromptTextarea
|
|
|
value={formData.prompt}
|
|
value={formData.prompt}
|
|
|
- onChange={(value) => setFormData({ ...formData, prompt: value })}
|
|
|
|
|
|
|
+ onChange={(value) =>
|
|
|
|
|
+ setFormData({ ...formData, prompt: value })
|
|
|
|
|
+ }
|
|
|
placeholder="Describe the transformation you want..."
|
|
placeholder="Describe the transformation you want..."
|
|
|
rows={3}
|
|
rows={3}
|
|
|
loras={loraModels}
|
|
loras={loraModels}
|
|
|
embeddings={embeddings}
|
|
embeddings={embeddings}
|
|
|
/>
|
|
/>
|
|
|
<p className="text-xs text-muted-foreground">
|
|
<p className="text-xs text-muted-foreground">
|
|
|
- Tip: Use <lora:name:weight> for LoRAs and embedding names directly
|
|
|
|
|
|
|
+ Tip: Use <lora:name:weight> for LoRAs and embedding
|
|
|
|
|
+ names directly
|
|
|
</p>
|
|
</p>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
<div className="space-y-2">
|
|
|
<Label htmlFor="negative_prompt">Negative Prompt</Label>
|
|
<Label htmlFor="negative_prompt">Negative Prompt</Label>
|
|
|
<PromptTextarea
|
|
<PromptTextarea
|
|
|
- value={formData.negative_prompt || ''}
|
|
|
|
|
- onChange={(value) => setFormData({ ...formData, negative_prompt: value })}
|
|
|
|
|
|
|
+ value={formData.negative_prompt || ""}
|
|
|
|
|
+ onChange={(value) =>
|
|
|
|
|
+ setFormData({ ...formData, negative_prompt: value })
|
|
|
|
|
+ }
|
|
|
placeholder="What to avoid..."
|
|
placeholder="What to avoid..."
|
|
|
rows={2}
|
|
rows={2}
|
|
|
loras={loraModels}
|
|
loras={loraModels}
|
|
@@ -493,20 +600,31 @@ function Img2ImgForm() {
|
|
|
</select>
|
|
</select>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
-
|
|
|
|
|
<div className="flex gap-2">
|
|
<div className="flex gap-2">
|
|
|
- <Button type="submit" disabled={loading || !imageData || (imageValidation && !imageValidation.isValid)} className="flex-1">
|
|
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="submit"
|
|
|
|
|
+ disabled={
|
|
|
|
|
+ loading ||
|
|
|
|
|
+ !imageData ||
|
|
|
|
|
+ imageValidation?.isValid === false
|
|
|
|
|
+ }
|
|
|
|
|
+ className="flex-1"
|
|
|
|
|
+ >
|
|
|
{loading ? (
|
|
{loading ? (
|
|
|
<>
|
|
<>
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
Generating...
|
|
Generating...
|
|
|
</>
|
|
</>
|
|
|
) : (
|
|
) : (
|
|
|
- 'Generate'
|
|
|
|
|
|
|
+ "Generate"
|
|
|
)}
|
|
)}
|
|
|
</Button>
|
|
</Button>
|
|
|
{loading && (
|
|
{loading && (
|
|
|
- <Button type="button" variant="destructive" onClick={handleCancel}>
|
|
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ variant="destructive"
|
|
|
|
|
+ onClick={handleCancel}
|
|
|
|
|
+ >
|
|
|
<X className="h-4 w-4" />
|
|
<X className="h-4 w-4" />
|
|
|
Cancel
|
|
Cancel
|
|
|
</Button>
|
|
</Button>
|
|
@@ -530,7 +648,9 @@ function Img2ImgForm() {
|
|
|
{generatedImages.length === 0 ? (
|
|
{generatedImages.length === 0 ? (
|
|
|
<div className="flex h-96 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
|
<div className="flex h-96 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
|
|
<p className="text-muted-foreground">
|
|
<p className="text-muted-foreground">
|
|
|
- {loading ? 'Generating...' : 'Generated images will appear here'}
|
|
|
|
|
|
|
+ {loading
|
|
|
|
|
+ ? "Generating..."
|
|
|
|
|
+ : "Generated images will appear here"}
|
|
|
</p>
|
|
</p>
|
|
|
</div>
|
|
</div>
|
|
|
) : (
|
|
) : (
|
|
@@ -547,14 +667,22 @@ function Img2ImgForm() {
|
|
|
variant="secondary"
|
|
variant="secondary"
|
|
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
|
onClick={() => {
|
|
onClick={() => {
|
|
|
- const authToken = localStorage.getItem('auth_token');
|
|
|
|
|
- const unixUser = localStorage.getItem('unix_user');
|
|
|
|
|
- downloadAuthenticatedImage(image, `img2img-${Date.now()}-${index}.png`, authToken || undefined, unixUser || undefined)
|
|
|
|
|
- .catch(err => {
|
|
|
|
|
- console.error('Failed to download image:', err);
|
|
|
|
|
- // Fallback to regular download if authenticated download fails
|
|
|
|
|
- downloadImage(image, `img2img-${Date.now()}-${index}.png`);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const authToken =
|
|
|
|
|
+ localStorage.getItem("auth_token");
|
|
|
|
|
+ const unixUser = localStorage.getItem("unix_user");
|
|
|
|
|
+ downloadAuthenticatedImage(
|
|
|
|
|
+ image,
|
|
|
|
|
+ `img2img-${Date.now()}-${index}.png`,
|
|
|
|
|
+ authToken || undefined,
|
|
|
|
|
+ unixUser || undefined,
|
|
|
|
|
+ ).catch((err) => {
|
|
|
|
|
+ console.error("Failed to download image:", err);
|
|
|
|
|
+ // Fallback to regular download if authenticated download fails
|
|
|
|
|
+ downloadImage(
|
|
|
|
|
+ image,
|
|
|
|
|
+ `img2img-${Date.now()}-${index}.png`,
|
|
|
|
|
+ );
|
|
|
|
|
+ });
|
|
|
}}
|
|
}}
|
|
|
>
|
|
>
|
|
|
<Download className="h-4 w-4" />
|
|
<Download className="h-4 w-4" />
|
|
@@ -574,4 +702,4 @@ function Img2ImgForm() {
|
|
|
|
|
|
|
|
export default function Img2ImgPage() {
|
|
export default function Img2ImgPage() {
|
|
|
return <Img2ImgForm />;
|
|
return <Img2ImgForm />;
|
|
|
-}
|
|
|
|
|
|
|
+}
|