diff --git a/frontend/src/app.ts b/frontend/src/app.ts index bbe5e25..ec763f9 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -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(id: string): T { 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"); @@ -164,7 +212,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"); @@ -178,6 +231,8 @@ const paintInputs = { const imageCache = new Map>(); 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 = `${label}${value}`; + 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 = `

Checking queue status.

`; + 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 = `${label}${value}`; + 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): 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 { + 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 { + 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): 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 { + 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 { + 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 { + renderQueueSummary(); renderInference(); renderStatusMetrics(); renderLossChart(); @@ -1225,14 +1546,42 @@ async function renderEverything(): Promise { async function handleEvent(eventName: string, payload: JsonRecord): Promise { switch (eventName) { case "job_state": - setJobState(String(payload.status ?? "working")); + applyJobSnapshot(payload as Partial); + 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); + 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 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 { 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 { 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 { + 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(); diff --git a/src/repixelizer/gui.py b/src/repixelizer/gui.py index d003493..d391743 100644 --- a/src/repixelizer/gui.py +++ b/src/repixelizer/gui.py @@ -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") diff --git a/src/repixelizer/gui_static/app.js b/src/repixelizer/gui_static/app.js index 934c11b..9defe98 100644 --- a/src/repixelizer/gui_static/app.js +++ b/src/repixelizer/gui_static/app.js @@ -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 = `${label}${value}`; + 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 = `

Checking queue status.

`; + 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 = `${label}${value}`; + 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(); diff --git a/src/repixelizer/gui_static/index.html b/src/repixelizer/gui_static/index.html index 313063d..794d333 100644 --- a/src/repixelizer/gui_static/index.html +++ b/src/repixelizer/gui_static/index.html @@ -141,7 +141,7 @@

Input

-

Start with the ugly input.

+

Start with the ugly input.

@@ -156,11 +156,11 @@

Run Controls

-

Pin what you know. Search what you do not.

+

Pin what you know. Search what you do not.

-