Fix backend port handling, generation progress sync, and restore img2img UI
This commit is contained in:
1 parent
992a642b40
commit
c78960c6f4
9 files changed
+259
-257
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import React, { memo, useState, useRef, useCallback } from "react";
|
||||
import { Sparkles, Download, Copy, RefreshCw, Check, Sliders, Trash2 } from "lucide-react";
|
||||
import { Sparkles, Download, Copy, RefreshCw, Check, Sliders, Trash2, ImagePlus } from "lucide-react";
|
||||
import { generateImage, startServer, stopServer, waitForServerReady, getBackendStatus, getGenerationProgress, saveGeneratedOutput, deleteGeneratedOutputs } from "../services/api";
|
||||
|
||||
const GalleryItem = memo(({ img, idx, isSelected, onClick }) => {
|
||||
@@ -55,10 +55,27 @@ function Generator({
|
||||
const [errorMsg, setErrorMsg] = useState(null);
|
||||
const [isDecoding, setIsDecoding] = useState(false);
|
||||
const [selectedGalleryIndexes, setSelectedGalleryIndexes] = useState([]);
|
||||
const [baseImage, setBaseImage] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
const abortControllerRef = useRef(null);
|
||||
const hasRealGenerationStepRef = useRef(false);
|
||||
|
||||
const handleBaseImageSelect = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setErrorMsg("Please choose an image file (PNG, JPG, or WEBP).");
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setBaseImage(reader.result);
|
||||
reader.onerror = () => setErrorMsg("Could not read the selected image file.");
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleClearBaseImage = () => setBaseImage(null);
|
||||
|
||||
// Trigger main image generation process
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return;
|
||||
@@ -299,7 +316,7 @@ function Generator({
|
||||
negativePrompt,
|
||||
constraints,
|
||||
activeModel,
|
||||
null,
|
||||
baseImage,
|
||||
(prog) => setGenerationProgress((prev) => Math.max(prev, prog)),
|
||||
abortControllerRef.current.signal
|
||||
);
|
||||
@@ -320,6 +337,8 @@ function Generator({
|
||||
height: constraints.height,
|
||||
sampler: constraints.sampler,
|
||||
model: activeModel,
|
||||
mode: baseImage ? "img2img" : "txt2img",
|
||||
denoisingStrength: baseImage ? constraints.denoisingStrength : null,
|
||||
duration_sec: result.duration_sec,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
};
|
||||
@@ -588,6 +607,62 @@ function Generator({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">Base Image (Optional)</label>
|
||||
{baseImage ? (
|
||||
<div style={{ display: "flex", gap: "12px", alignItems: "center", padding: "12px", background: "var(--md-sys-color-surface-variant)", borderRadius: "var(--md-shape-corner-medium)", border: "1px solid var(--md-sys-color-outline-variant)" }}>
|
||||
<img src={baseImage} alt="Base" style={{ width: "64px", height: "64px", objectFit: "cover", borderRadius: "8px", flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.85rem" }}>Base image ready</div>
|
||||
<div style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)" }}>
|
||||
The AI will redraw this image guided by your prompt.
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="m3-btn m3-btn-error" style={{ height: "34px", flexShrink: 0 }} onClick={handleClearBaseImage} disabled={isGenerating}>
|
||||
<Trash2 size={14} />
|
||||
<span>Remove</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="import-box" style={{ margin: 0, padding: "16px", cursor: isGenerating ? "not-allowed" : "pointer" }}>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleBaseImageSelect}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<ImagePlus className="import-icon" />
|
||||
<span style={{ fontWeight: 600 }}>Upload a base image</span>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", textAlign: "center" }}>
|
||||
Optional. Generate a new image based on one of your own.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{baseImage && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "0.8rem", marginBottom: "4px" }}>
|
||||
<span>Transformation strength</span>
|
||||
<span>{constraints.denoisingStrength.toFixed(2)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={constraints.denoisingStrength}
|
||||
onChange={(e) => setConstraints((prev) => ({ ...prev, denoisingStrength: parseFloat(e.target.value) }))}
|
||||
disabled={isGenerating}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<div style={{ fontSize: "0.72rem", color: "var(--md-sys-color-outline)", marginTop: "2px" }}>
|
||||
Lower = closer to your image; higher = more creative freedom. 0.7 is a good start.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configuration Status Chips (Material 3 style) */}
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">Active Image Constraints</label>
|
||||
@@ -618,7 +693,7 @@ function Generator({
|
||||
disabled={isGenerating || !prompt.trim() || !activeModel}
|
||||
>
|
||||
<Sparkles size={18} />
|
||||
<span>{!activeModel ? "Select Model to Generate" : "Generate Local Image"}</span>
|
||||
<span>{!activeModel ? "Select Model to Generate" : baseImage ? "Generate from Base Image" : "Generate Local Image"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -528,11 +528,11 @@ export async function generateImage(prompt, negativePrompt, constraints, activeM
|
||||
};
|
||||
};
|
||||
|
||||
// stable-diffusion.cpp server only exposes /v1/images/generations (OpenAI-compat)
|
||||
// Steps, cfg_scale, seed, sample_method are passed in the body and read by the server
|
||||
// txt2img uses /v1/images/generations; img2img uses /sdapi/v1/img2img.
|
||||
const isImg2Img = !!payload.image;
|
||||
let endpoint = `${baseUrl}/v1/images/generations`;
|
||||
|
||||
const genBody = {
|
||||
let genBody = {
|
||||
prompt: payload.prompt,
|
||||
negative_prompt: payload.negative_prompt || "",
|
||||
n: 1,
|
||||
@@ -547,13 +547,30 @@ export async function generateImage(prompt, negativePrompt, constraints, activeM
|
||||
|
||||
// img2img extra fields
|
||||
if (isImg2Img) {
|
||||
genBody.init_images = [payload.image];
|
||||
genBody.denoising_strength = payload.denoising_strength || 0.7;
|
||||
const initBase64 = String(payload.image).replace(/^data:[^;]+;base64,/, "");
|
||||
endpoint = `${baseUrl}/sdapi/v1/img2img`;
|
||||
genBody = {
|
||||
init_images: [initBase64],
|
||||
prompt: payload.prompt,
|
||||
negative_prompt: payload.negative_prompt || "",
|
||||
denoising_strength: payload.denoising_strength || 0.7,
|
||||
steps: payload.steps,
|
||||
cfg_scale: payload.cfg_scale,
|
||||
seed: payload.seed,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
sampler_name: payload.sampler || "euler_a",
|
||||
sample_method: payload.sampler || "euler_a",
|
||||
batch_size: 1,
|
||||
n_iter: 1,
|
||||
send_images: true,
|
||||
save_images: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Attempt real HTTP call
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/images/generations`, {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: signal,
|
||||
|
||||
Reference in new issue
Block a user