Add hosted demo queue and limits

This commit is contained in:
Meta Vens 2026-04-25 06:25:14 +01:00
commit a77612a2bb
13 changed files with 1821 additions and 89 deletions

View file

@ -41,6 +41,45 @@ type LogItem = {
detail: string;
};
type RuntimeConfig = {
hostedDemo: boolean;
limits: {
maxUploadBytes: number;
maxInputDimension: number;
maxOutputDimension: number;
defaultSteps: number;
maxSteps: number;
queueCapacity: number;
heartbeatIntervalSeconds: number;
staleAfterSeconds: number;
};
ui: {
showDeviceControl: boolean;
showStripBackgroundControl: boolean;
};
};
type QueueSummary = {
queueDepth: number;
waitingCount: number;
queueCapacity: number;
hasActiveJob: boolean;
activeStatus: string | null;
};
type JobStateSnapshot = {
jobId: string;
status: string;
error: string | null;
queuePosition: number | null;
queueDepth: number;
waitingCount: number;
queueCapacity: number;
heartbeatIntervalSeconds: number;
staleAfterSeconds: number;
runSummary?: JsonRecord | null;
};
type LatticeSearchInfo = {
candidateCount: number;
completedCandidates: number;
@ -101,6 +140,14 @@ type AppState = {
heatmapImage: ImageAsset | null;
finalOutputImage: ImageAsset | null;
runSummary: JsonRecord | null;
runtimeConfig: RuntimeConfig | null;
queueSummary: QueueSummary | null;
queuePosition: number | null;
queueDepth: number;
waitingCount: number;
queueCapacity: number;
heartbeatIntervalSeconds: number;
staleAfterSeconds: number;
solverStepBudget: number;
lossAxisMax: number | null;
eventLog: LogItem[];
@ -129,6 +176,7 @@ function byId<T extends HTMLElement>(id: string): T {
const fileInput = byId<HTMLInputElement>("fileInput");
const dropzone = byId<HTMLButtonElement>("dropzone");
const dropzoneLabel = byId<HTMLSpanElement>("dropzoneLabel");
const inputCopy = byId<HTMLParagraphElement>("inputCopy");
const pickFileButton = byId<HTMLButtonElement>("pickFileButton");
const runButton = byId<HTMLButtonElement>("runButton");
const statusPanel = byId<HTMLDivElement>("statusPanel");
@ -164,7 +212,12 @@ const phaseXInput = byId<HTMLInputElement>("phaseXInput");
const phaseYInput = byId<HTMLInputElement>("phaseYInput");
const stepsInput = byId<HTMLInputElement>("stepsInput");
const seedInput = byId<HTMLInputElement>("seedInput");
const runControlsCopy = byId<HTMLParagraphElement>("runControlsCopy");
const queueSummaryPanel = byId<HTMLDivElement>("queueSummary");
const cancelJobButton = byId<HTMLButtonElement>("cancelJobButton");
const deviceField = byId<HTMLElement>("deviceField");
const deviceInput = byId<HTMLSelectElement>("deviceInput");
const stripBackgroundField = byId<HTMLElement>("stripBackgroundField");
const stripBackgroundInput = byId<HTMLInputElement>("stripBackgroundInput");
const skipRerankInput = byId<HTMLInputElement>("skipRerankInput");
@ -178,6 +231,8 @@ const paintInputs = {
const imageCache = new Map<string, Promise<HTMLImageElement>>();
const eventTypes = [
"job_state",
"job_canceled",
"queue_state",
"job_failed",
"stage_started",
"source_loaded",
@ -221,6 +276,14 @@ const state: AppState = {
heatmapImage: null,
finalOutputImage: null,
runSummary: null,
runtimeConfig: null,
queueSummary: null,
queuePosition: null,
queueDepth: 0,
waitingCount: 0,
queueCapacity: 0,
heartbeatIntervalSeconds: 10,
staleAfterSeconds: 30,
solverStepBudget: 0,
lossAxisMax: null,
eventLog: [],
@ -234,6 +297,8 @@ const state: AppState = {
};
let currentEventSource: EventSource | null = null;
let currentHeartbeatTimer: number | null = null;
let currentQueuePollTimer: number | null = null;
let painting = false;
let panning = false;
let activePanPointerId: number | null = null;
@ -295,6 +360,60 @@ function renderEventLog(): void {
}
}
function renderQueueSummary(): void {
queueSummaryPanel.innerHTML = "";
if (hasActiveJob()) {
const cards: Array<[string, string]> = [
["Status", formatStatusBadge(state.status)],
[
"Line",
state.queuePosition === null || state.queueDepth <= 0
? "active now"
: `${state.queuePosition} / ${Math.max(state.queueDepth, state.queuePosition)}`,
],
["Waiting", `${state.waitingCount} / ${state.queueCapacity || (state.runtimeConfig?.limits.queueCapacity ?? 0)}`],
];
for (const [label, value] of cards) {
const node = document.createElement("div");
node.className = "info-card summary-card";
node.innerHTML = `<strong>${label}</strong><span>${value}</span>`;
queueSummaryPanel.appendChild(node);
}
const note = document.createElement("p");
note.className = "muted";
note.textContent =
state.status === "queued"
? "Heartbeats keep this slot alive while you wait."
: "This run owns the worker. Leave the page and the queue slot gets reclaimed.";
queueSummaryPanel.appendChild(note);
cancelJobButton.hidden = false;
return;
}
cancelJobButton.hidden = true;
if (!state.queueSummary) {
queueSummaryPanel.innerHTML = `<p class="muted">Checking queue status.</p>`;
return;
}
const idleCards: Array<[string, string]> = [
["Worker", state.queueSummary.hasActiveJob ? "busy" : "idle"],
["Queue", String(state.queueSummary.queueDepth)],
["Waiting", `${state.queueSummary.waitingCount} / ${state.queueSummary.queueCapacity}`],
];
for (const [label, value] of idleCards) {
const node = document.createElement("div");
node.className = "info-card summary-card";
node.innerHTML = `<strong>${label}</strong><span>${value}</span>`;
queueSummaryPanel.appendChild(node);
}
const note = document.createElement("p");
note.className = "muted";
note.textContent = state.queueSummary.hasActiveJob
? "One run is chewing right now. New jobs line up behind it."
: "Nobody is using the machine. A small miracle.";
queueSummaryPanel.appendChild(note);
}
function formatStatusBadge(status: string): string {
return status.replaceAll("_", " ").toUpperCase();
}
@ -313,6 +432,62 @@ function setStage(stageKey: string, label: string, detail: string): void {
statusText.textContent = detail;
}
function hasActiveJob(): boolean {
return state.jobId !== null && ["queued", "running", "waiting"].includes(state.status);
}
function formatByteLimit(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${formatNumber(bytes / (1024 * 1024), 1)} MiB`;
}
if (bytes >= 1024) {
return `${Math.round(bytes / 1024)} KiB`;
}
return `${bytes} B`;
}
function applyQueueState(payload: Partial<JobStateSnapshot>): void {
if (typeof payload.queuePosition === "number") {
state.queuePosition = payload.queuePosition;
} else if (payload.queuePosition === null) {
state.queuePosition = null;
}
if (typeof payload.queueDepth === "number") {
state.queueDepth = payload.queueDepth;
}
if (typeof payload.waitingCount === "number") {
state.waitingCount = payload.waitingCount;
}
if (typeof payload.queueCapacity === "number") {
state.queueCapacity = payload.queueCapacity;
}
if (typeof payload.heartbeatIntervalSeconds === "number") {
state.heartbeatIntervalSeconds = payload.heartbeatIntervalSeconds;
}
if (typeof payload.staleAfterSeconds === "number") {
state.staleAfterSeconds = payload.staleAfterSeconds;
}
}
function applyRuntimeConfig(config: RuntimeConfig): void {
state.runtimeConfig = config;
deviceField.hidden = !config.ui.showDeviceControl;
stripBackgroundField.hidden = !config.ui.showStripBackgroundControl;
stepsInput.max = String(config.limits.maxSteps);
if (stepsInput.value.trim() === "" || Number(stepsInput.value) > config.limits.maxSteps || Number(stepsInput.value) === 48) {
stepsInput.value = String(config.limits.defaultSteps);
}
if (config.hostedDemo) {
deviceInput.value = "cpu";
stripBackgroundInput.checked = false;
inputCopy.textContent = `PNG up to ${formatByteLimit(config.limits.maxUploadBytes)}, max ${config.limits.maxInputDimension}x${config.limits.maxInputDimension}.`;
runControlsCopy.textContent = `Hosted demo mode. CPU only, output max ${config.limits.maxOutputDimension}px, steps clamp at ${config.limits.maxSteps}.`;
} else {
inputCopy.textContent = "Start with the ugly input.";
runControlsCopy.textContent = "Pin what you know. Search what you do not.";
}
}
function resetRunArtifacts(options: { preserveSourceImage?: boolean } = {}): void {
const preserveSourceImage = options.preserveSourceImage ?? false;
state.jobId = null;
@ -332,6 +507,9 @@ function resetRunArtifacts(options: { preserveSourceImage?: boolean } = {}): voi
state.heatmapImage = null;
state.finalOutputImage = null;
state.runSummary = null;
state.queuePosition = null;
state.queueDepth = 0;
state.waitingCount = 0;
state.solverStepBudget = 0;
state.lossAxisMax = null;
state.eventLog = [];
@ -568,7 +746,17 @@ function renderInference(): void {
function renderStatusMetrics(): void {
const items: StatusMetricItem[] = [];
const frame = getSelectedFrame();
if (state.stageKey === "inference") {
if (state.stageKey === "queued" || state.status === "queued" || state.status === "waiting") {
items.push({
label: "Line",
value:
state.queuePosition === null || state.queueDepth <= 0
? "pending"
: `${state.queuePosition} / ${Math.max(state.queueDepth, state.queuePosition)}`,
});
items.push({ label: "Waiting", value: `${state.waitingCount} / ${state.queueCapacity || (state.runtimeConfig?.limits.queueCapacity ?? 0)}` });
items.push({ label: "Heartbeat", value: `${state.heartbeatIntervalSeconds}s` });
} else if (state.stageKey === "inference") {
if (state.latticeSearch) {
items.push({ label: "Sizes", value: `${state.latticeSearch.completedCandidates} / ${state.latticeSearch.candidateCount}` });
items.push({ label: "Phases", value: String(state.latticeSearch.phaseSampleCount) });
@ -1188,22 +1376,154 @@ function endPan(pointerId: number | null = null): void {
syncEditorCursorState();
}
async function loadRuntimeConfig(): Promise<void> {
const response = await fetch("/api/config");
if (!response.ok) {
throw new Error(await response.text());
}
applyRuntimeConfig((await response.json()) as RuntimeConfig);
}
async function refreshQueueSummary(): Promise<void> {
if (hasActiveJob()) {
return;
}
const response = await fetch("/api/queue");
if (!response.ok) {
throw new Error(await response.text());
}
state.queueSummary = (await response.json()) as QueueSummary;
renderQueueSummary();
}
function stopQueuePolling(): void {
if (currentQueuePollTimer !== null) {
window.clearInterval(currentQueuePollTimer);
currentQueuePollTimer = null;
}
}
function syncQueuePolling(): void {
if (hasActiveJob()) {
stopQueuePolling();
return;
}
if (currentQueuePollTimer !== null) {
return;
}
currentQueuePollTimer = window.setInterval(() => {
void refreshQueueSummary().catch(() => undefined);
}, 10000);
}
function stopHeartbeat(): void {
if (currentHeartbeatTimer !== null) {
window.clearInterval(currentHeartbeatTimer);
currentHeartbeatTimer = null;
}
}
function applyJobSnapshot(snapshot: Partial<JobStateSnapshot>): void {
applyQueueState(snapshot);
if (typeof snapshot.status === "string") {
setJobState(snapshot.status);
}
if (snapshot.runSummary && typeof snapshot.runSummary === "object") {
state.runSummary = snapshot.runSummary;
}
}
async function sendHeartbeat(): Promise<void> {
if (!state.jobId || !hasActiveJob()) {
stopHeartbeat();
return;
}
const response = await fetch(`/api/jobs/${state.jobId}/heartbeat`, { method: "POST" });
if (response.status === 404) {
stopHeartbeat();
return;
}
if (!response.ok) {
return;
}
applyJobSnapshot((await response.json()) as JobStateSnapshot);
renderQueueSummary();
}
function syncHeartbeat(): void {
if (!hasActiveJob()) {
stopHeartbeat();
return;
}
const intervalMs = Math.max(1000, state.heartbeatIntervalSeconds * 1000);
if (currentHeartbeatTimer !== null) {
window.clearInterval(currentHeartbeatTimer);
}
currentHeartbeatTimer = window.setInterval(() => {
void sendHeartbeat().catch(() => undefined);
}, intervalMs);
}
function extractErrorDetail(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object") {
const detail = (payload as JsonRecord).detail;
if (typeof detail === "string" && detail.trim()) {
return detail;
}
}
return fallback;
}
async function cancelCurrentJob(options: { keepalive?: boolean; silent?: boolean } = {}): Promise<void> {
if (!state.jobId || !hasActiveJob()) {
return;
}
const request = fetch(`/api/jobs/${state.jobId}`, {
method: "DELETE",
keepalive: options.keepalive ?? false,
});
if (options.keepalive) {
return;
}
try {
const response = await request;
if (!response.ok) {
throw new Error(await response.text());
}
const payload = (await response.json()) as JobStateSnapshot;
applyJobSnapshot(payload);
if (!options.silent) {
setStage("queued", "Cancel requested", "Asked the worker to drop this job and free the queue slot.");
}
renderQueueSummary();
} catch (error) {
if (!options.silent) {
addLog("Cancel", error instanceof Error ? error.message : "Failed to cancel the current job.");
}
}
}
function buildFormData(): FormData {
if (!state.file) {
throw new Error("No file selected");
}
const data = new FormData();
data.set("image", state.file);
const runtimeConfig = state.runtimeConfig;
const maxSteps = runtimeConfig?.limits.maxSteps ?? 48;
const defaultSteps = runtimeConfig?.limits.defaultSteps ?? 48;
const requestedSteps = parseOptionalInteger(stepsInput);
const normalizedSteps = Math.max(0, Math.min(maxSteps, requestedSteps ?? defaultSteps));
const values: Array<[string, string | null]> = [
["target_size", parseOptionalInteger(targetSizeInput)?.toString() ?? null],
["target_width", parseOptionalInteger(targetWidthInput)?.toString() ?? null],
["target_height", parseOptionalInteger(targetHeightInput)?.toString() ?? null],
["phase_x", parseOptionalFloat(phaseXInput)?.toString() ?? null],
["phase_y", parseOptionalFloat(phaseYInput)?.toString() ?? null],
["steps", String(parseOptionalInteger(stepsInput) ?? 48)],
["steps", String(normalizedSteps)],
["seed", String(parseOptionalInteger(seedInput) ?? 7)],
["device", deviceInput.value],
["strip_background", stripBackgroundInput.checked ? "true" : "false"],
["device", runtimeConfig?.hostedDemo ? "cpu" : deviceInput.value],
["strip_background", runtimeConfig?.hostedDemo ? "false" : stripBackgroundInput.checked ? "true" : "false"],
["skip_phase_rerank", skipRerankInput.checked ? "true" : "false"],
];
for (const [key, value] of values) {
@ -1215,6 +1535,7 @@ function buildFormData(): FormData {
}
async function renderEverything(): Promise<void> {
renderQueueSummary();
renderInference();
renderStatusMetrics();
renderLossChart();
@ -1225,14 +1546,42 @@ async function renderEverything(): Promise<void> {
async function handleEvent(eventName: string, payload: JsonRecord): Promise<void> {
switch (eventName) {
case "job_state":
setJobState(String(payload.status ?? "working"));
applyJobSnapshot(payload as Partial<JobStateSnapshot>);
if (payload.cancelRequested === true && state.status === "running") {
setStage(state.stageKey, state.stageLabel, String(payload.message ?? "Cancel requested. Waiting for the worker to drop the job."));
}
syncHeartbeat();
syncQueuePolling();
break;
case "queue_state":
applyQueueState(payload as Partial<JobStateSnapshot>);
state.queueSummary = {
queueDepth: state.queueDepth,
waitingCount: state.waitingCount,
queueCapacity: state.queueCapacity,
hasActiveJob: state.queueDepth > 0,
activeStatus: state.status,
};
break;
case "job_canceled":
stopHeartbeat();
setJobState("canceled");
setStage("canceled", "Job canceled", String(payload.message ?? "The queued or running job was canceled."));
addLog("Canceled", String(payload.message ?? "Canceled the job."));
runButton.disabled = false;
currentEventSource?.close();
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
case "job_failed":
stopHeartbeat();
setJobState("failed");
setStage("failed", "Run failed", String(payload.message ?? "The GUI run fell over."));
addLog("Failure", String(payload.message ?? "The run failed."));
runButton.disabled = false;
currentEventSource?.close();
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
case "stage_started":
setJobState("running");
@ -1439,12 +1788,15 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
case "pipeline_completed":
state.finalOutputImage = payload.outputImage as ImageAsset;
state.runSummary = (payload.runSummary as JsonRecord | null) ?? null;
stopHeartbeat();
setJobState("completed");
setStage("completed", "Run complete", "The output is ready. If the machine still did something stupid, fix it pixel by pixel.");
addLog("Done", "Full pipeline completed.");
runButton.disabled = false;
currentEventSource?.close();
await loadEditorAsset(state.finalOutputImage);
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
default:
break;
@ -1464,9 +1816,9 @@ function connectEventStream(jobId: string): void {
});
}
stream.onerror = () => {
if (state.status === "running") {
if (hasActiveJob()) {
setJobState("waiting");
setStage(state.stageKey, state.stageLabel, "Event stream hiccup. If it stays this way, rerun it.");
setStage(state.stageKey, state.stageLabel, "Event stream hiccup. Heartbeats are still trying to keep the queue slot alive.");
}
};
}
@ -1480,9 +1832,12 @@ async function startRun(): Promise<void> {
resetRunArtifacts({ preserveSourceImage: true });
renderEventLog();
runButton.disabled = true;
state.solverStepBudget = parseOptionalInteger(stepsInput) ?? 48;
const runtimeConfig = state.runtimeConfig;
const maxSteps = runtimeConfig?.limits.maxSteps ?? 48;
const defaultSteps = runtimeConfig?.limits.defaultSteps ?? 48;
state.solverStepBudget = Math.max(0, Math.min(maxSteps, parseOptionalInteger(stepsInput) ?? defaultSteps));
setJobState("queued");
setStage("queued", "Queued", "Starting the pipeline. This should wake up almost immediately.");
setStage("queued", "Queued", "Submitting the job and trying not to trip over our own shoelaces.");
addLog("Queued", `Starting ${state.file.name}.`);
await renderEverything();
try {
@ -1491,16 +1846,28 @@ async function startRun(): Promise<void> {
body: buildFormData(),
});
if (!response.ok) {
throw new Error(await response.text());
const errorPayload = await response.json().catch(async () => ({ detail: await response.text() }));
throw new Error(extractErrorDetail(errorPayload, "Failed to start GUI job."));
}
const payload = (await response.json()) as { jobId: string };
const payload = (await response.json()) as JobStateSnapshot;
state.jobId = payload.jobId;
applyJobSnapshot(payload);
state.queueSummary = {
queueDepth: payload.queueDepth,
waitingCount: payload.waitingCount,
queueCapacity: payload.queueCapacity,
hasActiveJob: payload.queueDepth > 0,
activeStatus: payload.status,
};
connectEventStream(payload.jobId);
syncHeartbeat();
syncQueuePolling();
} catch (error) {
runButton.disabled = false;
setJobState("failed");
setStage("failed", "Run failed", error instanceof Error ? error.message : "Failed to start GUI job.");
addLog("Failure", error instanceof Error ? error.message : "Failed to start GUI job.");
syncQueuePolling();
}
}
@ -1555,6 +1922,17 @@ runButton.addEventListener("click", () => {
void startRun();
});
cancelJobButton.addEventListener("click", () => {
void cancelCurrentJob();
});
stepsInput.addEventListener("change", () => {
const maxSteps = state.runtimeConfig?.limits.maxSteps;
if (typeof maxSteps === "number" && Number(stepsInput.value) > maxSteps) {
stepsInput.value = String(maxSteps);
}
});
inspectZoomInput.addEventListener("change", () => {
const value = Number(inspectZoomInput.value);
state.viewerInspectZoom = Number.isFinite(value) && value > 0 ? value : 1;
@ -1605,6 +1983,10 @@ window.addEventListener("blur", () => {
syncEditorCursorState();
});
window.addEventListener("pagehide", () => {
void cancelCurrentJob({ keepalive: true, silent: true });
});
for (const [panel, canvas] of [
["left", leftCanvas],
["right", rightCanvas],
@ -1744,4 +2126,20 @@ syncEditorCursorState();
syncViewerInspectCursor();
zoomValue.textContent = `${state.zoom}x`;
renderEventLog();
void renderEverything();
async function initializeApp(): Promise<void> {
try {
await loadRuntimeConfig();
} catch (error) {
addLog("Config", error instanceof Error ? error.message : "Failed to load runtime config.");
}
try {
await refreshQueueSummary();
} catch (error) {
addLog("Queue", error instanceof Error ? error.message : "Failed to load queue summary.");
}
syncQueuePolling();
await renderEverything();
}
void initializeApp();

View file

@ -1,9 +1,14 @@
import base64
import contextlib
import io
import json
import os
import shutil
import tempfile
import threading
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@ -13,7 +18,8 @@ from PIL import Image, ImageDraw
from .diagnostics import _displacement_preview_rgba
from .inference import inference_to_json
from .pipeline import run_pipeline_rgba
from .observe import PipelineCancelled
from .pipeline import _resolve_requested_target_dims, run_pipeline_rgba
from .types import InferenceResult, PaletteResult
@ -122,6 +128,97 @@ def _decode_rgba(data: bytes) -> np.ndarray:
return np.asarray(Image.open(io.BytesIO(data)).convert("RGBA"), dtype=np.float32) / 255.0
def _env_flag(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw)
except ValueError:
return default
@dataclass(slots=True)
class HostedDemoConfig:
hosted_demo: bool
max_upload_bytes: int
max_input_dimension: int
max_output_dimension: int
default_steps: int
max_steps: int
queue_capacity: int
heartbeat_interval_seconds: int
stale_after_seconds: int
phase_field_preview_stride: int
spool_dir: Path
@classmethod
def from_env(cls) -> "HostedDemoConfig":
hosted_demo = _env_flag("REPIXELIZER_HOSTED_DEMO", False)
defaults = {
"max_upload_bytes": 1_048_576 if hosted_demo else 16 * 1_048_576,
"max_input_dimension": 2048 if hosted_demo else 4096,
"max_output_dimension": 256 if hosted_demo else 1024,
"default_steps": 32 if hosted_demo else 48,
"max_steps": 48 if hosted_demo else 256,
"queue_capacity": 10 if hosted_demo else 32,
"heartbeat_interval_seconds": 10,
"stale_after_seconds": 30,
"phase_field_preview_stride": 4,
}
spool_dir_raw = os.getenv("REPIXELIZER_SPOOL_DIR")
if spool_dir_raw:
spool_dir = Path(spool_dir_raw).expanduser()
else:
spool_dir = Path(tempfile.gettempdir()) / "repixelizer-gui-spool"
return cls(
hosted_demo=hosted_demo,
max_upload_bytes=max(1, _env_int("REPIXELIZER_MAX_UPLOAD_BYTES", defaults["max_upload_bytes"])),
max_input_dimension=max(1, _env_int("REPIXELIZER_MAX_INPUT_DIMENSION", defaults["max_input_dimension"])),
max_output_dimension=max(1, _env_int("REPIXELIZER_MAX_OUTPUT_DIMENSION", defaults["max_output_dimension"])),
default_steps=max(0, _env_int("REPIXELIZER_DEFAULT_STEPS", defaults["default_steps"])),
max_steps=max(0, _env_int("REPIXELIZER_MAX_STEPS", defaults["max_steps"])),
queue_capacity=max(1, _env_int("REPIXELIZER_QUEUE_CAPACITY", defaults["queue_capacity"])),
heartbeat_interval_seconds=max(
1, _env_int("REPIXELIZER_HEARTBEAT_INTERVAL_SECONDS", defaults["heartbeat_interval_seconds"])
),
stale_after_seconds=max(2, _env_int("REPIXELIZER_STALE_AFTER_SECONDS", defaults["stale_after_seconds"])),
phase_field_preview_stride=max(
1, _env_int("REPIXELIZER_PHASE_FIELD_PREVIEW_STRIDE", defaults["phase_field_preview_stride"])
),
spool_dir=spool_dir,
)
def ui_flags(self) -> dict[str, bool]:
return {
"showDeviceControl": not self.hosted_demo,
"showStripBackgroundControl": not self.hosted_demo,
}
def public_payload(self) -> dict[str, Any]:
return {
"hostedDemo": self.hosted_demo,
"limits": {
"maxUploadBytes": self.max_upload_bytes,
"maxInputDimension": self.max_input_dimension,
"maxOutputDimension": self.max_output_dimension,
"defaultSteps": self.default_steps,
"maxSteps": self.max_steps,
"queueCapacity": self.queue_capacity,
"heartbeatIntervalSeconds": self.heartbeat_interval_seconds,
"staleAfterSeconds": self.stale_after_seconds,
},
"ui": self.ui_flags(),
}
def _palette_to_json(palette_result: PaletteResult | None) -> dict[str, Any] | None:
if palette_result is None:
return None
@ -135,16 +232,21 @@ def _palette_to_json(palette_result: PaletteResult | None) -> dict[str, Any] | N
class GuiJob:
job_id: str
filename: str
options: dict[str, Any]
spool_path: Path
created_at: float = field(default_factory=time.time)
last_heartbeat_at: float = field(default_factory=time.time)
phase_field_preview_stride: int = 4
phase_field_include_snapshot: bool = True
status: str = "queued"
error: str | None = None
cancel_reason: str | None = None
events: list[dict[str, Any]] = field(default_factory=list)
current_source_rgba: np.ndarray | None = None
current_inference: InferenceResult | None = None
run_summary: dict[str, Any] | None = None
_condition: threading.Condition = field(default_factory=threading.Condition, repr=False)
_cancel_event: threading.Event = field(default_factory=threading.Event, repr=False)
def observe(self, event: str, payload: dict[str, Any]) -> None:
serialized = self._serialize_event(event, payload)
@ -165,6 +267,7 @@ class GuiJob:
def mark_running(self) -> None:
self.status = "running"
self.touch_heartbeat()
self.publish("job_state", {"status": self.status})
def mark_completed(self) -> None:
@ -176,9 +279,35 @@ class GuiJob:
self.error = message
self.publish("job_failed", {"status": self.status, "message": message})
def mark_canceled(self, message: str) -> None:
self.status = "canceled"
self.error = message
self.cancel_reason = message
self.publish("job_canceled", {"status": self.status, "message": message})
self.publish("job_state", {"status": self.status, "message": message})
def touch_heartbeat(self) -> None:
self.last_heartbeat_at = time.time()
def request_cancel(self, reason: str) -> None:
self.cancel_reason = reason
self._cancel_event.set()
@property
def cancellation_message(self) -> str:
return self.cancel_reason or "Pipeline canceled."
def check_cancelled(self) -> bool:
return self._cancel_event.is_set()
def is_stale(self, *, now: float, stale_after_seconds: int) -> bool:
if self.status not in {"queued", "running"}:
return False
return (now - self.last_heartbeat_at) >= float(stale_after_seconds)
def wait_for_events(self, index: int, timeout: float = 0.5) -> list[dict[str, Any]]:
with self._condition:
if index >= len(self.events) and self.status not in {"completed", "failed"}:
if index >= len(self.events) and self.status not in {"completed", "failed", "canceled"}:
self._condition.wait(timeout=timeout)
return self.events[index:]
@ -323,6 +452,7 @@ class GuiJob:
def _job_state_payload(job: GuiJob) -> dict[str, Any]:
return {
"jobId": job.job_id,
"filename": job.filename,
"status": job.status,
"error": job.error,
"createdAt": job.created_at,
@ -331,30 +461,387 @@ def _job_state_payload(job: GuiJob) -> dict[str, Any]:
}
def _run_job(job: GuiJob, source_rgba: np.ndarray, options: dict[str, Any]) -> None:
def _inspect_upload_image(raw: bytes) -> tuple[int, int]:
with Image.open(io.BytesIO(raw)) as image:
width, height = image.size
return int(width), int(height)
def _normalize_optional_positive_int(name: str, value: int | None) -> int | None:
if value is None:
return None
resolved = int(value)
if resolved <= 0:
raise ValueError(f"{name} must be greater than zero.")
return resolved
def _normalize_job_options(
config: HostedDemoConfig,
*,
source_width: int,
source_height: int,
target_size: int | None,
target_width: int | None,
target_height: int | None,
phase_x: float | None,
phase_y: float | None,
steps: int | None,
seed: int,
device: str,
strip_background: bool,
skip_phase_rerank: bool,
) -> dict[str, Any]:
normalized_target_size = _normalize_optional_positive_int("target_size", target_size)
normalized_target_width = _normalize_optional_positive_int("target_width", target_width)
normalized_target_height = _normalize_optional_positive_int("target_height", target_height)
try:
job.mark_running()
run_pipeline_rgba(
source_rgba,
target_size=options["target_size"],
target_width=options["target_width"],
target_height=options["target_height"],
phase_x=options["phase_x"],
phase_y=options["phase_y"],
palette_mode="off",
seed=options["seed"],
steps=options["steps"],
device=options["device"],
strip_background=options["strip_background"],
enable_phase_rerank=not options["skip_phase_rerank"],
observer=job.observe,
fixed_dims = _resolve_requested_target_dims(
source_width=source_width,
source_height=source_height,
target_size=normalized_target_size,
target_width=normalized_target_width,
target_height=normalized_target_height,
phase_x=phase_x,
phase_y=phase_y,
)
except Exception as exc: # pragma: no cover - exercised through manual GUI runs
job.mark_failed(str(exc))
return
except ValueError as exc:
raise ValueError(str(exc)) from exc
if fixed_dims is not None and max(fixed_dims) > config.max_output_dimension:
raise ValueError(
f"Requested output {fixed_dims[0]}x{fixed_dims[1]} exceeds the maximum hosted output dimension of {config.max_output_dimension}."
)
requested_steps = config.default_steps if steps is None else max(0, int(steps))
normalized_steps = min(requested_steps, config.max_steps)
normalized_device = "cpu" if config.hosted_demo else (device.strip() if device.strip() else "auto")
normalized_strip_background = False if config.hosted_demo else bool(strip_background)
effective_target_size = normalized_target_size
if fixed_dims is None:
effective_target_size = (
config.max_output_dimension
if effective_target_size is None
else min(effective_target_size, config.max_output_dimension)
)
return {
"target_size": effective_target_size,
"target_width": normalized_target_width,
"target_height": normalized_target_height,
"phase_x": phase_x,
"phase_y": phase_y,
"steps": normalized_steps,
"seed": int(seed),
"device": normalized_device,
"strip_background": normalized_strip_background,
"skip_phase_rerank": bool(skip_phase_rerank),
}
def _validate_upload_request(
config: HostedDemoConfig,
*,
raw: bytes,
filename: str,
target_size: int | None,
target_width: int | None,
target_height: int | None,
phase_x: float | None,
phase_y: float | None,
steps: int | None,
seed: int,
device: str,
strip_background: bool,
skip_phase_rerank: bool,
) -> tuple[dict[str, Any], int, int]:
if len(raw) > config.max_upload_bytes:
raise ValueError(f"Upload is too large. Limit is {config.max_upload_bytes // 1024} KiB for the hosted demo.")
try:
source_width, source_height = _inspect_upload_image(raw)
except Exception as exc:
raise ValueError(f"{filename or 'Upload'} is not a readable PNG or image file.") from exc
if source_width > config.max_input_dimension or source_height > config.max_input_dimension:
raise ValueError(
f"Input image is {source_width}x{source_height}. Limit is {config.max_input_dimension}px on each side."
)
options = _normalize_job_options(
config,
source_width=source_width,
source_height=source_height,
target_size=target_size,
target_width=target_width,
target_height=target_height,
phase_x=phase_x,
phase_y=phase_y,
steps=steps,
seed=seed,
device=device,
strip_background=strip_background,
skip_phase_rerank=skip_phase_rerank,
)
return options, source_width, source_height
def _execute_job(job: GuiJob) -> None:
if job.check_cancelled():
raise PipelineCancelled(job.cancellation_message)
source_rgba = _decode_rgba(job.spool_path.read_bytes())
job.mark_running()
run_pipeline_rgba(
source_rgba,
target_size=job.options["target_size"],
target_width=job.options["target_width"],
target_height=job.options["target_height"],
phase_x=job.options["phase_x"],
phase_y=job.options["phase_y"],
palette_mode="off",
seed=job.options["seed"],
steps=job.options["steps"],
device=job.options["device"],
strip_background=job.options["strip_background"],
enable_phase_rerank=not job.options["skip_phase_rerank"],
observer=job.observe,
)
job.mark_completed()
class QueueFullError(RuntimeError):
"""Raised when the hosted demo queue has no remaining waiting slots."""
class GuiJobManager:
def __init__(self, config: HostedDemoConfig) -> None:
self.config = config
self.jobs: dict[str, GuiJob] = {}
self._queued_job_ids: deque[str] = deque()
self._active_job_id: str | None = None
self._condition = threading.Condition()
self._stop_event = threading.Event()
self._worker_thread: threading.Thread | None = None
self._monitor_thread: threading.Thread | None = None
self._started = False
def start(self) -> None:
with self._condition:
if self._started:
return
self._started = True
self._stop_event.clear()
self.config.spool_dir.mkdir(parents=True, exist_ok=True)
self._purge_spool_dir()
self._worker_thread = threading.Thread(target=self._worker_loop, name="repixelizer-gui-worker", daemon=True)
self._monitor_thread = threading.Thread(target=self._monitor_loop, name="repixelizer-gui-monitor", daemon=True)
self._worker_thread.start()
self._monitor_thread.start()
def stop(self) -> None:
with self._condition:
if not self._started:
return
self._stop_event.set()
self._condition.notify_all()
for thread in (self._worker_thread, self._monitor_thread):
if thread is not None:
thread.join(timeout=2.0)
with self._condition:
self._started = False
self._purge_spool_dir()
def submit_job(self, *, filename: str, raw: bytes, options: dict[str, Any]) -> GuiJob:
self.start()
suffix = Path(filename or "input.png").suffix or ".png"
job = GuiJob(
job_id=str(uuid.uuid4()),
filename=filename or "input.png",
options=options,
spool_path=self.config.spool_dir / f"{uuid.uuid4().hex}{suffix}",
phase_field_preview_stride=self.config.phase_field_preview_stride,
)
job.spool_path.write_bytes(raw)
with self._condition:
if len(self._queued_job_ids) >= self.config.queue_capacity:
self._cleanup_spool_file(job)
raise QueueFullError(f"Queue is full. {self.config.queue_capacity} waiting jobs are already lined up.")
self.jobs[job.job_id] = job
self._queued_job_ids.append(job.job_id)
job.publish("job_state", {"status": job.status})
self._publish_queue_state_locked()
self._condition.notify_all()
return job
def get_job(self, job_id: str) -> GuiJob | None:
with self._condition:
return self.jobs.get(job_id)
def get_job_state_payload(self, job_id: str) -> dict[str, Any] | None:
with self._condition:
job = self.jobs.get(job_id)
if job is None:
return None
payload = _job_state_payload(job)
payload.update(self._queue_state_payload_locked(job.job_id))
return payload
def get_queue_summary(self) -> dict[str, Any]:
with self._condition:
return {
"queueDepth": self._queue_depth_locked(),
"waitingCount": len(self._queued_job_ids),
"queueCapacity": self.config.queue_capacity,
"hasActiveJob": self._active_job_id is not None,
"activeStatus": None if self._active_job_id is None else self.jobs[self._active_job_id].status,
}
def heartbeat(self, job_id: str) -> dict[str, Any] | None:
with self._condition:
job = self.jobs.get(job_id)
if job is None:
return None
if job.status in {"queued", "running"}:
job.touch_heartbeat()
payload = _job_state_payload(job)
payload.update(self._queue_state_payload_locked(job.job_id))
return payload
def cancel_job(self, job_id: str, reason: str) -> dict[str, Any] | None:
with self._condition:
job = self.jobs.get(job_id)
if job is None:
return None
if job.status == "queued":
self._remove_from_queue_locked(job_id)
job.request_cancel(reason)
job.mark_canceled(reason)
self._cleanup_spool_file(job)
self._publish_queue_state_locked()
payload = _job_state_payload(job)
payload.update(self._queue_state_payload_locked(job.job_id))
self._condition.notify_all()
return payload
if job.status == "running":
job.request_cancel(reason)
job.publish("job_state", {"status": job.status, "message": reason, "cancelRequested": True})
payload = _job_state_payload(job)
payload.update(self._queue_state_payload_locked(job.job_id))
return payload
payload = _job_state_payload(job)
payload.update(self._queue_state_payload_locked(job.job_id))
return payload
def _worker_loop(self) -> None:
while not self._stop_event.is_set():
with self._condition:
while not self._stop_event.is_set() and not self._queued_job_ids:
self._condition.wait(timeout=0.5)
if self._stop_event.is_set():
return
job_id = self._queued_job_ids.popleft()
job = self.jobs.get(job_id)
if job is None or job.status != "queued":
continue
self._active_job_id = job_id
self._publish_queue_state_locked()
try:
_execute_job(job)
except PipelineCancelled as exc:
job.mark_canceled(str(exc))
except Exception as exc: # pragma: no cover - exercised through manual GUI runs
job.mark_failed(str(exc))
finally:
self._cleanup_spool_file(job)
with self._condition:
if self._active_job_id == job.job_id:
self._active_job_id = None
self._publish_queue_state_locked()
self._condition.notify_all()
def _monitor_loop(self) -> None:
poll_interval = min(1.0, max(0.25, self.config.heartbeat_interval_seconds / 2))
while not self._stop_event.wait(timeout=poll_interval):
now = time.time()
with self._condition:
changed = False
for job_id in list(self._queued_job_ids):
job = self.jobs.get(job_id)
if job is None:
self._remove_from_queue_locked(job_id)
changed = True
continue
if job.is_stale(now=now, stale_after_seconds=self.config.stale_after_seconds):
self._remove_from_queue_locked(job_id)
job.request_cancel("Queued job canceled after the browser stopped heartbeating.")
job.mark_canceled(job.cancellation_message)
self._cleanup_spool_file(job)
changed = True
if self._active_job_id is not None:
active_job = self.jobs.get(self._active_job_id)
if (
active_job is not None
and active_job.is_stale(now=now, stale_after_seconds=self.config.stale_after_seconds)
and not active_job.check_cancelled()
):
active_job.request_cancel("Running job canceled after the browser stopped heartbeating.")
active_job.publish(
"job_state",
{
"status": active_job.status,
"message": active_job.cancellation_message,
"cancelRequested": True,
},
)
if changed:
self._publish_queue_state_locked()
self._condition.notify_all()
def _remove_from_queue_locked(self, job_id: str) -> None:
with contextlib.suppress(ValueError):
self._queued_job_ids.remove(job_id)
def _cleanup_spool_file(self, job: GuiJob) -> None:
with contextlib.suppress(FileNotFoundError):
job.spool_path.unlink()
def _purge_spool_dir(self) -> None:
if not self.config.spool_dir.exists():
return
for entry in self.config.spool_dir.iterdir():
if entry.is_dir():
shutil.rmtree(entry, ignore_errors=True)
else:
with contextlib.suppress(FileNotFoundError):
entry.unlink()
def _queue_depth_locked(self) -> int:
return len(self._queued_job_ids) + (1 if self._active_job_id is not None else 0)
def _queue_position_locked(self, job_id: str) -> int | None:
if self._active_job_id == job_id:
return 1
if job_id not in self._queued_job_ids:
return None
offset = 1 if self._active_job_id is not None else 0
return offset + list(self._queued_job_ids).index(job_id) + 1
def _queue_state_payload_locked(self, job_id: str) -> dict[str, Any]:
return {
"queuePosition": self._queue_position_locked(job_id),
"queueDepth": self._queue_depth_locked(),
"waitingCount": len(self._queued_job_ids),
"queueCapacity": self.config.queue_capacity,
"heartbeatIntervalSeconds": self.config.heartbeat_interval_seconds,
"staleAfterSeconds": self.config.stale_after_seconds,
}
def _publish_queue_state_locked(self) -> None:
targets: list[str] = []
if self._active_job_id is not None:
targets.append(self._active_job_id)
targets.extend(self._queued_job_ids)
for job_id in targets:
job = self.jobs.get(job_id)
if job is None or job.status not in {"queued", "running"}:
continue
job.publish("queue_state", self._queue_state_payload_locked(job_id))
def _static_dir() -> Path:
return Path(__file__).with_name("gui_static")
@ -384,8 +871,18 @@ def create_app():
StreamingResponse = deps["StreamingResponse"]
StaticFiles = deps["StaticFiles"]
app = FastAPI(title="Repixelizer GUI", version="0.1.0")
jobs: dict[str, GuiJob] = {}
config = HostedDemoConfig.from_env()
manager = GuiJobManager(config)
@contextlib.asynccontextmanager
async def lifespan(_app):
manager.start()
try:
yield
finally:
manager.stop()
app = FastAPI(title="Repixelizer GUI", version="0.1.0", lifespan=lifespan)
static_dir = _static_dir()
@app.middleware("http")
@ -401,12 +898,20 @@ def create_app():
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/config")
def get_config():
return JSONResponse(config.public_payload())
@app.get("/api/queue")
def get_queue():
return JSONResponse(manager.get_queue_summary())
@app.get("/api/jobs/{job_id}")
def get_job(job_id: str):
job = jobs.get(job_id)
if job is None:
payload = manager.get_job_state_payload(job_id)
if payload is None:
raise HTTPException(status_code=404, detail="Unknown job")
return JSONResponse(_job_state_payload(job))
return JSONResponse(payload)
@app.post("/api/jobs")
async def create_job(
@ -416,42 +921,70 @@ def create_app():
target_height: int | None = Form(default=None),
phase_x: float | None = Form(default=None),
phase_y: float | None = Form(default=None),
steps: int = Form(default=48),
steps: int | None = Form(default=None),
seed: int = Form(default=7),
device: str = Form(default="auto"),
strip_background: bool = Form(default=False),
skip_phase_rerank: bool = Form(default=False),
):
raw = await image.read()
source_rgba = _decode_rgba(raw)
job = GuiJob(job_id=str(uuid.uuid4()), filename=image.filename or "input.png")
jobs[job.job_id] = job
options = {
"target_size": target_size,
"target_width": target_width,
"target_height": target_height,
"phase_x": phase_x,
"phase_y": phase_y,
"steps": max(0, int(steps)),
"seed": int(seed),
"device": device,
"strip_background": bool(strip_background),
"skip_phase_rerank": bool(skip_phase_rerank),
}
worker = threading.Thread(target=_run_job, args=(job, source_rgba, options), daemon=True)
worker.start()
filename = image.filename or "input.png"
try:
options, _source_width, _source_height = _validate_upload_request(
config,
raw=raw,
filename=filename,
target_size=target_size,
target_width=target_width,
target_height=target_height,
phase_x=phase_x,
phase_y=phase_y,
steps=steps,
seed=seed,
device=device,
strip_background=strip_background,
skip_phase_rerank=skip_phase_rerank,
)
job = manager.submit_job(filename=filename, raw=raw, options=options)
except QueueFullError as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc
except ValueError as exc:
detail = str(exc)
status_code = 413 if "too large" in detail.lower() else 422
raise HTTPException(status_code=status_code, detail=detail) from exc
state_payload = manager.get_job_state_payload(job.job_id)
assert state_payload is not None
return JSONResponse(
{
"jobId": job.job_id,
"status": job.status,
"status": state_payload["status"],
"eventsUrl": f"/api/jobs/{job.job_id}/events",
"stateUrl": f"/api/jobs/{job.job_id}",
"queuePosition": state_payload["queuePosition"],
"queueDepth": state_payload["queueDepth"],
"queueCapacity": state_payload["queueCapacity"],
"heartbeatIntervalSeconds": state_payload["heartbeatIntervalSeconds"],
"staleAfterSeconds": state_payload["staleAfterSeconds"],
}
)
@app.post("/api/jobs/{job_id}/heartbeat")
def job_heartbeat(job_id: str):
payload = manager.heartbeat(job_id)
if payload is None:
raise HTTPException(status_code=404, detail="Unknown job")
return JSONResponse(payload)
@app.delete("/api/jobs/{job_id}")
def cancel_job(job_id: str):
payload = manager.cancel_job(job_id, "Canceled because the browser left or explicitly bailed.")
if payload is None:
raise HTTPException(status_code=404, detail="Unknown job")
return JSONResponse(payload)
@app.get("/api/jobs/{job_id}/events")
def job_events(job_id: str):
job = jobs.get(job_id)
job = manager.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Unknown job")
@ -466,11 +999,18 @@ def create_app():
f"event: {record['event']}\n"
f"data: {json.dumps(record['payload'])}\n\n"
)
if job.status in {"completed", "failed"} and index >= len(job.events):
if job.status in {"completed", "failed", "canceled"} and index >= len(job.events):
break
yield ": keep-alive\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
if static_dir.exists():
@app.get("/app")

View file

@ -9,6 +9,7 @@ function byId(id) {
const fileInput = byId("fileInput");
const dropzone = byId("dropzone");
const dropzoneLabel = byId("dropzoneLabel");
const inputCopy = byId("inputCopy");
const pickFileButton = byId("pickFileButton");
const runButton = byId("runButton");
const statusPanel = byId("statusPanel");
@ -43,7 +44,12 @@ const phaseXInput = byId("phaseXInput");
const phaseYInput = byId("phaseYInput");
const stepsInput = byId("stepsInput");
const seedInput = byId("seedInput");
const runControlsCopy = byId("runControlsCopy");
const queueSummaryPanel = byId("queueSummary");
const cancelJobButton = byId("cancelJobButton");
const deviceField = byId("deviceField");
const deviceInput = byId("deviceInput");
const stripBackgroundField = byId("stripBackgroundField");
const stripBackgroundInput = byId("stripBackgroundInput");
const skipRerankInput = byId("skipRerankInput");
const paintInputs = {
@ -55,6 +61,8 @@ const paintInputs = {
const imageCache = new Map();
const eventTypes = [
"job_state",
"job_canceled",
"queue_state",
"job_failed",
"stage_started",
"source_loaded",
@ -97,6 +105,14 @@ const state = {
heatmapImage: null,
finalOutputImage: null,
runSummary: null,
runtimeConfig: null,
queueSummary: null,
queuePosition: null,
queueDepth: 0,
waitingCount: 0,
queueCapacity: 0,
heartbeatIntervalSeconds: 10,
staleAfterSeconds: 30,
solverStepBudget: 0,
lossAxisMax: null,
eventLog: [],
@ -109,6 +125,8 @@ const state = {
showGrid: gridToggle.checked,
};
let currentEventSource = null;
let currentHeartbeatTimer = null;
let currentQueuePollTimer = null;
let painting = false;
let panning = false;
let activePanPointerId = null;
@ -163,6 +181,58 @@ function renderEventLog() {
eventLog.appendChild(node);
}
}
function renderQueueSummary() {
queueSummaryPanel.innerHTML = "";
if (hasActiveJob()) {
const cards = [
["Status", formatStatusBadge(state.status)],
[
"Line",
state.queuePosition === null || state.queueDepth <= 0
? "active now"
: `${state.queuePosition} / ${Math.max(state.queueDepth, state.queuePosition)}`,
],
["Waiting", `${state.waitingCount} / ${state.queueCapacity || (state.runtimeConfig?.limits.queueCapacity ?? 0)}`],
];
for (const [label, value] of cards) {
const node = document.createElement("div");
node.className = "info-card summary-card";
node.innerHTML = `<strong>${label}</strong><span>${value}</span>`;
queueSummaryPanel.appendChild(node);
}
const note = document.createElement("p");
note.className = "muted";
note.textContent =
state.status === "queued"
? "Heartbeats keep this slot alive while you wait."
: "This run owns the worker. Leave the page and the queue slot gets reclaimed.";
queueSummaryPanel.appendChild(note);
cancelJobButton.hidden = false;
return;
}
cancelJobButton.hidden = true;
if (!state.queueSummary) {
queueSummaryPanel.innerHTML = `<p class="muted">Checking queue status.</p>`;
return;
}
const idleCards = [
["Worker", state.queueSummary.hasActiveJob ? "busy" : "idle"],
["Queue", String(state.queueSummary.queueDepth)],
["Waiting", `${state.queueSummary.waitingCount} / ${state.queueSummary.queueCapacity}`],
];
for (const [label, value] of idleCards) {
const node = document.createElement("div");
node.className = "info-card summary-card";
node.innerHTML = `<strong>${label}</strong><span>${value}</span>`;
queueSummaryPanel.appendChild(node);
}
const note = document.createElement("p");
note.className = "muted";
note.textContent = state.queueSummary.hasActiveJob
? "One run is chewing right now. New jobs line up behind it."
: "Nobody is using the machine. A small miracle.";
queueSummaryPanel.appendChild(note);
}
function formatStatusBadge(status) {
return status.replaceAll("_", " ").toUpperCase();
}
@ -178,6 +248,60 @@ function setStage(stageKey, label, detail) {
statusStage.textContent = label;
statusText.textContent = detail;
}
function hasActiveJob() {
return state.jobId !== null && ["queued", "running", "waiting"].includes(state.status);
}
function formatByteLimit(bytes) {
if (bytes >= 1024 * 1024) {
return `${formatNumber(bytes / (1024 * 1024), 1)} MiB`;
}
if (bytes >= 1024) {
return `${Math.round(bytes / 1024)} KiB`;
}
return `${bytes} B`;
}
function applyQueueState(payload) {
if (typeof payload.queuePosition === "number") {
state.queuePosition = payload.queuePosition;
}
else if (payload.queuePosition === null) {
state.queuePosition = null;
}
if (typeof payload.queueDepth === "number") {
state.queueDepth = payload.queueDepth;
}
if (typeof payload.waitingCount === "number") {
state.waitingCount = payload.waitingCount;
}
if (typeof payload.queueCapacity === "number") {
state.queueCapacity = payload.queueCapacity;
}
if (typeof payload.heartbeatIntervalSeconds === "number") {
state.heartbeatIntervalSeconds = payload.heartbeatIntervalSeconds;
}
if (typeof payload.staleAfterSeconds === "number") {
state.staleAfterSeconds = payload.staleAfterSeconds;
}
}
function applyRuntimeConfig(config) {
state.runtimeConfig = config;
deviceField.hidden = !config.ui.showDeviceControl;
stripBackgroundField.hidden = !config.ui.showStripBackgroundControl;
stepsInput.max = String(config.limits.maxSteps);
if (stepsInput.value.trim() === "" || Number(stepsInput.value) > config.limits.maxSteps || Number(stepsInput.value) === 48) {
stepsInput.value = String(config.limits.defaultSteps);
}
if (config.hostedDemo) {
deviceInput.value = "cpu";
stripBackgroundInput.checked = false;
inputCopy.textContent = `PNG up to ${formatByteLimit(config.limits.maxUploadBytes)}, max ${config.limits.maxInputDimension}x${config.limits.maxInputDimension}.`;
runControlsCopy.textContent = `Hosted demo mode. CPU only, output max ${config.limits.maxOutputDimension}px, steps clamp at ${config.limits.maxSteps}.`;
}
else {
inputCopy.textContent = "Start with the ugly input.";
runControlsCopy.textContent = "Pin what you know. Search what you do not.";
}
}
function resetRunArtifacts(options = {}) {
const preserveSourceImage = options.preserveSourceImage ?? false;
state.jobId = null;
@ -197,6 +321,9 @@ function resetRunArtifacts(options = {}) {
state.heatmapImage = null;
state.finalOutputImage = null;
state.runSummary = null;
state.queuePosition = null;
state.queueDepth = 0;
state.waitingCount = 0;
state.solverStepBudget = 0;
state.lossAxisMax = null;
state.eventLog = [];
@ -398,7 +525,17 @@ function renderInference() {
function renderStatusMetrics() {
const items = [];
const frame = getSelectedFrame();
if (state.stageKey === "inference") {
if (state.stageKey === "queued" || state.status === "queued" || state.status === "waiting") {
items.push({
label: "Line",
value: state.queuePosition === null || state.queueDepth <= 0
? "pending"
: `${state.queuePosition} / ${Math.max(state.queueDepth, state.queuePosition)}`,
});
items.push({ label: "Waiting", value: `${state.waitingCount} / ${state.queueCapacity || (state.runtimeConfig?.limits.queueCapacity ?? 0)}` });
items.push({ label: "Heartbeat", value: `${state.heartbeatIntervalSeconds}s` });
}
else if (state.stageKey === "inference") {
if (state.latticeSearch) {
items.push({ label: "Sizes", value: `${state.latticeSearch.completedCandidates} / ${state.latticeSearch.candidateCount}` });
items.push({ label: "Phases", value: String(state.latticeSearch.phaseSampleCount) });
@ -975,22 +1112,145 @@ function endPan(pointerId = null) {
activePanPointerId = null;
syncEditorCursorState();
}
async function loadRuntimeConfig() {
const response = await fetch("/api/config");
if (!response.ok) {
throw new Error(await response.text());
}
applyRuntimeConfig((await response.json()));
}
async function refreshQueueSummary() {
if (hasActiveJob()) {
return;
}
const response = await fetch("/api/queue");
if (!response.ok) {
throw new Error(await response.text());
}
state.queueSummary = (await response.json());
renderQueueSummary();
}
function stopQueuePolling() {
if (currentQueuePollTimer !== null) {
window.clearInterval(currentQueuePollTimer);
currentQueuePollTimer = null;
}
}
function syncQueuePolling() {
if (hasActiveJob()) {
stopQueuePolling();
return;
}
if (currentQueuePollTimer !== null) {
return;
}
currentQueuePollTimer = window.setInterval(() => {
void refreshQueueSummary().catch(() => undefined);
}, 10000);
}
function stopHeartbeat() {
if (currentHeartbeatTimer !== null) {
window.clearInterval(currentHeartbeatTimer);
currentHeartbeatTimer = null;
}
}
function applyJobSnapshot(snapshot) {
applyQueueState(snapshot);
if (typeof snapshot.status === "string") {
setJobState(snapshot.status);
}
if (snapshot.runSummary && typeof snapshot.runSummary === "object") {
state.runSummary = snapshot.runSummary;
}
}
async function sendHeartbeat() {
if (!state.jobId || !hasActiveJob()) {
stopHeartbeat();
return;
}
const response = await fetch(`/api/jobs/${state.jobId}/heartbeat`, { method: "POST" });
if (response.status === 404) {
stopHeartbeat();
return;
}
if (!response.ok) {
return;
}
applyJobSnapshot((await response.json()));
renderQueueSummary();
}
function syncHeartbeat() {
if (!hasActiveJob()) {
stopHeartbeat();
return;
}
const intervalMs = Math.max(1000, state.heartbeatIntervalSeconds * 1000);
if (currentHeartbeatTimer !== null) {
window.clearInterval(currentHeartbeatTimer);
}
currentHeartbeatTimer = window.setInterval(() => {
void sendHeartbeat().catch(() => undefined);
}, intervalMs);
}
function extractErrorDetail(payload, fallback) {
if (payload && typeof payload === "object") {
const detail = payload.detail;
if (typeof detail === "string" && detail.trim()) {
return detail;
}
}
return fallback;
}
async function cancelCurrentJob(options = {}) {
if (!state.jobId || !hasActiveJob()) {
return;
}
const request = fetch(`/api/jobs/${state.jobId}`, {
method: "DELETE",
keepalive: options.keepalive ?? false,
});
if (options.keepalive) {
return;
}
try {
const response = await request;
if (!response.ok) {
throw new Error(await response.text());
}
const payload = (await response.json());
applyJobSnapshot(payload);
if (!options.silent) {
setStage("queued", "Cancel requested", "Asked the worker to drop this job and free the queue slot.");
}
renderQueueSummary();
}
catch (error) {
if (!options.silent) {
addLog("Cancel", error instanceof Error ? error.message : "Failed to cancel the current job.");
}
}
}
function buildFormData() {
if (!state.file) {
throw new Error("No file selected");
}
const data = new FormData();
data.set("image", state.file);
const runtimeConfig = state.runtimeConfig;
const maxSteps = runtimeConfig?.limits.maxSteps ?? 48;
const defaultSteps = runtimeConfig?.limits.defaultSteps ?? 48;
const requestedSteps = parseOptionalInteger(stepsInput);
const normalizedSteps = Math.max(0, Math.min(maxSteps, requestedSteps ?? defaultSteps));
const values = [
["target_size", parseOptionalInteger(targetSizeInput)?.toString() ?? null],
["target_width", parseOptionalInteger(targetWidthInput)?.toString() ?? null],
["target_height", parseOptionalInteger(targetHeightInput)?.toString() ?? null],
["phase_x", parseOptionalFloat(phaseXInput)?.toString() ?? null],
["phase_y", parseOptionalFloat(phaseYInput)?.toString() ?? null],
["steps", String(parseOptionalInteger(stepsInput) ?? 48)],
["steps", String(normalizedSteps)],
["seed", String(parseOptionalInteger(seedInput) ?? 7)],
["device", deviceInput.value],
["strip_background", stripBackgroundInput.checked ? "true" : "false"],
["device", runtimeConfig?.hostedDemo ? "cpu" : deviceInput.value],
["strip_background", runtimeConfig?.hostedDemo ? "false" : stripBackgroundInput.checked ? "true" : "false"],
["skip_phase_rerank", skipRerankInput.checked ? "true" : "false"],
];
for (const [key, value] of values) {
@ -1001,6 +1261,7 @@ function buildFormData() {
return data;
}
async function renderEverything() {
renderQueueSummary();
renderInference();
renderStatusMetrics();
renderLossChart();
@ -1010,14 +1271,42 @@ async function renderEverything() {
async function handleEvent(eventName, payload) {
switch (eventName) {
case "job_state":
setJobState(String(payload.status ?? "working"));
applyJobSnapshot(payload);
if (payload.cancelRequested === true && state.status === "running") {
setStage(state.stageKey, state.stageLabel, String(payload.message ?? "Cancel requested. Waiting for the worker to drop the job."));
}
syncHeartbeat();
syncQueuePolling();
break;
case "queue_state":
applyQueueState(payload);
state.queueSummary = {
queueDepth: state.queueDepth,
waitingCount: state.waitingCount,
queueCapacity: state.queueCapacity,
hasActiveJob: state.queueDepth > 0,
activeStatus: state.status,
};
break;
case "job_canceled":
stopHeartbeat();
setJobState("canceled");
setStage("canceled", "Job canceled", String(payload.message ?? "The queued or running job was canceled."));
addLog("Canceled", String(payload.message ?? "Canceled the job."));
runButton.disabled = false;
currentEventSource?.close();
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
case "job_failed":
stopHeartbeat();
setJobState("failed");
setStage("failed", "Run failed", String(payload.message ?? "The GUI run fell over."));
addLog("Failure", String(payload.message ?? "The run failed."));
runButton.disabled = false;
currentEventSource?.close();
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
case "stage_started":
setJobState("running");
@ -1186,12 +1475,15 @@ async function handleEvent(eventName, payload) {
case "pipeline_completed":
state.finalOutputImage = payload.outputImage;
state.runSummary = payload.runSummary ?? null;
stopHeartbeat();
setJobState("completed");
setStage("completed", "Run complete", "The output is ready. If the machine still did something stupid, fix it pixel by pixel.");
addLog("Done", "Full pipeline completed.");
runButton.disabled = false;
currentEventSource?.close();
await loadEditorAsset(state.finalOutputImage);
syncQueuePolling();
void refreshQueueSummary().catch(() => undefined);
break;
default:
break;
@ -1210,9 +1502,9 @@ function connectEventStream(jobId) {
});
}
stream.onerror = () => {
if (state.status === "running") {
if (hasActiveJob()) {
setJobState("waiting");
setStage(state.stageKey, state.stageLabel, "Event stream hiccup. If it stays this way, rerun it.");
setStage(state.stageKey, state.stageLabel, "Event stream hiccup. Heartbeats are still trying to keep the queue slot alive.");
}
};
}
@ -1225,9 +1517,12 @@ async function startRun() {
resetRunArtifacts({ preserveSourceImage: true });
renderEventLog();
runButton.disabled = true;
state.solverStepBudget = parseOptionalInteger(stepsInput) ?? 48;
const runtimeConfig = state.runtimeConfig;
const maxSteps = runtimeConfig?.limits.maxSteps ?? 48;
const defaultSteps = runtimeConfig?.limits.defaultSteps ?? 48;
state.solverStepBudget = Math.max(0, Math.min(maxSteps, parseOptionalInteger(stepsInput) ?? defaultSteps));
setJobState("queued");
setStage("queued", "Queued", "Starting the pipeline. This should wake up almost immediately.");
setStage("queued", "Queued", "Submitting the job and trying not to trip over our own shoelaces.");
addLog("Queued", `Starting ${state.file.name}.`);
await renderEverything();
try {
@ -1236,17 +1531,29 @@ async function startRun() {
body: buildFormData(),
});
if (!response.ok) {
throw new Error(await response.text());
const errorPayload = await response.json().catch(async () => ({ detail: await response.text() }));
throw new Error(extractErrorDetail(errorPayload, "Failed to start GUI job."));
}
const payload = (await response.json());
state.jobId = payload.jobId;
applyJobSnapshot(payload);
state.queueSummary = {
queueDepth: payload.queueDepth,
waitingCount: payload.waitingCount,
queueCapacity: payload.queueCapacity,
hasActiveJob: payload.queueDepth > 0,
activeStatus: payload.status,
};
connectEventStream(payload.jobId);
syncHeartbeat();
syncQueuePolling();
}
catch (error) {
runButton.disabled = false;
setJobState("failed");
setStage("failed", "Run failed", error instanceof Error ? error.message : "Failed to start GUI job.");
addLog("Failure", error instanceof Error ? error.message : "Failed to start GUI job.");
syncQueuePolling();
}
}
async function acceptFile(file) {
@ -1297,6 +1604,15 @@ dropzone.addEventListener("drop", (event) => {
runButton.addEventListener("click", () => {
void startRun();
});
cancelJobButton.addEventListener("click", () => {
void cancelCurrentJob();
});
stepsInput.addEventListener("change", () => {
const maxSteps = state.runtimeConfig?.limits.maxSteps;
if (typeof maxSteps === "number" && Number(stepsInput.value) > maxSteps) {
stepsInput.value = String(maxSteps);
}
});
inspectZoomInput.addEventListener("change", () => {
const value = Number(inspectZoomInput.value);
state.viewerInspectZoom = Number.isFinite(value) && value > 0 ? value : 1;
@ -1340,6 +1656,9 @@ window.addEventListener("blur", () => {
endPan();
syncEditorCursorState();
});
window.addEventListener("pagehide", () => {
void cancelCurrentJob({ keepalive: true, silent: true });
});
for (const [panel, canvas] of [
["left", leftCanvas],
["right", rightCanvas],
@ -1458,4 +1777,20 @@ syncEditorCursorState();
syncViewerInspectCursor();
zoomValue.textContent = `${state.zoom}x`;
renderEventLog();
void renderEverything();
async function initializeApp() {
try {
await loadRuntimeConfig();
}
catch (error) {
addLog("Config", error instanceof Error ? error.message : "Failed to load runtime config.");
}
try {
await refreshQueueSummary();
}
catch (error) {
addLog("Queue", error instanceof Error ? error.message : "Failed to load queue summary.");
}
syncQueuePolling();
await renderEverything();
}
void initializeApp();

View file

@ -141,7 +141,7 @@
<div class="card-head">
<div class="copy-stack">
<h2>Input</h2>
<p class="copy-tag">Start with the ugly input.</p>
<p id="inputCopy" class="copy-tag">Start with the ugly input.</p>
</div>
<button id="pickFileButton" class="button button-small button-secondary" type="button">Choose File</button>
</div>
@ -156,11 +156,11 @@
<div class="card-head">
<div class="copy-stack">
<h2>Run Controls</h2>
<p class="copy-tag">Pin what you know. Search what you do not.</p>
<p id="runControlsCopy" class="copy-tag">Pin what you know. Search what you do not.</p>
</div>
</div>
<form id="controlsForm" class="controls-grid">
<label class="field">
<label id="deviceField" class="field">
<span class="field-heading">
<span class="field-label">Device</span>
<span
@ -276,7 +276,7 @@
</span>
<input id="seedInput" class="field-control" name="seed" type="number" min="0" value="7" />
</label>
<label class="toggle-row ui-surface ui-frame">
<label id="stripBackgroundField" class="toggle-row ui-surface ui-frame">
<input id="stripBackgroundInput" name="stripBackground" type="checkbox" />
<span class="toggle-copy">
<span>Strip checkerboard-ish junk</span>
@ -308,6 +308,21 @@
<button id="runButton" class="button" type="button">Run The Machine</button>
</section>
<section class="card sidebar-section ui-surface ui-frame">
<div class="card-head">
<div class="copy-stack">
<h2>Queue</h2>
<p class="copy-tag">One worker. The rest wait politely instead of setting the server on fire.</p>
</div>
</div>
<div id="queueSummary" class="info-stack">
<p class="muted">Checking queue status.</p>
</div>
<div class="queue-actions">
<button id="cancelJobButton" class="button button-small button-secondary" type="button" hidden>Cancel Job</button>
</div>
</section>
<section class="card sidebar-section ui-surface ui-frame">
<div class="card-head">
<div class="copy-stack">

View file

@ -644,6 +644,12 @@ select.field-control optgroup {
color: var(--ui-text-muted);
}
.queue-actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.viz-card {
margin: 0;
padding: 10px;

View file

@ -6,7 +6,7 @@ import numpy as np
from .io import premultiply
from .metrics import source_lattice_evidence_breakdown
from .observe import PipelineObserver, emit_observer
from .observe import PipelineObserver, check_observer_cancelled, emit_observer
from .types import InferenceCandidate, InferenceResult
@ -755,6 +755,7 @@ def infer_lattice(
candidates: list[InferenceCandidate] = []
for candidate_index, (target_width, target_height) in enumerate(candidate_dims, start=1):
check_observer_cancelled(observer)
scored_group = _score_phase_group(
rgba,
target_width=target_width,

View file

@ -7,7 +7,34 @@ class PipelineObserver(Protocol):
def __call__(self, event: str, payload: dict[str, Any]) -> None: ...
class PipelineCancelled(RuntimeError):
"""Raised when a cooperative pipeline cancellation request is observed."""
def emit_observer(observer: PipelineObserver | None, event: str, /, **payload: Any) -> None:
if observer is None:
return
observer(event, payload)
def observer_attribute(observer: PipelineObserver | None, name: str, default: object = None) -> object:
if observer is None:
return default
direct = getattr(observer, name, None)
if direct is not None:
return direct
owner = getattr(observer, "__self__", None)
if owner is not None:
owner_value = getattr(owner, name, None)
if owner_value is not None:
return owner_value
return default
def check_observer_cancelled(observer: PipelineObserver | None) -> None:
if observer is None:
return
checker = observer_attribute(observer, "check_cancelled", None)
if callable(checker) and checker():
message = observer_attribute(observer, "cancellation_message", "Pipeline canceled.")
raise PipelineCancelled(str(message))

View file

@ -5,7 +5,7 @@ from dataclasses import dataclass
import numpy as np
from .io import premultiply
from .observe import PipelineObserver, emit_observer
from .observe import PipelineObserver, check_observer_cancelled, emit_observer, observer_attribute
from .types import InferenceResult, PhaseFieldSourceAnalysis, SolverArtifacts
from .params import SolverHyperParams
@ -392,17 +392,7 @@ def _materialize_loss_history(torch, values: list[object]) -> list[float]:
def _observer_option(observer: PipelineObserver | None, name: str, default: object) -> object:
if observer is None:
return default
direct = getattr(observer, name, None)
if direct is not None:
return direct
owner = getattr(observer, "__self__", None)
if owner is not None:
owner_value = getattr(owner, name, None)
if owner_value is not None:
return owner_value
return default
return observer_attribute(observer, name, default)
def _observer_preview_stride(observer: PipelineObserver | None) -> int:
@ -488,6 +478,7 @@ def optimize_phase_field(
max_dy = solver_params.phase_field_max_displacement_ratio * prep.cell_y
for step_index in range(max(0, steps)):
check_observer_cancelled(observer)
optimizer.zero_grad(set_to_none=True)
loss, _sampled_rgba, terms = _phase_field_loss(torch, F, prep, disp_t, solver_params)
loss.backward()
@ -521,6 +512,7 @@ def optimize_phase_field(
emit_observer(observer, "phase_field_step", **payload)
with torch.no_grad():
check_observer_cancelled(observer)
if steps <= 0:
loss, _sampled_rgba, final_terms_raw = _phase_field_loss(torch, F, prep, disp_t, solver_params)
loss_history_raw.append(loss.detach())

View file

@ -6,7 +6,7 @@ from typing import Any
import numpy as np
from .observe import PipelineObserver, emit_observer
from .observe import PipelineObserver, check_observer_cancelled, emit_observer
from .analysis import analyze_phase_field_source
from .diagnostics import (
summarize_run,
@ -97,8 +97,10 @@ def run_pipeline_rgba(
) -> RunResult:
started = time.perf_counter()
solver_params = solver_params or SolverHyperParams()
check_observer_cancelled(observer)
emit_observer(observer, "source_loaded", source_rgba=source.copy())
if strip_background:
check_observer_cancelled(observer)
emit_observer(
observer,
"stage_started",
@ -108,6 +110,7 @@ def run_pipeline_rgba(
)
source = strip_edge_background(source)
emit_observer(observer, "preprocess_completed", source_rgba=source.copy(), operation="strip_background")
check_observer_cancelled(observer)
fixed_dims = _resolve_requested_target_dims(
source_width=source.shape[1],
source_height=source.shape[0],
@ -118,6 +121,7 @@ def run_pipeline_rgba(
phase_y=phase_y,
)
if fixed_dims is None:
check_observer_cancelled(observer)
emit_observer(
observer,
"stage_started",
@ -128,6 +132,7 @@ def run_pipeline_rgba(
inference = infer_lattice(source, target_size=target_size, device=device, observer=observer)
inference_mode = "searched"
else:
check_observer_cancelled(observer)
emit_observer(
observer,
"stage_started",
@ -145,6 +150,7 @@ def run_pipeline_rgba(
)
inference_mode = "fixed"
emit_observer(observer, "inference_candidates_ready", inference=inference, inference_mode=inference_mode)
check_observer_cancelled(observer)
emit_observer(
observer,
"stage_started",
@ -158,6 +164,7 @@ def run_pipeline_rgba(
device=device,
)
emit_observer(observer, "analysis_completed", edge_map=analysis.edge_map.copy())
check_observer_cancelled(observer)
emit_observer(
observer,
"stage_started",
@ -177,6 +184,7 @@ def run_pipeline_rgba(
observer=observer,
)
emit_observer(observer, "phase_selection_completed", inference=inference, inference_mode=inference_mode)
check_observer_cancelled(observer)
emit_observer(
observer,
"stage_started",
@ -198,6 +206,7 @@ def run_pipeline_rgba(
solver_params=solver_params,
observer=observer,
)
check_observer_cancelled(observer)
emit_observer(
observer,
"stage_started",
@ -213,6 +222,7 @@ def run_pipeline_rgba(
isolated_heatmap=cleanup.isolated_heatmap.copy(),
)
palette = load_palette(palette_path) if palette_path else None
check_observer_cancelled(observer)
emit_observer(
observer,
"stage_started",
@ -376,6 +386,7 @@ def _select_phase_candidate_with_reconstruction(
total_candidates = min(8, len(inference.top_candidates))
candidate_records: list[dict[str, float | InferenceResult]] = []
for candidate_index, candidate in enumerate(inference.top_candidates[:total_candidates], start=1):
check_observer_cancelled(observer)
candidate_inference = InferenceResult(
target_width=candidate.target_width,
target_height=candidate.target_height,
@ -429,6 +440,7 @@ def _select_phase_candidate_with_reconstruction(
solver_params=solver_params,
observer=rerank_observer if observer is not None else None,
)
check_observer_cancelled(observer)
emit_observer(
observer,
"phase_rerank_candidate_completed",

View file

@ -3,6 +3,8 @@ from __future__ import annotations
import importlib.util
import io
import asyncio
import threading
import time
from pathlib import Path
import numpy as np
@ -11,6 +13,7 @@ from starlette.datastructures import UploadFile
import repixelizer.inference as inference_module
import repixelizer.pipeline as pipeline_module
from repixelizer.observe import PipelineCancelled
from repixelizer.pipeline import run_pipeline_rgba
from repixelizer.synthetic import fake_pixelize, make_emblem
from repixelizer.params import SolverHyperParams
@ -51,6 +54,26 @@ async def _get_response(app, path: str) -> tuple[int, dict[str, str], bytes]:
return status, headers, body
def _png_bytes(*, width: int = 4, height: int = 4) -> bytes:
image = Image.new("RGBA", (width, height), (12, 34, 56, 255))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
def _route_endpoint(app, path: str, method: str):
for route in app.routes:
if getattr(route, "path", None) == path and method.upper() in getattr(route, "methods", set()):
return route.endpoint
raise AssertionError(f"Missing route {method} {path}")
def _response_json(response) -> dict[str, object]:
import json
return json.loads(response.body.decode("utf-8"))
def test_run_pipeline_rgba_emits_observer_events_for_gui() -> None:
source = make_emblem(20, 20)
fake = fake_pixelize(source, upscale=8, phase_x=0.15, phase_y=0.2, blur_radius=0.45, seed=3)
@ -402,3 +425,262 @@ def test_phase_rerank_emits_candidate_progress_events(monkeypatch) -> None:
first_step_payload = next(payload for event, payload in events if event == "phase_rerank_candidate_step")
assert first_step_payload["candidate_index"] == 1
assert first_step_payload["total_steps"] == 2
def test_gui_hosted_config_endpoint_exposes_demo_limits(monkeypatch, tmp_path: Path) -> None:
from repixelizer.gui import create_app
monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1")
monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool"))
app = create_app()
payload = _response_json(_route_endpoint(app, "/api/config", "GET")())
assert payload["hostedDemo"] is True
assert payload["limits"]["maxOutputDimension"] == 256
assert payload["limits"]["defaultSteps"] == 32
assert payload["ui"]["showDeviceControl"] is False
assert payload["ui"]["showStripBackgroundControl"] is False
def test_gui_queue_rejects_eleventh_waiting_job(monkeypatch, tmp_path: Path) -> None:
from repixelizer.gui import create_app
release = threading.Event()
def fake_run_pipeline_rgba(*args, **kwargs):
observer = kwargs.get("observer")
if observer is not None:
observer("stage_started", {"stage": "solver", "label": "Solver", "detail": "Working."})
release.wait(timeout=2.0)
if observer is not None and observer.__self__.check_cancelled(): # type: ignore[attr-defined]
raise PipelineCancelled(observer.__self__.cancellation_message) # type: ignore[attr-defined]
monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1")
monkeypatch.setenv("REPIXELIZER_QUEUE_CAPACITY", "10")
monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool"))
monkeypatch.setattr("repixelizer.gui.run_pipeline_rgba", fake_run_pipeline_rgba)
upload = _png_bytes()
app = create_app()
create_job = _route_endpoint(app, "/api/jobs", "POST")
accepted = []
for _ in range(11):
accepted.append(
asyncio.run(
create_job(
image=UploadFile(filename="tiny.png", file=io.BytesIO(upload)),
target_size=None,
target_width=None,
target_height=None,
phase_x=None,
phase_y=None,
steps=None,
seed=7,
device="auto",
strip_background=False,
skip_phase_rerank=False,
)
)
)
rejected = None
try:
asyncio.run(
create_job(
image=UploadFile(filename="tiny.png", file=io.BytesIO(upload)),
target_size=None,
target_width=None,
target_height=None,
phase_x=None,
phase_y=None,
steps=None,
seed=7,
device="auto",
strip_background=False,
skip_phase_rerank=False,
)
)
except Exception as exc:
rejected = exc
release.set()
assert all(response.status_code == 200 for response in accepted)
assert rejected is not None
assert "Queue is full" in getattr(rejected, "detail", str(rejected))
def test_gui_canceling_queued_job_cleans_spool_file(monkeypatch, tmp_path: Path) -> None:
from repixelizer.gui import create_app
release = threading.Event()
def fake_run_pipeline_rgba(*args, **kwargs):
release.wait(timeout=2.0)
spool_dir = tmp_path / "spool"
monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1")
monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(spool_dir))
monkeypatch.setattr("repixelizer.gui.run_pipeline_rgba", fake_run_pipeline_rgba)
upload = _png_bytes()
app = create_app()
create_job = _route_endpoint(app, "/api/jobs", "POST")
cancel_job = _route_endpoint(app, "/api/jobs/{job_id}", "DELETE")
queue_endpoint = _route_endpoint(app, "/api/queue", "GET")
first = asyncio.run(
create_job(
image=UploadFile(filename="first.png", file=io.BytesIO(upload)),
target_size=None,
target_width=None,
target_height=None,
phase_x=None,
phase_y=None,
steps=None,
seed=7,
device="auto",
strip_background=False,
skip_phase_rerank=False,
)
)
second = asyncio.run(
create_job(
image=UploadFile(filename="second.png", file=io.BytesIO(upload)),
target_size=None,
target_width=None,
target_height=None,
phase_x=None,
phase_y=None,
steps=None,
seed=7,
device="auto",
strip_background=False,
skip_phase_rerank=False,
)
)
assert first.status_code == 200
assert second.status_code == 200
queued_job_id = _response_json(second)["jobId"]
before_cancel = sorted(spool_dir.iterdir())
canceled = cancel_job(queued_job_id)
queue_summary = _response_json(queue_endpoint())
after_cancel = sorted(spool_dir.iterdir())
release.set()
assert len(before_cancel) == 2
assert canceled.status_code == 200
assert _response_json(canceled)["status"] == "canceled"
assert queue_summary["waitingCount"] == 0
assert len(after_cancel) == 1
def test_gui_running_job_is_canceled_after_stale_heartbeat(monkeypatch, tmp_path: Path) -> None:
from repixelizer.gui import create_app
def fake_run_pipeline_rgba(*args, **kwargs):
observer = kwargs.get("observer")
assert observer is not None
observer("stage_started", {"stage": "solver", "label": "Solver", "detail": "Working."})
for _ in range(80):
owner = observer.__self__ # type: ignore[attr-defined]
if owner.check_cancelled():
raise PipelineCancelled(owner.cancellation_message)
time.sleep(0.05)
monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1")
monkeypatch.setenv("REPIXELIZER_HEARTBEAT_INTERVAL_SECONDS", "1")
monkeypatch.setenv("REPIXELIZER_STALE_AFTER_SECONDS", "1")
monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool"))
monkeypatch.setattr("repixelizer.gui.run_pipeline_rgba", fake_run_pipeline_rgba)
app = create_app()
create_job = _route_endpoint(app, "/api/jobs", "POST")
get_job = _route_endpoint(app, "/api/jobs/{job_id}", "GET")
created = asyncio.run(
create_job(
image=UploadFile(filename="tiny.png", file=io.BytesIO(_png_bytes())),
target_size=None,
target_width=None,
target_height=None,
phase_x=None,
phase_y=None,
steps=None,
seed=7,
device="auto",
strip_background=False,
skip_phase_rerank=False,
)
)
assert created.status_code == 200
job_id = _response_json(created)["jobId"]
deadline = time.time() + 4.0
latest = None
while time.time() < deadline:
latest = get_job(job_id)
if _response_json(latest)["status"] == "canceled":
break
time.sleep(0.1)
assert latest is not None
assert latest.status_code == 200
assert _response_json(latest)["status"] == "canceled"
def test_gui_rejects_oversized_upload_and_output(monkeypatch, tmp_path: Path) -> None:
from repixelizer.gui import create_app
monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1")
monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool"))
monkeypatch.setenv("REPIXELIZER_MAX_UPLOAD_BYTES", "20")
app = create_app()
create_job = _route_endpoint(app, "/api/jobs", "POST")
too_large_upload = None
try:
too_large_upload = asyncio.run(
create_job(
image=UploadFile(filename="tiny.png", file=io.BytesIO(_png_bytes())),
target_size=None,
target_width=None,
target_height=None,
phase_x=None,
phase_y=None,
steps=None,
seed=7,
device="auto",
strip_background=False,
skip_phase_rerank=False,
)
)
except Exception as exc:
too_large_upload = exc
monkeypatch.setenv("REPIXELIZER_MAX_UPLOAD_BYTES", "1048576")
monkeypatch.setenv("REPIXELIZER_MAX_OUTPUT_DIMENSION", "8")
app = create_app()
create_job = _route_endpoint(app, "/api/jobs", "POST")
explicit_big_output = None
try:
explicit_big_output = asyncio.run(
create_job(
image=UploadFile(filename="tiny.png", file=io.BytesIO(_png_bytes(width=4, height=4))),
target_size=None,
target_width=16,
target_height=None,
phase_x=None,
phase_y=None,
steps=None,
seed=7,
device="auto",
strip_background=False,
skip_phase_rerank=False,
)
)
except Exception as exc:
explicit_big_output = exc
assert too_large_upload is not None
assert "too large" in getattr(too_large_upload, "detail", str(too_large_upload)).lower()
assert explicit_big_output is not None
assert "exceeds the maximum hosted output dimension" in getattr(
explicit_big_output, "detail", str(explicit_big_output)
)

View file

@ -1,5 +1,8 @@
from __future__ import annotations
import numpy as np
import pytest
from repixelizer.inference import (
SpacingEstimate,
_axis_prior_from_estimates,
@ -11,6 +14,7 @@ from repixelizer.inference import (
infer_fixed_lattice,
infer_lattice,
)
from repixelizer.observe import PipelineCancelled
from repixelizer.types import InferenceCandidate
from repixelizer.synthetic import fake_pixelize, make_emblem
@ -186,3 +190,53 @@ def test_infer_fixed_lattice_searches_phase_within_pinned_size() -> None:
assert len(result.top_candidates) > 1
assert all(candidate.target_width == 16 for candidate in result.top_candidates)
assert all(candidate.target_height == 16 for candidate in result.top_candidates)
def test_infer_lattice_honors_cooperative_cancellation(monkeypatch) -> None:
import repixelizer.inference as inference_module
class _FakeCuda:
@staticmethod
def is_available() -> bool:
return False
class _FakeTorch:
cuda = _FakeCuda()
class CancelObserver:
def __call__(self, event: str, payload: dict[str, object]) -> None:
del event, payload
def check_cancelled(self) -> bool:
return True
monkeypatch.setattr(inference_module, "_require_torch", lambda: (_FakeTorch(), object()))
monkeypatch.setattr(
inference_module,
"_estimate_lattice_spacing_details",
lambda rgba: (inference_module.SpacingEstimate(None, 0.0, None, 0.0, (), ()),) * 2,
)
monkeypatch.setattr(inference_module, "_hint_target_sizes_from_spacing", lambda width, height, spacing_x, spacing_y: [])
monkeypatch.setattr(inference_module, "_estimate_lattice_prior_details", lambda rgba, **kwargs: (4.0, 4.0, 0.5))
monkeypatch.setattr(
inference_module,
"_resolve_candidate_dims_from_spacing",
lambda *args, **kwargs: [(10, 8), (12, 10)],
)
monkeypatch.setattr(
inference_module,
"_score_phase_group",
lambda *args, **kwargs: [
inference_module.InferenceCandidate(
target_width=10,
target_height=8,
phase_x=0.0,
phase_y=0.0,
score=0.5,
breakdown={},
)
],
)
with pytest.raises(PipelineCancelled):
inference_module.infer_lattice(np.zeros((16, 16, 4), dtype=np.float32), observer=CancelObserver())

View file

@ -1,8 +1,10 @@
from __future__ import annotations
import numpy as np
import pytest
from repixelizer.analysis import analyze_phase_field_source
from repixelizer.observe import PipelineCancelled
from repixelizer.phase_field import optimize_phase_field
from repixelizer.synthetic import fake_pixelize, make_emblem
from repixelizer.types import InferenceResult
@ -72,3 +74,37 @@ def test_optimize_phase_field_moves_off_zero_when_phase_is_wrong() -> None:
)
assert artifacts.stage_diagnostics["phase_field"]["mean_displacement_px"] > 0.01
def test_optimize_phase_field_honors_cooperative_cancellation() -> None:
source = make_emblem(12, 12)
inference = InferenceResult(
target_width=12,
target_height=12,
phase_x=0.0,
phase_y=0.0,
confidence=1.0,
top_candidates=[],
)
class CancelObserver:
def __call__(self, event: str, payload: dict[str, object]) -> None:
del event, payload
def __init__(self) -> None:
self.calls = 0
def check_cancelled(self) -> bool:
self.calls += 1
return self.calls > 1
with pytest.raises(PipelineCancelled):
optimize_phase_field(
source,
inference=inference,
analysis=analyze_phase_field_source(source, seed=7),
steps=4,
seed=7,
device="cpu",
observer=CancelObserver(),
)

View file

@ -4,12 +4,14 @@ import hashlib
from pathlib import Path
import numpy as np
import pytest
from repixelizer.analysis import analyze_phase_field_source
from repixelizer.baselines import naive_resize_baseline
from repixelizer.metrics import coherence_breakdown, foreground_reconstruction_error, source_lattice_consistency_breakdown
from repixelizer.params import SolverHyperParams
from repixelizer.pipeline import _run_reconstruction, _select_phase_candidate, run_pipeline
from repixelizer.observe import PipelineCancelled
from repixelizer.phase_field import optimize_phase_field
from repixelizer.io import nearest_resize
from repixelizer.synthetic import fake_pixelize, make_emblem, make_sprite
@ -220,6 +222,38 @@ def test_phase_rerank_preview_respects_requested_zero_steps(monkeypatch) -> None
_select_phase_candidate(source, inference, analysis=object(), steps=0, seed=7, device="cpu")
def test_phase_rerank_honors_cooperative_cancellation(monkeypatch) -> None:
source = np.zeros((2, 2, 4), dtype=np.float32)
candidate_a = InferenceCandidate(target_width=2, target_height=2, phase_x=-0.4, phase_y=0.2, score=0.91, breakdown={})
candidate_b = InferenceCandidate(target_width=2, target_height=2, phase_x=0.4, phase_y=0.2, score=0.909, breakdown={})
inference = InferenceResult(
target_width=2,
target_height=2,
phase_x=candidate_a.phase_x,
phase_y=candidate_a.phase_y,
confidence=0.1,
top_candidates=[candidate_a, candidate_b],
)
class CancelObserver:
def __call__(self, event: str, payload: dict[str, object]) -> None:
del event, payload
def check_cancelled(self) -> bool:
return True
with pytest.raises(PipelineCancelled):
_select_phase_candidate(
source,
inference,
analysis=object(),
steps=8,
seed=7,
device="cpu",
observer=CancelObserver(),
)
def test_phase_rerank_can_override_to_better_size_candidate(monkeypatch) -> None:
source = np.zeros((16, 16, 4), dtype=np.float32)
candidate_a = InferenceCandidate(target_width=16, target_height=16, phase_x=0.0, phase_y=0.0, score=0.91, breakdown={})