Spec covers real upload progress (XHR), server-side progress via SSE for AI tools (Python stderr → bridge.ts → SSE), and a unified ProgressCard component replacing the fake time-based AIProgressBar.
14 KiB
Real Progress Bars for All Tools
Problem
The current progress indicators are either fake (AI tools use time-based guessing) or nonexistent (fast tools show only a spinner icon on the button). Users have no real visibility into what's happening during processing.
Solution
Replace all progress indicators with a unified, honest ProgressCard component backed by real progress data:
- Upload phase: real byte-level tracking via
XMLHttpRequest.upload.onprogress - Processing phase: real server-side progress via SSE for AI tools; honest brief state for fast tools
- Completion: card disappears immediately, download button appears
Architecture
Data Flow
Frontend Backend
| |
|-- Open SSE (jobId) ----------------->| (side channel ready)
|-- POST file + settings + jobId ----->|
| (XMLHttpRequest tracks upload %) |-- parse multipart
| |-- call tool.process()
| | |-- bridge.ts spawns Python
| | | Python emits progress to stderr
| | | bridge.ts parses, calls updateJobProgress()
|<-- SSE: {stage, percent} ------------|
|<-- SSE: {stage, percent} ------------|
| | |-- processing done
|<-- POST response (result) ----------|
|-- Close SSE ----------------------->|
The frontend opens an SSE connection before POSTing the file, using a client-generated jobId. This avoids restructuring the existing synchronous API. The SSE is a parallel side-channel for progress updates. Fast tools skip the SSE step entirely.
Progress Phases
| Phase | Source | Data |
|---|---|---|
| Upload | XMLHttpRequest.upload.onprogress |
Real bytes sent / total bytes |
| Processing (fast tools) | Implied — between upload complete and POST response | No sub-stages, honest "Processing..." |
| Processing (AI tools) | SSE from backend, driven by Python stderr | Real stage labels + granular percentages |
| Complete | POST response received | Card disappears, download button shown |
Frontend
ProgressCard Component
Replaces the existing AIProgressBar. Card-style compact design.
Visual structure:
┌─────────────────────────────────────────────┐
│ [icon] Removing background 45% │
│ Analyzing image · 8s │
│ ████████████████░░░░░░░░░░░░░░░░░░░░░░░░ │
└─────────────────────────────────────────────┘
- Icon: upload arrow during upload, spinner during processing
- Primary label: action name ("Uploading image" / "Removing background")
- Sub-label: current stage + elapsed time
- Percentage: monospace, right-aligned, blue
- Progress bar: thin (4px), rounded, blue fill on dark track
- Container: dark card with subtle border, rounded corners
Props:
interface ProgressCardProps {
/** Whether processing is active */
active: boolean;
/** Current phase */
phase: 'uploading' | 'processing' | 'complete';
/** Primary action label (e.g., "Removing background") */
label: string;
/** Current stage detail (e.g., "Analyzing image") */
stage?: string;
/** Progress percentage 0-100 */
percent: number;
/** Elapsed seconds */
elapsed: number;
}
Location: apps/web/src/components/common/progress-card.tsx
The old AIProgressBar component (apps/web/src/components/common/ai-progress-bar.tsx) will be deleted after all tools are migrated.
useToolProcessor Hook Changes
Rewrite to support real progress tracking.
New return type:
interface ToolProcessorResult {
processFiles: (files: File[], settings: Record<string, unknown>) => void;
processing: boolean;
error: string | null;
downloadUrl: string | null;
originalSize: number | null;
processedSize: number | null;
/** New: real-time progress state */
progress: {
phase: 'idle' | 'uploading' | 'processing' | 'complete';
percent: number;
stage?: string;
elapsed: number;
};
}
Implementation changes:
- Generate a UUID
clientJobIdclient-side before each operation - Replace
fetchwithXMLHttpRequestfor upload progress tracking - For AI tools: open an
EventSourceSSE connection to/api/v1/jobs/{clientJobId}/progressbefore starting the upload - Track elapsed time internally
- Merge upload progress and SSE progress into unified
progressstate
State location: The progress object is local React state within the hook (via useState), not stored in the Zustand useFileStore. Progress is transient per-request and only relevant to the component rendering it. The existing Zustand fields (processing, error, processedUrl, originalSize, processedSize) remain in the store unchanged.
Note: The hook returns progress.phase with 'idle' as a possible value. The ProgressCard component accepts only 'uploading' | 'processing' | 'complete' — the card is simply not rendered when phase is 'idle' (controlled by the active prop).
Determining if a tool is AI-powered: Import the tool category from @stirling-image/shared. If category === 'ai', enable SSE progress. Otherwise, skip SSE.
Tool Settings Components
All ~37 tool settings components that currently show AIProgressBar or just a spinner need to:
- Use the new
progressfield fromuseToolProcessor - Render
<ProgressCard>instead of<AIProgressBar>or inline<Loader2>spinner - Show the progress card in place of the process button while active
The ProgressCard replaces both the process button AND any progress indicator during processing. When complete, the process button reappears along with the download button.
Backend
progress.ts — Extend for Single-File Progress
Add a discriminated union type that encompasses both batch and single-file progress:
interface BaseProgress {
jobId: string;
type: 'batch' | 'single';
}
interface BatchProgress extends BaseProgress {
type: 'batch';
status: 'processing' | 'completed' | 'failed';
totalFiles: number;
completedFiles: number;
failedFiles: number;
errors: Array<{ filename: string; error: string }>;
currentFile?: string;
}
interface SingleFileProgress extends BaseProgress {
type: 'single';
phase: 'processing' | 'complete' | 'failed';
stage?: string; // "Loading model", "Running inference"
percent: number; // 0-100
error?: string;
}
type ProgressEvent = BatchProgress | SingleFileProgress;
Update the listener map type to Map<string, Set<(data: ProgressEvent) => void>>. The existing updateJobProgress function wraps its data with type: 'batch'. Add a new updateSingleFileProgress(progress: Omit<SingleFileProgress, 'type'>) function that adds type: 'single' and pushes through the same listener infrastructure. The existing SSE endpoint /api/v1/jobs/:jobId/progress serves both — the frontend discriminates on the type field.
bridge.ts — Stream Python Stderr
Switch from execFile to spawn for child process management:
function runPythonWithProgress(
script: string,
args: string[],
options: {
jobId?: string;
onProgress?: (percent: number, stage: string) => void;
timeout?: number;
maxBuffer?: number;
}
): Promise<string>
- Use
child_process.spawninstead ofexecFile - Capture stderr line-by-line
- Parse each line as JSON:
{ "progress": number, "stage": string } - Non-JSON stderr lines are collected as error output (backward compatible)
- Forward parsed progress to
onProgresscallback - Stdout is still collected as the final result (JSON output)
- Timeout and cleanup behavior remains the same
Venv fallback handling: The current bridge.ts retries with system python3 if the venv binary throws ENOENT. With spawn, ENOENT surfaces as an 'error' event on the child process (not a thrown exception). The implementation must listen for the 'error' event with code ENOENT and retry with the fallback python3 path, preserving the existing behavior.
Custom AI Route Handlers — Extract clientJobId
The 5 AI tools have custom route handlers (not using createToolRoute). Each needs to:
- Extract
clientJobIdfrom the multipart form data (alongsidefileandsettings) - Pass it through to the AI wrapper function, which forwards it to
bridge.ts
Files to modify:
apps/api/src/routes/tools/remove-background.tsapps/api/src/routes/tools/upscale.tsapps/api/src/routes/tools/blur-faces.tsapps/api/src/routes/tools/erase-object.tsapps/api/src/routes/tools/ocr.ts
Non-AI tools use createToolRoute and do not need SSE progress — no changes needed to tool-factory.ts.
JobId lifecycle: Two IDs exist per request:
clientJobId(from frontend): used only for SSE progress correlation. Sent by frontend in the multipart form. Frontend opens SSE at/api/v1/jobs/{clientJobId}/progressbefore uploading.jobId(server-generated): used for workspace paths and download URLs. Returned in the response as today. These never cross; they serve different purposes.
AI Tool TypeScript Wrappers
Each AI tool wrapper (packages/ai/src/*.ts) needs to:
- Accept optional
jobIdparameter - Pass it to
runPythonWithProgress - Wire up the
onProgresscallback toupdateSingleFileProgress
Python Scripts — Emit Progress
Each Python script emits progress as JSON lines to stderr:
import sys, json
def emit_progress(percent: int, stage: str):
print(json.dumps({"progress": percent, "stage": stage}), file=sys.stderr, flush=True)
Per-script progress granularity:
remove_bg.py
- Replace existing
sys.stderr.write()calls withemit_progress()JSON calls emit_progress(10, "Loading model")— before session creationemit_progress(25, "Model loaded")— after session readyemit_progress(30, "Analyzing image")— beforerembg.remove()emit_progress(80, "Background removed")— after remove completesemit_progress(90, "Applying alpha matting")— if alpha matting enabledemit_progress(95, "Saving result")— before file write
detect_faces.py
emit_progress(10, "Loading face detection model")emit_progress(20, "Model ready")emit_progress(25, "Scanning for faces")emit_progress(50, "Found N faces")— after detectionemit_progress(50 + (i/N)*40, "Blurring face {i+1} of {N}")— per-face progressemit_progress(95, "Saving result")
upscale.py
emit_progress(10, "Loading upscale model")emit_progress(20, "Model ready")- For Real-ESRGAN with tiles:
emit_progress(20 + (tile/total)*70, "Upscaling tile {tile} of {total}") - For Lanczos fallback:
emit_progress(50, "Upscaling with Lanczos") emit_progress(95, "Saving result")
inpaint.py
emit_progress(10, "Loading inpainting model")emit_progress(25, "Analyzing mask")emit_progress(40, "Inpainting region")emit_progress(85, "Refining edges")emit_progress(95, "Saving result")
ocr.py
emit_progress(10, "Loading OCR engine")emit_progress(30, "Analyzing text regions")emit_progress(70, "Extracting text")emit_progress(95, "Formatting results")
Smart Crop (no Python script — uses Sharp)
- Smart crop is implemented in TypeScript/Sharp, not Python. It does not go through
bridge.ts, so it behaves like a fast tool (upload progress only, brief "Processing..." state). No SSE progress needed.
File Changes Summary
New Files
apps/web/src/components/common/progress-card.tsx— new ProgressCard component
Modified Files
apps/web/src/hooks/use-tool-processor.ts— add XHR upload progress + SSE progressapps/api/src/routes/progress.ts— add ProgressEvent union type + updateSingleFileProgress functionapps/api/src/routes/tools/remove-background.ts— extract clientJobId, pass to AI wrapperapps/api/src/routes/tools/upscale.ts— extract clientJobId, pass to AI wrapperapps/api/src/routes/tools/blur-faces.ts— extract clientJobId, pass to AI wrapperapps/api/src/routes/tools/erase-object.ts— extract clientJobId, pass to AI wrapperapps/api/src/routes/tools/ocr.ts— extract clientJobId, pass to AI wrapperpackages/ai/src/bridge.ts— switch to spawn, stream stderr progress, preserve venv fallbackpackages/ai/src/background-removal.ts— accept progressJobId, wire progress callbackpackages/ai/src/face-detection.ts— accept progressJobId, wire progress callbackpackages/ai/src/upscaling.ts— accept progressJobId, wire progress callbackpackages/ai/src/inpainting.ts— accept progressJobId, wire progress callbackpackages/ai/src/ocr.ts— accept progressJobId, wire progress callbackpackages/ai/python/remove_bg.py— replace stderr writes with emit_progress() JSON callspackages/ai/python/detect_faces.py— add emit_progress() callspackages/ai/python/upscale.py— add emit_progress() callspackages/ai/python/inpaint.py— add emit_progress() callspackages/ai/python/ocr.py— add emit_progress() calls- 33 tool settings components in
apps/web/src/components/tools/— swap AIProgressBar/spinner for ProgressCard (4 currently use AIProgressBar, 29 use only a Loader2 spinner)
Deleted Files
apps/web/src/components/common/ai-progress-bar.tsx— replaced by ProgressCard
Edge Cases
- SSE connection fails: Fall back to upload-progress-only mode. Processing phase shows "Processing..." without percentage. System still works.
- Python script doesn't emit progress: Bridge treats it as zero progress updates — frontend shows "Processing..." until POST completes. Backward compatible.
- Very large file upload: Upload phase shows real progress, which is the most useful part for large files.
- User navigates away mid-processing: XHR abort + SSE close. Backend process may continue but workspace cleanup handles orphaned files.
- Multiple rapid requests: Each gets its own jobId, progress is isolated. Previous progress card is replaced.
- Cancellation: No explicit cancel button in v1. Users can navigate away to abort (XHR abort + SSE close). A cancel button for long-running AI operations (60s+ BiRefNet) is a natural follow-up but out of scope for this spec.