Add hosted demo queue and limits
This commit is contained in:
parent
67ddcceff0
commit
a77612a2bb
13 changed files with 1821 additions and 89 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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={})
|
||||
|
|
|
|||
Loading…
Reference in a new issue