Real per-step progress bars for local GPU inference

Backend:
- local_diffusion.py: add _make_step_cb() that writes step/total_steps/
  progress into _states on every diffusers callback_on_step_end; wired into
  txt2img, inpaint, img2img with TypeError fallback for older diffusers
- ai_tools.py: GET /api/generate/progress SSE endpoint — streams _states
  as JSON array every 200ms so clients get live denoising step counts

Frontend:
- progress_overlay.js: add connectProgressSSE(pipeType, baseUrl) /
  disconnectProgressSSE() — opens EventSource, maps step/total_steps
  to bar percentage (0→85% during denoising, 85→100 for decode/place)
- text_to_image.js: connect SSE before POST, disconnect on done/error
- selection_actions.js: connect SSE for AI edit / asymmetry operations

Result: for local GPU, progress bar shows "Step 12 / 30" with exact fill;
for remote providers and upscale (no step callbacks), shimmer animates.

https://claude.ai/code/session_01WVDg7amsy1TTtxvpku7bcM
This commit is contained in:
Claude
2026-06-13 16:48:55 +00:00
parent ee94282753
commit 372ab48991
5 changed files with 214 additions and 50 deletions
+33
View File
@@ -4,10 +4,12 @@ All endpoints are under /api prefix.
""" """
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import base64 import base64
import asyncio import asyncio
import json
from app.services.local_inpaint import ( from app.services.local_inpaint import (
lama_inpaint, opencv_inpaint, lama_available, gpu_available, rembg_available, lama_inpaint, opencv_inpaint, lama_available, gpu_available, rembg_available,
@@ -191,6 +193,37 @@ async def inpaint_remote(req: InpaintRemoteRequest):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/generate/progress")
async def generation_progress_stream():
"""
SSE stream of local GPU pipeline inference progress.
Events are JSON arrays of pipeline state objects, emitted every 200 ms.
Each object: {pipeline, state, step, total_steps, progress, message, model_id, …}
Clients open this with EventSource before firing a generation POST,
then close it when the POST resolves.
"""
from app.services.local_diffusion import get_all_model_states
async def event_gen():
try:
while True:
states = get_all_model_states()
yield f"data: {json.dumps(states)}\n\n"
await asyncio.sleep(0.2)
except asyncio.CancelledError:
pass
return StreamingResponse(
event_gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@router.post("/generate/txt2img") @router.post("/generate/txt2img")
async def txt2img(req: Txt2ImgRequest): async def txt2img(req: Txt2ImgRequest):
"""Text-to-image via configured remote provider.""" """Text-to-image via configured remote provider."""
+130 -47
View File
@@ -48,6 +48,24 @@ def get_all_model_states() -> list[dict]:
return list(_states.values()) return list(_states.values())
def _make_step_cb(pipe_type: str, total_steps: int):
"""
Returns a diffusers callback_on_step_end that writes per-step progress
into _states so the SSE /api/generate/progress endpoint can stream it.
Called from a thread executor — _set_state is thread-safe.
"""
def cb(pipe, step_index: int, timestep, callback_kwargs: dict) -> dict:
done = step_index + 1
_set_state(pipe_type,
state="running",
step=done,
total_steps=total_steps,
progress=round(done / total_steps * 85, 1),
message=f"Step {done} / {total_steps}")
return callback_kwargs
return cb
# ── LRU pipeline cache ──────────────────────────────────────────────────────── # ── LRU pipeline cache ────────────────────────────────────────────────────────
class _PipelineCache: class _PipelineCache:
@@ -295,18 +313,35 @@ class LocalDiffusionProvider(RemoteAIProvider):
steps = int(params.get("steps", 30)) steps = int(params.get("steps", 30))
cfg = float(params.get("cfg_scale", 7.5)) cfg = float(params.get("cfg_scale", 7.5))
neg = params.get("negative_prompt", "") or None neg = params.get("negative_prompt", "") or None
step_cb = _make_step_cb("inpaint", steps)
_set_state("inpaint", state="running", step=0, total_steps=steps, progress=0, message="Starting…")
def _run(): def _run():
return pipe( try:
prompt=prompt, return pipe(
negative_prompt=neg, prompt=prompt,
image=img_r, negative_prompt=neg,
mask_image=mask_r, image=img_r,
num_inference_steps=steps, mask_image=mask_r,
guidance_scale=cfg, num_inference_steps=steps,
).images[0].resize(orig, Image.LANCZOS) guidance_scale=cfg,
callback_on_step_end=step_cb,
callback_on_step_end_tensor_inputs=["latents"],
).images[0].resize(orig, Image.LANCZOS)
except TypeError:
return pipe(
prompt=prompt,
negative_prompt=neg,
image=img_r,
mask_image=mask_r,
num_inference_steps=steps,
guidance_scale=cfg,
).images[0].resize(orig, Image.LANCZOS)
return _to_png(await asyncio.get_event_loop().run_in_executor(None, _run)) result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run))
_set_state("inpaint", state="ready", step=None, total_steps=None, progress=100, message="Ready")
return result
async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes: async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes:
pipe = await self._get_pipeline("txt2img") pipe = await self._get_pipeline("txt2img")
@@ -316,34 +351,61 @@ class LocalDiffusionProvider(RemoteAIProvider):
w = min(width, max_dim) // 8 * 8 w = min(width, max_dim) // 8 * 8
h = min(height, max_dim) // 8 * 8 h = min(height, max_dim) // 8 * 8
seed = int(params.get("seed", 0)) seed = int(params.get("seed", 0))
is_flux = spec.family == "flux" is_flux = spec.family == "flux"
steps = 4 if is_flux else int(params.get("steps", 30))
step_cb = _make_step_cb("txt2img", steps)
_set_state("txt2img", state="running", step=0, total_steps=steps, progress=0, message="Starting…")
def _run(): def _run():
import torch import torch
device = self._info.backend device = self._info.backend
gen = torch.Generator(device=device).manual_seed(seed) if seed else None gen = torch.Generator(device=device).manual_seed(seed) if seed else None
if is_flux: try:
return pipe( if is_flux:
prompt=prompt, return pipe(
width=w, height=h, prompt=prompt,
num_inference_steps=4, # FLUX.1-schnell is a 4-step model width=w, height=h,
guidance_scale=0.0, # fully CFG-distilled num_inference_steps=steps,
max_sequence_length=256, guidance_scale=0.0,
generator=gen, max_sequence_length=256,
).images[0] generator=gen,
else: callback_on_step_end=step_cb,
return pipe( callback_on_step_end_tensor_inputs=["latents"],
prompt=prompt, ).images[0]
negative_prompt=params.get("negative_prompt", "") or None, else:
width=w, height=h, return pipe(
num_inference_steps=int(params.get("steps", 30)), prompt=prompt,
guidance_scale=float(params.get("cfg_scale", 7.5)), negative_prompt=params.get("negative_prompt", "") or None,
generator=gen, width=w, height=h,
).images[0] num_inference_steps=steps,
guidance_scale=float(params.get("cfg_scale", 7.5)),
generator=gen,
callback_on_step_end=step_cb,
callback_on_step_end_tensor_inputs=["latents"],
).images[0]
except TypeError:
# Older diffusers without callback_on_step_end
if is_flux:
return pipe(
prompt=prompt, width=w, height=h,
num_inference_steps=steps, guidance_scale=0.0,
max_sequence_length=256, generator=gen,
).images[0]
else:
return pipe(
prompt=prompt,
negative_prompt=params.get("negative_prompt", "") or None,
width=w, height=h,
num_inference_steps=steps,
guidance_scale=float(params.get("cfg_scale", 7.5)),
generator=gen,
).images[0]
return _to_png(await asyncio.get_event_loop().run_in_executor(None, _run)) result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run))
_set_state("txt2img", state="ready", step=None, total_steps=None, progress=100, message="Ready")
return result
async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes: async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes:
pipe = await self._get_pipeline("img2img") pipe = await self._get_pipeline("img2img")
@@ -353,28 +415,49 @@ class LocalDiffusionProvider(RemoteAIProvider):
orig = img.size orig = img.size
img_r = _resize_square(img, spec.native_res) img_r = _resize_square(img, spec.native_res)
is_flux = spec.family == "flux" is_flux = spec.family == "flux"
steps = 4 if is_flux else int(params.get("steps", 30))
step_cb = _make_step_cb("img2img", steps)
_set_state("img2img", state="running", step=0, total_steps=steps, progress=0, message="Starting…")
def _run(): def _run():
if is_flux: try:
result = pipe( if is_flux:
prompt=prompt, result = pipe(
image=img_r, prompt=prompt, image=img_r, strength=strength,
strength=strength, num_inference_steps=steps, guidance_scale=0.0,
num_inference_steps=4, callback_on_step_end=step_cb,
guidance_scale=0.0, callback_on_step_end_tensor_inputs=["latents"],
).images[0] ).images[0]
else: else:
result = pipe( result = pipe(
prompt=prompt, prompt=prompt,
negative_prompt=params.get("negative_prompt", "") or None, negative_prompt=params.get("negative_prompt", "") or None,
image=img_r, image=img_r, strength=strength,
strength=strength, num_inference_steps=steps,
num_inference_steps=int(params.get("steps", 30)), guidance_scale=float(params.get("cfg_scale", 7.5)),
guidance_scale=float(params.get("cfg_scale", 7.5)), callback_on_step_end=step_cb,
).images[0] callback_on_step_end_tensor_inputs=["latents"],
).images[0]
except TypeError:
if is_flux:
result = pipe(
prompt=prompt, image=img_r, strength=strength,
num_inference_steps=steps, guidance_scale=0.0,
).images[0]
else:
result = pipe(
prompt=prompt,
negative_prompt=params.get("negative_prompt", "") or None,
image=img_r, strength=strength,
num_inference_steps=steps,
guidance_scale=float(params.get("cfg_scale", 7.5)),
).images[0]
return result.resize(orig, Image.LANCZOS) return result.resize(orig, Image.LANCZOS)
return _to_png(await asyncio.get_event_loop().run_in_executor(None, _run)) result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run))
_set_state("img2img", state="ready", step=None, total_steps=None, progress=100, message="Ready")
return result
async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes: async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes:
from PIL import ImageDraw from PIL import ImageDraw
+38
View File
@@ -19,6 +19,44 @@ var _shimmerAnim = null;
var _fakeTimer = null; var _fakeTimer = null;
var _currentPct = 0; var _currentPct = 0;
// ── SSE progress connection ───────────────────────────────────────────────────
var _sse = null;
/**
* Open an EventSource to /api/generate/progress and drive the bar with real
* denoising step counts from the local GPU pipeline.
*
* @param {string} pipeType - 'txt2img' | 'inpaint' | 'img2img'
* @param {string} baseUrl - window.API_BASE_URL or ''
*/
export function connectProgressSSE(pipeType, baseUrl) {
disconnectProgressSSE();
try {
var url = (baseUrl || '') + '/api/generate/progress';
_sse = new EventSource(url);
_sse.onmessage = (e) => {
try {
var states = JSON.parse(e.data);
var s = Array.isArray(states)
? states.find(st => st.pipeline === pipeType)
: null;
if (s && s.state === 'running' && s.total_steps) {
var pct = Math.round(s.step / s.total_steps * 85);
updateProgress(pct, s.message || `Step ${s.step} / ${s.total_steps}`);
}
} catch { /* malformed event — ignore */ }
};
_sse.onerror = () => disconnectProgressSSE();
} catch { /* SSE not supported */ }
}
export function disconnectProgressSSE() {
if (_sse) { _sse.close(); _sse = null; }
}
// ── Progress overlay ──────────────────────────────────────────────────────────
export function showProgress(message, estimatedSeconds) { export function showProgress(message, estimatedSeconds) {
hideProgress(); hideProgress();
@@ -12,7 +12,7 @@ import Dialog_class from './../../libs/popup.js';
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
import apiService from './../../services/api.js'; import apiService from './../../services/api.js';
import { getCapabilities } from './../../api/capabilities.js'; import { getCapabilities } from './../../api/capabilities.js';
import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js'; import { showProgress, updateProgress, hideProgress, connectProgressSSE, disconnectProgressSSE } from './../../libs/progress_overlay.js';
var instance = null; var instance = null;
@@ -142,7 +142,8 @@ class Generate_text_to_image_class {
if (this.isProcessing) return; if (this.isProcessing) return;
this.isProcessing = true; this.isProcessing = true;
showProgress('Generating image… this may take a minute on local GPU', estSec || 60); connectProgressSSE('txt2img', window.API_BASE_URL || '');
showProgress('Generating image…', estSec || 60);
try { try {
var result = await apiService.textToImage(params.prompt, { var result = await apiService.textToImage(params.prompt, {
@@ -185,11 +186,13 @@ class Generate_text_to_image_class {
]) ])
); );
} }
disconnectProgressSSE();
hideProgress(); hideProgress();
alertify.success('Image generated!'); alertify.success('Image generated!');
this.isProcessing = false; this.isProcessing = false;
}; };
img.onerror = () => { img.onerror = () => {
disconnectProgressSSE();
hideProgress(); hideProgress();
alertify.error('Failed to load generated image.'); alertify.error('Failed to load generated image.');
this.isProcessing = false; this.isProcessing = false;
@@ -197,6 +200,7 @@ class Generate_text_to_image_class {
img.src = 'data:image/png;base64,' + result.result; img.src = 'data:image/png;base64,' + result.result;
} catch (err) { } catch (err) {
disconnectProgressSSE();
hideProgress(); hideProgress();
alertify.error('Generation failed: ' + (err.message || err)); alertify.error('Generation failed: ' + (err.message || err));
this.isProcessing = false; this.isProcessing = false;
+7 -1
View File
@@ -18,7 +18,7 @@ import app from './../app.js';
import config from './../config.js'; import config from './../config.js';
import Base_layers_class from './../core/base-layers.js'; import Base_layers_class from './../core/base-layers.js';
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
import { showProgress, hideProgress } from './../libs/progress_overlay.js'; import { showProgress, updateProgress, hideProgress, connectProgressSSE, disconnectProgressSSE } from './../libs/progress_overlay.js';
const BASE = window.API_BASE_URL || ''; const BASE = window.API_BASE_URL || '';
@@ -196,6 +196,7 @@ export class SelectionActions {
async _makeAsymmetric() { async _makeAsymmetric() {
if (!this._check()) return; if (!this._check()) return;
this.hide(); this.hide();
connectProgressSSE('inpaint', window.API_BASE_URL || '');
showProgress('AI is adding natural asymmetry…', 60); showProgress('AI is adding natural asymmetry…', 60);
try { try {
var res = await _post('/api/image/ai-edit-region', { var res = await _post('/api/image/ai-edit-region', {
@@ -208,9 +209,11 @@ export class SelectionActions {
}); });
this.tool.updateLayerWithResult(res.result); this.tool.updateLayerWithResult(res.result);
this.tool.clearSelection(); this.tool.clearSelection();
disconnectProgressSSE();
hideProgress(); hideProgress();
alertify.success('Made less symmetrical!'); alertify.success('Made less symmetrical!');
} catch (e) { } catch (e) {
disconnectProgressSSE();
hideProgress(); hideProgress();
alertify.error('AI edit failed: ' + e.message); alertify.error('AI edit failed: ' + e.message);
} }
@@ -219,6 +222,7 @@ export class SelectionActions {
async _aiEditRegion(instruction) { async _aiEditRegion(instruction) {
if (!this._check()) return; if (!this._check()) return;
this.hide(); this.hide();
connectProgressSSE('inpaint', window.API_BASE_URL || '');
showProgress('AI is editing the region…', 60); showProgress('AI is editing the region…', 60);
try { try {
var res = await _post('/api/image/ai-edit-region', { var res = await _post('/api/image/ai-edit-region', {
@@ -230,9 +234,11 @@ export class SelectionActions {
}); });
this.tool.updateLayerWithResult(res.result); this.tool.updateLayerWithResult(res.result);
this.tool.clearSelection(); this.tool.clearSelection();
disconnectProgressSSE();
hideProgress(); hideProgress();
alertify.success('Done!'); alertify.success('Done!');
} catch (e) { } catch (e) {
disconnectProgressSSE();
hideProgress(); hideProgress();
alertify.error('AI edit failed: ' + e.message); alertify.error('AI edit failed: ' + e.message);
} }