repixelizer/frontend/src/app.ts
2026-04-29 17:58:25 +01:00

2090 lines
71 KiB
TypeScript

type JsonRecord = Record<string, unknown>;
type ImageAsset = {
dataUrl: string;
width: number;
height: number;
};
type InferenceCandidate = {
target_width: number;
target_height: number;
score: number;
breakdown: Record<string, number | null | undefined>;
};
type InferencePayload = {
target_width: number;
target_height: number;
confidence: number;
top_candidates: InferenceCandidate[];
};
type FramePayload = {
step: number;
totalSteps: number;
loss: number | null;
terms: Record<string, number>;
solverMetrics: Record<string, number>;
outputImage: ImageAsset;
samplingOverlayImage: ImageAsset;
displacementImage: ImageAsset;
lossHistory: number[];
};
type LogItem = {
label: string;
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;
showQueuePanel: 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;
device: string | null;
currentTargetWidth: number | null;
currentTargetHeight: number | null;
bestScore: number | null;
};
type candidateRerankInfo = {
previewSteps: number;
candidateCount: number;
confidence: number;
completedCandidates: number;
currentCandidateIndex: number;
currentTargetWidth: number | null;
currentTargetHeight: number | null;
currentStep: number;
currentTotalSteps: number;
currentLoss: number | null;
};
type PhaseFieldPrepInfo = {
targetWidth: number;
targetHeight: number;
cellX: number;
cellY: number;
};
type ViewerPanelKey = "left" | "right";
type ViewerPanelState = {
asset: ImageAsset | null;
label: string;
};
type AppState = {
file: File | null;
jobId: string | null;
status: string;
stageKey: string;
stageLabel: string;
statusText: string;
sourceImage: ImageAsset | null;
preprocessedImage: ImageAsset | null;
latticeImage: ImageAsset | null;
guidanceImage: ImageAsset | null;
inference: InferencePayload | null;
inferenceMode: string | null;
latticeSearch: LatticeSearchInfo | null;
candidateRerank: candidateRerankInfo | null;
phaseFieldPrep: PhaseFieldPrepInfo | null;
frames: FramePayload[];
cleanupImage: ImageAsset | null;
heatmapImage: ImageAsset | null;
finalOutputImage: ImageAsset | 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[];
viewerInspectZoom: number;
paintColor: [number, number, number, number];
editorBaseAsset: ImageAsset | null;
editorDirty: boolean;
altHeld: boolean;
zoom: number;
showGrid: boolean;
};
type ViewportAnchor = {
clientX: number;
clientY: number;
};
function byId<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!element) {
throw new Error(`Missing element: ${id}`);
}
return element as 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");
const statusBadge = byId<HTMLSpanElement>("statusBadge");
const statusStage = byId<HTMLParagraphElement>("statusStage");
const statusText = byId<HTMLParagraphElement>("statusText");
const statusMetrics = byId<HTMLDivElement>("statusMetrics");
const inferenceSummary = byId<HTMLDivElement>("inferenceSummary");
const candidateList = byId<HTMLDivElement>("candidateList");
const eventLog = byId<HTMLDivElement>("eventLog");
const lossCanvas = byId<HTMLCanvasElement>("lossCanvas");
const leftCanvas = byId<HTMLCanvasElement>("leftCanvas");
const rightCanvas = byId<HTMLCanvasElement>("rightCanvas");
const leftVizLabel = byId<HTMLDivElement>("leftVizLabel");
const rightVizLabel = byId<HTMLDivElement>("rightVizLabel");
const inspectZoomInput = byId<HTMLSelectElement>("inspectZoomInput");
const paintSwatch = byId<HTMLDivElement>("paintSwatch");
const zoomInput = byId<HTMLInputElement>("zoomInput");
const zoomValue = byId<HTMLSpanElement>("zoomValue");
const gridToggle = byId<HTMLInputElement>("gridToggle");
const editorCanvas = byId<HTMLCanvasElement>("editorCanvas");
const editorGridCanvas = byId<HTMLCanvasElement>("editorGridCanvas");
const editorSurface = byId<HTMLDivElement>("editorSurface");
const editorCanvasSizeValue = byId<HTMLSpanElement>("editorCanvasSizeValue");
const resetEditorButton = byId<HTMLButtonElement>("resetEditorButton");
const downloadButton = byId<HTMLButtonElement>("downloadButton");
const targetSizeInput = byId<HTMLInputElement>("targetSizeInput");
const targetWidthInput = byId<HTMLInputElement>("targetWidthInput");
const targetHeightInput = byId<HTMLInputElement>("targetHeightInput");
const stepsInput = byId<HTMLInputElement>("stepsInput");
const seedInput = byId<HTMLInputElement>("seedInput");
const runControlsCopy = byId<HTMLParagraphElement>("runControlsCopy");
const queuePanel = byId<HTMLElement>("queuePanel");
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 paintInputs = {
r: byId<HTMLInputElement>("paintR"),
g: byId<HTMLInputElement>("paintG"),
b: byId<HTMLInputElement>("paintB"),
a: byId<HTMLInputElement>("paintA"),
};
const imageCache = new Map<string, Promise<HTMLImageElement>>();
const eventTypes = [
"job_state",
"job_canceled",
"queue_state",
"job_failed",
"stage_started",
"source_loaded",
"preprocess_completed",
"lattice_search_started",
"lattice_search_progress",
"inference_candidates_ready",
"candidate_rerank_started",
"candidate_rerank_candidate_started",
"candidate_rerank_candidate_step",
"candidate_rerank_candidate_completed",
"candidate_selection_completed",
"analysis_completed",
"phase_field_prepared",
"phase_field_initial",
"phase_field_step",
"phase_field_final",
"cleanup_completed",
"palette_completed",
"pipeline_completed",
] as const;
const state: AppState = {
file: null,
jobId: null,
status: "idle",
stageKey: "idle",
stageLabel: "Waiting for input",
statusText: "Choose a file, then run the machine.",
sourceImage: null,
preprocessedImage: null,
latticeImage: null,
guidanceImage: null,
inference: null,
inferenceMode: null,
latticeSearch: null,
candidateRerank: null,
phaseFieldPrep: null,
frames: [],
cleanupImage: null,
heatmapImage: null,
finalOutputImage: null,
runtimeConfig: null,
queueSummary: null,
queuePosition: null,
queueDepth: 0,
waitingCount: 0,
queueCapacity: 0,
heartbeatIntervalSeconds: 10,
staleAfterSeconds: 30,
solverStepBudget: 0,
lossAxisMax: null,
eventLog: [],
viewerInspectZoom: Number(inspectZoomInput.value),
paintColor: [255, 255, 255, 255],
editorBaseAsset: null,
editorDirty: false,
altHeld: false,
zoom: Number(zoomInput.value),
showGrid: gridToggle.checked,
};
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;
let panStartClientX = 0;
let panStartClientY = 0;
let panStartScrollLeft = 0;
let panStartScrollTop = 0;
let offscreenCanvas: HTMLCanvasElement | null = null;
let offscreenContext: CanvasRenderingContext2D | null = null;
let singlePixel = new ImageData(1, 1);
let viewerInspect: { panel: ViewerPanelKey; pointerId: number; xRatio: number; yRatio: number } | null = null;
let viewerRenderQueued = false;
let currentViewerPanels: Record<ViewerPanelKey, ViewerPanelState> = {
left: { asset: null, label: "Input" },
right: { asset: null, label: "Output" },
};
function clampByte(value: number): number {
return Math.max(0, Math.min(255, Math.round(value)));
}
function clampZoom(value: number): number {
const minZoom = Number(zoomInput.min) || 1;
const maxZoom = Number(zoomInput.max) || 64;
return Math.max(minZoom, Math.min(maxZoom, Math.round(value)));
}
function syncEditorCursorState(): void {
editorSurface.classList.toggle("is-panning", panning);
editorSurface.classList.toggle("is-eyedropper", state.altHeld);
}
function setPaintColor(color: [number, number, number, number]): void {
state.paintColor = color;
paintInputs.r.value = String(color[0]);
paintInputs.g.value = String(color[1]);
paintInputs.b.value = String(color[2]);
paintInputs.a.value = String(color[3]);
paintSwatch.style.background = `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`;
}
function addLog(label: string, detail: string): void {
state.eventLog.unshift({ label, detail });
state.eventLog = state.eventLog.slice(0, 8);
renderEventLog();
}
function renderEventLog(): void {
eventLog.innerHTML = "";
if (state.eventLog.length === 0) {
eventLog.innerHTML = `<p class="muted">Nothing to report yet.</p>`;
return;
}
for (const entry of state.eventLog) {
const node = document.createElement("div");
node.className = "info-card event-item";
node.innerHTML = `<strong>${entry.label}</strong><span>${entry.detail}</span>`;
eventLog.appendChild(node);
}
}
function renderQueueSummary(): void {
if (!state.runtimeConfig?.ui.showQueuePanel) {
queueSummaryPanel.innerHTML = "";
cancelJobButton.hidden = true;
return;
}
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();
}
function setJobState(status: string): void {
state.status = status;
statusBadge.textContent = formatStatusBadge(status);
statusPanel.dataset.jobState = status;
}
function setStage(stageKey: string, label: string, detail: string): void {
state.stageKey = stageKey;
state.stageLabel = label;
state.statusText = detail;
statusStage.textContent = label;
statusText.textContent = detail;
}
function hasActiveJob(): boolean {
return state.jobId !== null && ["queued", "running", "waiting"].includes(state.status);
}
function queueSummaryFromState(activeStatus: string | null = state.status): QueueSummary {
return {
queueDepth: state.queueDepth,
waitingCount: state.waitingCount,
queueCapacity: state.queueCapacity || (state.runtimeConfig?.limits.queueCapacity ?? 0),
hasActiveJob: state.queueDepth > 0,
activeStatus,
};
}
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;
queuePanel.hidden = !config.ui.showQueuePanel;
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;
if (!preserveSourceImage) {
state.sourceImage = null;
}
state.preprocessedImage = null;
state.latticeImage = null;
state.guidanceImage = null;
state.inference = null;
state.inferenceMode = null;
state.latticeSearch = null;
state.candidateRerank = null;
state.phaseFieldPrep = null;
state.frames = [];
state.cleanupImage = null;
state.heatmapImage = null;
state.finalOutputImage = null;
state.queuePosition = null;
state.queueDepth = 0;
state.waitingCount = 0;
state.solverStepBudget = 0;
state.lossAxisMax = null;
state.eventLog = [];
state.editorBaseAsset = null;
state.editorDirty = false;
}
function parseOptionalInteger(input: HTMLInputElement): number | null {
if (input.value.trim() === "") {
return null;
}
const value = Number(input.value);
return Number.isFinite(value) ? Math.round(value) : null;
}
function parseOptionalFloat(input: HTMLInputElement): number | null {
if (input.value.trim() === "") {
return null;
}
const value = Number(input.value);
return Number.isFinite(value) ? value : null;
}
async function loadImage(asset: ImageAsset): Promise<HTMLImageElement> {
const cached = imageCache.get(asset.dataUrl);
if (cached) {
return cached;
}
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load image asset"));
image.src = asset.dataUrl;
});
imageCache.set(asset.dataUrl, promise);
return promise;
}
async function drawAsset(canvas: HTMLCanvasElement, asset: ImageAsset | null): Promise<void> {
const context = canvas.getContext("2d");
if (!context) {
return;
}
if (!asset) {
canvas.width = 640;
canvas.height = 360;
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#081012";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "rgba(255,255,255,0.2)";
context.font = "16px sans-serif";
context.fillText("Waiting for image data", 24, 32);
return;
}
const image = await loadImage(asset);
canvas.width = image.naturalWidth || asset.width;
canvas.height = image.naturalHeight || asset.height;
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
}
async function drawAssetInspection(
canvas: HTMLCanvasElement,
asset: ImageAsset | null,
focus: { xRatio: number; yRatio: number },
referenceWidth: number,
): Promise<void> {
if (!asset) {
await drawAsset(canvas, asset);
return;
}
const context = canvas.getContext("2d");
if (!context) {
return;
}
const image = await loadImage(asset);
const rect = canvas.getBoundingClientRect();
const displayWidth = Math.max(1, Math.round(rect.width));
const displayHeight = Math.max(1, Math.round(rect.height));
if (displayWidth <= 0 || displayHeight <= 0) {
await drawAsset(canvas, asset);
return;
}
const scale = Math.max(1, Math.ceil(window.devicePixelRatio || 1));
canvas.width = displayWidth * scale;
canvas.height = displayHeight * scale;
context.setTransform(scale, 0, 0, scale, 0, 0);
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, displayWidth, displayHeight);
context.fillStyle = "#081012";
context.fillRect(0, 0, displayWidth, displayHeight);
const imageWidth = image.naturalWidth || asset.width;
const imageHeight = image.naturalHeight || asset.height;
const zoomMultiplier = Math.max(1, state.viewerInspectZoom || 1);
const inspectPixelScale = zoomMultiplier * Math.max(1, referenceWidth / Math.max(1, imageWidth));
const viewportWidth = Math.max(1, Math.min(Math.floor(displayWidth / inspectPixelScale), imageWidth));
const viewportHeight = Math.max(1, Math.min(Math.floor(displayHeight / inspectPixelScale), imageHeight));
const destWidth = Math.min(displayWidth, viewportWidth * inspectPixelScale);
const destHeight = Math.min(displayHeight, viewportHeight * inspectPixelScale);
const focusX = Math.max(0, Math.min(1, focus.xRatio));
const focusY = Math.max(0, Math.min(1, focus.yRatio));
const centerX = imageWidth <= viewportWidth ? imageWidth * 0.5 : focusX * imageWidth;
const centerY = imageHeight <= viewportHeight ? imageHeight * 0.5 : focusY * imageHeight;
const sourceX = Math.max(0, Math.min(imageWidth - viewportWidth, Math.round(centerX - viewportWidth * 0.5)));
const sourceY = Math.max(0, Math.min(imageHeight - viewportHeight, Math.round(centerY - viewportHeight * 0.5)));
const destX = Math.floor((displayWidth - destWidth) * 0.5);
const destY = Math.floor((displayHeight - destHeight) * 0.5);
context.drawImage(
image,
sourceX,
sourceY,
viewportWidth,
viewportHeight,
destX,
destY,
destWidth,
destHeight,
);
}
async function fileToImageAsset(file: File): Promise<ImageAsset> {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== "string") {
reject(new Error("Failed to read image preview."));
return;
}
resolve(reader.result);
};
reader.onerror = () => reject(reader.error ?? new Error("Failed to read image preview."));
reader.readAsDataURL(file);
});
const image = await loadImage({ dataUrl, width: 1, height: 1 });
return {
dataUrl,
width: image.naturalWidth || 1,
height: image.naturalHeight || 1,
};
}
function formatNumber(value: number | null, digits = 3): string {
if (value === null || !Number.isFinite(value)) {
return "n/a";
}
return value.toFixed(digits);
}
type StatusMetricItem = {
label: string;
value: string;
};
function formatGridValue(width: number | null | undefined, height: number | null | undefined, fallback = "pending"): string {
if (!width || !height) {
return fallback;
}
return `${width} x ${height}`;
}
function renderStatusMetricItems(items: StatusMetricItem[]): void {
statusMetrics.innerHTML = "";
statusMetrics.style.setProperty("--status-metric-count", String(Math.max(items.length, 1)));
for (const { label, value } of items) {
const node = document.createElement("div");
node.className = "info-card status-metric";
node.title = `${label}: ${value}`;
node.innerHTML = `<strong>${label}</strong><span>${value}</span>`;
statusMetrics.appendChild(node);
}
}
function getSelectedFrame(): FramePayload | null {
if (state.frames.length === 0) {
return null;
}
return state.frames[state.frames.length - 1];
}
function renderInference(): void {
if (!state.inference) {
inferenceSummary.innerHTML = `<p class="muted">No lattice picked yet.</p>`;
candidateList.innerHTML = "";
return;
}
inferenceSummary.innerHTML = `
<div class="info-card summary-card">
<strong>Chosen Grid</strong>
<span>${state.inference.target_width} x ${state.inference.target_height}</span>
</div>
<div class="info-card summary-card">
<strong>Phase</strong>
</div>
<div class="info-card summary-card">
<strong>Confidence</strong>
<span>${formatNumber(state.inference.confidence, 3)}</span>
</div>
`;
candidateList.innerHTML = "";
const topCandidates = state.inference.top_candidates.slice(0, 6);
for (const candidate of topCandidates) {
const node = document.createElement("div");
node.className = "info-card candidate-card";
const rerank = candidate.breakdown["candidate_rerank_rank"];
node.innerHTML = `
<strong>${candidate.target_width} x ${candidate.target_height}</strong>
<span>score ${formatNumber(candidate.score, 3)}${typeof rerank === "number" ? ` • rerank #${Math.round(rerank)}` : ""}</span>
`;
candidateList.appendChild(node);
}
}
function renderStatusMetrics(): void {
const items: StatusMetricItem[] = [];
const frame = getSelectedFrame();
if (state.stageKey === "queued" || state.status === "queued" || state.status === "waiting") {
if (state.runtimeConfig?.ui.showQueuePanel) {
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: "Grid",
value: formatGridValue(state.latticeSearch.currentTargetWidth, state.latticeSearch.currentTargetHeight),
});
items.push({ label: "Device", value: state.latticeSearch.device ?? "pending" });
items.push({ label: "Best", value: formatNumber(state.latticeSearch.bestScore, 3) });
}
} else if (state.stageKey === "analysis" || state.stageKey === "selection") {
if (state.inference) {
items.push({ label: "Mode", value: state.inferenceMode === "fixed" ? "pinned" : state.inferenceMode ?? "search" });
items.push({ label: "Grid", value: `${state.inference.target_width} x ${state.inference.target_height}` });
items.push({ label: "Confidence", value: formatNumber(state.inference.confidence, 3) });
}
} else if (state.stageKey === "rerank") {
if (state.candidateRerank) {
items.push({
label: "Done",
value: `${state.candidateRerank.completedCandidates} / ${state.candidateRerank.candidateCount}`,
});
items.push({
label: "Candidate",
value:
state.candidateRerank.currentCandidateIndex > 0
? `${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}`
: `0 / ${state.candidateRerank.candidateCount}`,
});
items.push({
label: "Preview",
value:
state.candidateRerank.currentTotalSteps > 0
? `${state.candidateRerank.currentStep} / ${state.candidateRerank.currentTotalSteps}`
: state.candidateRerank.previewSteps > 0
? `0 / ${state.candidateRerank.previewSteps}`
: "skipped",
});
items.push({
label: "Grid",
value: formatGridValue(state.candidateRerank.currentTargetWidth, state.candidateRerank.currentTargetHeight),
});
items.push({ label: "Loss", value: formatNumber(state.candidateRerank.currentLoss, 4) });
items.push({ label: "Confidence", value: formatNumber(state.candidateRerank.confidence, 3) });
}
} else if (state.stageKey === "solver") {
const solverTerms: StatusMetricItem[] = [
{ label: "Coherence", value: formatNumber(frame?.terms.local_coherence ?? null, 4) },
{ label: "Edge", value: formatNumber(frame?.terms.local_edge ?? null, 4) },
{ label: "Smoothness", value: formatNumber(frame?.terms.smoothness ?? null, 4) },
{ label: "Collapse", value: formatNumber(frame?.terms.collapse ?? null, 4) },
{ label: "Magnitude", value: formatNumber(frame?.terms.magnitude ?? null, 4) },
];
items.push({
label: "Grid",
value: state.phaseFieldPrep ? `${state.phaseFieldPrep.targetWidth} x ${state.phaseFieldPrep.targetHeight}` : "pending",
});
items.push({
label: "Step",
value: frame ? `${frame.step} / ${frame.totalSteps}` : `0 / ${Math.max(0, state.solverStepBudget)}`,
});
items.push({
label: "Cell pitch",
value: state.phaseFieldPrep
? `${formatNumber(state.phaseFieldPrep.cellX, 1)}x${formatNumber(state.phaseFieldPrep.cellY, 1)} px`
: "pending",
});
items.push({ label: "Loss", value: frame?.loss === null || frame?.loss === undefined ? "n/a" : formatNumber(frame.loss, 4) });
items.push(...solverTerms);
}
renderStatusMetricItems(items);
}
function resolveViewerPanels(): Record<ViewerPanelKey, ViewerPanelState> {
const frame = getSelectedFrame();
const debugViewerStage = new Set(["inference", "analysis", "selection", "rerank", "solver", "cleanup"]);
const showDebugViewer = debugViewerStage.has(state.stageKey);
let leftAsset = state.sourceImage;
let rightAsset = state.finalOutputImage ?? state.cleanupImage ?? state.sourceImage;
let leftLabel = "Input";
let rightLabel = "Output";
if (showDebugViewer) {
leftAsset = state.preprocessedImage ?? state.sourceImage;
if (state.latticeImage) {
leftAsset = state.latticeImage;
leftLabel = "Lattice Prep";
}
if (state.guidanceImage) {
leftAsset = state.guidanceImage;
leftLabel = "Guidance";
}
if (frame) {
leftAsset = frame.samplingOverlayImage;
rightAsset = frame.outputImage;
leftLabel = "Sampling Overlay";
rightLabel = "Current Output";
} else if (state.cleanupImage) {
if (state.heatmapImage) {
leftAsset = state.heatmapImage;
leftLabel = "Cleanup Heatmap";
}
rightAsset = state.cleanupImage;
rightLabel = "Cleaned Output";
}
}
return {
left: { asset: leftAsset, label: leftLabel },
right: { asset: rightAsset, label: rightLabel },
};
}
function syncViewerInspectCursor(): void {
const inspecting = viewerInspect !== null;
leftCanvas.classList.toggle("is-inspecting", inspecting);
rightCanvas.classList.toggle("is-inspecting", inspecting);
}
function scheduleViewerRender(): void {
if (viewerRenderQueued) {
return;
}
viewerRenderQueued = true;
requestAnimationFrame(() => {
viewerRenderQueued = false;
void renderViewer();
});
}
function getInspectReferenceWidth(panels: Record<ViewerPanelKey, ViewerPanelState>): number {
const candidateWidths = [
state.sourceImage?.width ?? 0,
panels.left.asset?.width ?? 0,
panels.right.asset?.width ?? 0,
].filter((value) => value > 0);
return candidateWidths.length > 0 ? Math.max(...candidateWidths) : 1;
}
async function renderViewer(): Promise<void> {
currentViewerPanels = resolveViewerPanels();
leftVizLabel.textContent = currentViewerPanels.left.label;
rightVizLabel.textContent = currentViewerPanels.right.label;
const inspectReferenceWidth = getInspectReferenceWidth(currentViewerPanels);
await Promise.all([
viewerInspect
? drawAssetInspection(leftCanvas, currentViewerPanels.left.asset, viewerInspect, inspectReferenceWidth)
: drawAsset(leftCanvas, currentViewerPanels.left.asset),
viewerInspect
? drawAssetInspection(rightCanvas, currentViewerPanels.right.asset, viewerInspect, inspectReferenceWidth)
: drawAsset(rightCanvas, currentViewerPanels.right.asset),
]);
}
function drawWrappedCanvasText(
context: CanvasRenderingContext2D,
text: string,
options: { x: number; y: number; maxWidth: number; lineHeight: number },
): void {
const { x, y, maxWidth, lineHeight } = options;
const words = text.split(/\s+/);
let line = "";
let cursorY = y;
for (const word of words) {
const nextLine = line ? `${line} ${word}` : word;
if (line && context.measureText(nextLine).width > maxWidth) {
context.fillText(line, x, cursorY);
line = word;
cursorY += lineHeight;
continue;
}
line = nextLine;
}
if (line) {
context.fillText(line, x, cursorY);
}
}
function renderLossPlaceholder(context: CanvasRenderingContext2D, width: number, heading: string, detail: string): void {
context.textBaseline = "top";
context.fontKerning = "none";
context.fillStyle = "rgba(255, 216, 74, 0.9)";
context.font = "12px 'Press Start 2P', monospace";
context.fillText(heading.toUpperCase(), 20, 14);
context.fillStyle = "rgba(255,255,255,0.42)";
context.font = "20px 'VT323', monospace";
drawWrappedCanvasText(context, detail, { x: 20, y: 38, maxWidth: width - 40, lineHeight: 21 });
}
function getLossDomainMax(): number {
const frameDomain = state.frames.reduce((best, frame) => Math.max(best, frame.totalSteps), 0);
if (state.solverStepBudget > 0) {
return Math.max(1, state.solverStepBudget);
}
return Math.max(1, frameDomain);
}
function buildLossSamples(): Array<{ step: number; loss: number }> {
return state.frames
.filter((frame) => typeof frame.loss === "number" && Number.isFinite(frame.loss))
.map((frame) => ({ step: frame.step, loss: frame.loss as number }))
.sort((left, right) => left.step - right.step);
}
function formatTickLabel(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
function buildStepTicks(domainMax: number): number[] {
if (domainMax <= 1) {
return [0, domainMax];
}
const quarter = domainMax / 4;
const ticks = [0, quarter, quarter * 2, quarter * 3, domainMax];
const rounded = ticks.map((tick, index) => (index === 0 || index === ticks.length - 1 ? tick : Math.round(tick)));
return rounded.filter((tick, index) => rounded.findIndex((candidate) => Math.abs(candidate - tick) < 1e-6) === index);
}
function lossCanvasScale(): number {
return Math.max(1, Math.ceil(window.devicePixelRatio || 1));
}
function configureLossCanvas(): { context: CanvasRenderingContext2D; width: number; height: number } | null {
const context = lossCanvas.getContext("2d");
if (!context) {
return null;
}
const rect = lossCanvas.getBoundingClientRect();
const width = Math.max(1, Math.round(rect.width));
const height = Math.max(1, Math.round(rect.height));
if (width <= 0 || height <= 0) {
return null;
}
const scale = lossCanvasScale();
const pixelWidth = width * scale;
const pixelHeight = height * scale;
if (lossCanvas.width !== pixelWidth || lossCanvas.height !== pixelHeight) {
lossCanvas.width = pixelWidth;
lossCanvas.height = pixelHeight;
}
context.setTransform(scale, 0, 0, scale, 0, 0);
context.imageSmoothingEnabled = false;
context.clearRect(0, 0, width, height);
return { context, width, height };
}
function snapStroke(value: number): number {
return Math.round(value) + 0.5;
}
function snapText(value: number): number {
return Math.round(value);
}
function renderLossChart(): void {
lossCanvas.hidden = state.stageKey !== "solver";
if (lossCanvas.hidden) {
return;
}
const configuredCanvas = configureLossCanvas();
if (!configuredCanvas) {
return;
}
const { context, width, height } = configuredCanvas;
context.fontKerning = "none";
context.fillStyle = "rgba(7, 15, 17, 0.96)";
context.fillRect(0, 0, width, height);
const domainMax = getLossDomainMax();
const samples = buildLossSamples();
const observedMaxLoss = samples.length === 0 ? null : Math.max(...samples.map((sample) => sample.loss));
if (observedMaxLoss !== null) {
state.lossAxisMax = Math.max(state.lossAxisMax ?? 0, observedMaxLoss);
}
const axisMaxLoss = Math.max(0.001, state.lossAxisMax ?? observedMaxLoss ?? 1);
const maxLoss = axisMaxLoss * 1.05;
const minLoss = 0;
const chartLeft = 88;
const chartRight = width - 18;
const chartTop = 16;
const chartBottom = height - 42;
const chartWidth = chartRight - chartLeft;
const chartHeight = chartBottom - chartTop;
const span = Math.max(1e-6, maxLoss - minLoss);
const yTicks = 4;
context.strokeStyle = "rgba(255,255,255,0.08)";
context.lineWidth = 1;
context.font = "16px 'VT323', monospace";
context.fillStyle = "rgba(255,255,255,0.58)";
context.textAlign = "right";
context.textBaseline = "middle";
for (let index = 0; index <= yTicks; index += 1) {
const ratio = index / yTicks;
const y = snapStroke(chartTop + ratio * chartHeight);
context.beginPath();
context.moveTo(chartLeft, y);
context.lineTo(chartRight, y);
context.stroke();
const tickValue = maxLoss - ratio * span;
context.fillText(formatNumber(tickValue, 4), chartLeft - 10, snapText(y));
}
const stepTicks = buildStepTicks(Math.max(1, domainMax));
context.textAlign = "center";
context.textBaseline = "top";
for (const tick of stepTicks) {
const x = snapStroke(chartLeft + (tick / Math.max(1, domainMax)) * chartWidth);
context.beginPath();
context.moveTo(x, chartTop);
context.lineTo(x, chartBottom);
context.stroke();
context.fillText(formatTickLabel(tick), snapText(x), chartBottom + 8);
}
context.fillStyle = "rgba(255, 216, 74, 0.9)";
context.font = "12px 'Press Start 2P', monospace";
context.fillText(`SOLVER STEP (0-${domainMax})`, snapText(chartLeft + chartWidth * 0.5), height - 20);
context.save();
context.translate(26, snapText(chartTop + chartHeight * 0.5));
context.rotate(-Math.PI / 2);
context.fillText("LOSS", 0, 0);
context.restore();
context.strokeStyle = "rgba(255, 216, 74, 0.18)";
context.lineWidth = 1.5;
context.strokeRect(snapStroke(chartLeft), snapStroke(chartTop), Math.round(chartWidth), Math.round(chartHeight));
context.strokeStyle = "rgba(255, 216, 74, 0.42)";
context.lineWidth = 2;
context.beginPath();
context.moveTo(snapStroke(chartLeft), snapStroke(chartTop));
context.lineTo(snapStroke(chartLeft), snapStroke(chartBottom));
context.lineTo(snapStroke(chartRight), snapStroke(chartBottom));
context.stroke();
if (samples.length === 0) {
renderLossPlaceholder(
context,
width,
"Phase-field solve",
state.solverStepBudget <= 0
? "Solver steps were pinned to zero, so there is no loss walk to chart."
: "Loss samples land here once the solver starts moving.",
);
return;
}
context.strokeStyle = "#8fe2c8";
context.lineWidth = 3;
context.beginPath();
samples.forEach((sample, index) => {
const x = chartLeft + (sample.step / Math.max(1, domainMax)) * chartWidth;
const y = chartBottom - ((sample.loss - minLoss) / span) * chartHeight;
if (index === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
});
context.stroke();
const selected = getSelectedFrame();
if (selected && selected.loss !== null) {
const matchingIndex = samples.findIndex((sample) => sample.step === selected.step);
if (matchingIndex >= 0) {
const sample = samples[matchingIndex];
const x = chartLeft + (sample.step / Math.max(1, domainMax)) * chartWidth;
const y = chartBottom - (((selected.loss as number) - minLoss) / span) * chartHeight;
context.fillStyle = "#f5b76b";
context.beginPath();
context.arc(x, y, 5, 0, Math.PI * 2);
context.fill();
}
}
}
async function loadEditorAsset(asset: ImageAsset | null): Promise<void> {
state.editorBaseAsset = asset;
state.editorDirty = false;
if (!asset) {
offscreenCanvas = null;
offscreenContext = null;
renderEditor();
return;
}
const image = await loadImage(asset);
offscreenCanvas = document.createElement("canvas");
offscreenCanvas.width = image.naturalWidth || asset.width;
offscreenCanvas.height = image.naturalHeight || asset.height;
offscreenContext = offscreenCanvas.getContext("2d");
if (!offscreenContext) {
return;
}
offscreenContext.imageSmoothingEnabled = false;
offscreenContext.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);
offscreenContext.drawImage(image, 0, 0, offscreenCanvas.width, offscreenCanvas.height);
renderEditor();
}
function renderEditor(): void {
const displayContext = editorCanvas.getContext("2d");
const gridContext = editorGridCanvas.getContext("2d");
if (!displayContext || !gridContext) {
return;
}
if (!offscreenCanvas) {
editorCanvas.width = 1;
editorCanvas.height = 1;
editorGridCanvas.width = 1;
editorGridCanvas.height = 1;
editorCanvasSizeValue.textContent = "n/a";
return;
}
const width = offscreenCanvas.width * state.zoom;
const height = offscreenCanvas.height * state.zoom;
for (const canvas of [editorCanvas, editorGridCanvas]) {
canvas.width = width;
canvas.height = height;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
displayContext.imageSmoothingEnabled = false;
displayContext.clearRect(0, 0, width, height);
displayContext.drawImage(offscreenCanvas, 0, 0, width, height);
gridContext.clearRect(0, 0, width, height);
if (state.showGrid && state.zoom >= 8) {
gridContext.strokeStyle = "rgba(255,255,255,0.12)";
gridContext.lineWidth = 1;
for (let x = 0; x <= offscreenCanvas.width; x += 1) {
const xpos = x * state.zoom + 0.5;
gridContext.beginPath();
gridContext.moveTo(xpos, 0);
gridContext.lineTo(xpos, height);
gridContext.stroke();
}
for (let y = 0; y <= offscreenCanvas.height; y += 1) {
const ypos = y * state.zoom + 0.5;
gridContext.beginPath();
gridContext.moveTo(0, ypos);
gridContext.lineTo(width, ypos);
gridContext.stroke();
}
}
editorCanvasSizeValue.textContent = `${offscreenCanvas.width} x ${offscreenCanvas.height}`;
}
function setZoom(nextZoom: number, anchor: ViewportAnchor | null = null): void {
const clampedZoom = clampZoom(nextZoom);
const previousZoom = state.zoom;
if (clampedZoom === previousZoom) {
zoomInput.value = String(clampedZoom);
zoomValue.textContent = `${clampedZoom}x`;
return;
}
let viewportX = 0;
let viewportY = 0;
let imageX: number | null = null;
let imageY: number | null = null;
if (offscreenCanvas && anchor) {
const surfaceRect = editorSurface.getBoundingClientRect();
viewportX = anchor.clientX - surfaceRect.left;
viewportY = anchor.clientY - surfaceRect.top;
imageX = (editorSurface.scrollLeft + viewportX - editorCanvas.offsetLeft) / previousZoom;
imageY = (editorSurface.scrollTop + viewportY - editorCanvas.offsetTop) / previousZoom;
}
state.zoom = clampedZoom;
zoomInput.value = String(clampedZoom);
zoomValue.textContent = `${clampedZoom}x`;
renderEditor();
if (offscreenCanvas && imageX !== null && imageY !== null) {
const nextScrollLeft = imageX * clampedZoom + editorCanvas.offsetLeft - viewportX;
const nextScrollTop = imageY * clampedZoom + editorCanvas.offsetTop - viewportY;
const maxScrollLeft = Math.max(0, editorSurface.scrollWidth - editorSurface.clientWidth);
const maxScrollTop = Math.max(0, editorSurface.scrollHeight - editorSurface.clientHeight);
editorSurface.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft));
editorSurface.scrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop));
}
}
function editorPixelFromPointer(event: PointerEvent): { x: number; y: number } | null {
if (!offscreenCanvas) {
return null;
}
const rect = editorCanvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return null;
}
const x = Math.floor((event.clientX - rect.left) / state.zoom);
const y = Math.floor((event.clientY - rect.top) / state.zoom);
if (x < 0 || y < 0 || x >= offscreenCanvas.width || y >= offscreenCanvas.height) {
return null;
}
return { x, y };
}
function samplePaintColor(x: number, y: number): void {
if (!offscreenContext) {
return;
}
const pixel = offscreenContext.getImageData(x, y, 1, 1).data;
setPaintColor([pixel[0], pixel[1], pixel[2], pixel[3]]);
}
function paintPixel(x: number, y: number): void {
if (!offscreenContext) {
return;
}
singlePixel.data[0] = state.paintColor[0];
singlePixel.data[1] = state.paintColor[1];
singlePixel.data[2] = state.paintColor[2];
singlePixel.data[3] = state.paintColor[3];
offscreenContext.putImageData(singlePixel, x, y);
state.editorDirty = true;
renderEditor();
}
function exportEditorPng(): void {
if (!offscreenCanvas) {
return;
}
const link = document.createElement("a");
link.href = offscreenCanvas.toDataURL("image/png");
link.download = (state.file?.name?.replace(/\.[^.]+$/, "") || "repixelized") + "-edited.png";
link.click();
}
function beginViewerInspect(panel: ViewerPanelKey, event: PointerEvent): void {
if (event.button !== 0 || !currentViewerPanels[panel].asset) {
return;
}
event.preventDefault();
const canvas = panel === "left" ? leftCanvas : rightCanvas;
const rect = canvas.getBoundingClientRect();
const xRatio = rect.width <= 0 ? 0.5 : Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const yRatio = rect.height <= 0 ? 0.5 : Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
viewerInspect = {
panel,
pointerId: event.pointerId,
xRatio,
yRatio,
};
canvas.setPointerCapture(event.pointerId);
syncViewerInspectCursor();
scheduleViewerRender();
}
function updateViewerInspect(panel: ViewerPanelKey, event: PointerEvent): void {
if (!viewerInspect || viewerInspect.panel !== panel || viewerInspect.pointerId !== event.pointerId) {
return;
}
const canvas = panel === "left" ? leftCanvas : rightCanvas;
const rect = canvas.getBoundingClientRect();
viewerInspect.xRatio = rect.width <= 0 ? 0.5 : Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
viewerInspect.yRatio = rect.height <= 0 ? 0.5 : Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
scheduleViewerRender();
}
function endViewerInspect(pointerId: number | null = null): void {
if (!viewerInspect) {
return;
}
if (pointerId !== null && viewerInspect.pointerId !== pointerId) {
return;
}
const canvas = viewerInspect.panel === "left" ? leftCanvas : rightCanvas;
if (canvas.hasPointerCapture(viewerInspect.pointerId)) {
canvas.releasePointerCapture(viewerInspect.pointerId);
}
viewerInspect = null;
syncViewerInspectCursor();
scheduleViewerRender();
}
function beginPan(event: PointerEvent): void {
panning = true;
activePanPointerId = event.pointerId;
panStartClientX = event.clientX;
panStartClientY = event.clientY;
panStartScrollLeft = editorSurface.scrollLeft;
panStartScrollTop = editorSurface.scrollTop;
editorSurface.setPointerCapture(event.pointerId);
syncEditorCursorState();
}
function endPan(pointerId: number | null = null): void {
if (pointerId !== null && activePanPointerId !== pointerId) {
return;
}
if (activePanPointerId !== null && editorSurface.hasPointerCapture(activePanPointerId)) {
editorSurface.releasePointerCapture(activePanPointerId);
}
panning = false;
activePanPointerId = null;
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 (!state.runtimeConfig?.ui.showQueuePanel) {
state.queueSummary = null;
renderQueueSummary();
return;
}
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 (!state.runtimeConfig?.ui.showQueuePanel) {
stopQueuePolling();
return;
}
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);
}
}
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],
["steps", String(normalizedSteps)],
["seed", String(parseOptionalInteger(seedInput) ?? 7)],
["device", runtimeConfig?.hostedDemo ? "cpu" : deviceInput.value],
["strip_background", runtimeConfig?.hostedDemo ? "false" : stripBackgroundInput.checked ? "true" : "false"],
["skip_candidate_rerank", "false"],
];
for (const [key, value] of values) {
if (value !== null) {
data.set(key, value);
}
}
return data;
}
async function renderEverything(): Promise<void> {
renderQueueSummary();
renderInference();
renderStatusMetrics();
renderLossChart();
await renderViewer();
}
async function handleEvent(eventName: string, payload: JsonRecord): Promise<void> {
switch (eventName) {
case "job_state":
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 = queueSummaryFromState();
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();
await refreshQueueSummary().catch(() => undefined);
syncQueuePolling();
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();
await refreshQueueSummary().catch(() => undefined);
syncQueuePolling();
break;
case "stage_started":
setJobState("running");
setStage(
String(payload.stage ?? "running"),
String(payload.label ?? "Working"),
String(payload.detail ?? "The pipeline is chewing on the input."),
);
break;
case "source_loaded":
state.sourceImage = payload.sourceImage as ImageAsset;
addLog("Input", "Loaded the input image.");
break;
case "preprocess_completed":
state.preprocessedImage = payload.sourceImage as ImageAsset;
addLog("Preprocess", "Stripped edge-connected background noise.");
break;
case "lattice_search_started":
state.latticeSearch = {
candidateCount: Number(payload.candidateCount ?? 0),
completedCandidates: 0,
device: typeof payload.device === "string" ? payload.device : null,
currentTargetWidth: null,
currentTargetHeight: null,
bestScore: null,
};
setStage(
"inference",
"Lattice search",
`Testing ${state.latticeSearch.candidateCount} candidate sizes from canonical cell centers.`,
);
addLog("Search", `Started lattice search across ${String(payload.candidateCount)} size candidates.`);
break;
case "lattice_search_progress":
if (!state.latticeSearch) {
state.latticeSearch = {
candidateCount: Number(payload.totalCandidates ?? 0),
completedCandidates: 0,
device: null,
currentTargetWidth: null,
currentTargetHeight: null,
bestScore: null,
};
}
state.latticeSearch.completedCandidates = Number(payload.completedCandidates ?? state.latticeSearch.completedCandidates);
state.latticeSearch.candidateCount = Number(payload.totalCandidates ?? state.latticeSearch.candidateCount);
state.latticeSearch.currentTargetWidth = Number(payload.targetWidth ?? 0);
state.latticeSearch.currentTargetHeight = Number(payload.targetHeight ?? 0);
state.latticeSearch.bestScore =
payload.bestScore === null || payload.bestScore === undefined ? state.latticeSearch.bestScore : Number(payload.bestScore);
setStage(
"inference",
"Lattice search",
`Size ${state.latticeSearch.completedCandidates} / ${state.latticeSearch.candidateCount}: scored ${state.latticeSearch.currentTargetWidth} x ${state.latticeSearch.currentTargetHeight} from canonical cell centers.`,
);
break;
case "inference_candidates_ready":
state.inference = payload.inference as InferencePayload;
state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode;
addLog("Inference", "Scored candidate grid sizes.");
break;
case "candidate_rerank_started":
state.candidateRerank = {
previewSteps: Number(payload.previewSteps ?? 0),
candidateCount: Number(payload.candidateCount ?? 0),
confidence: Number(payload.confidence ?? 0),
completedCandidates: 0,
currentCandidateIndex: 0,
currentTargetWidth: null,
currentTargetHeight: null,
currentStep: 0,
currentTotalSteps: 0,
currentLoss: null,
};
setStage(
"rerank",
"Candidate rerank",
`Previewing ${state.candidateRerank.candidateCount} low-confidence candidates over ${state.candidateRerank.previewSteps} short solver steps.`,
);
addLog(
"Rerank",
`Running ${String(payload.previewSteps)} preview steps across ${String(payload.candidateCount)} low-confidence candidates.`,
);
break;
case "candidate_rerank_candidate_started":
if (state.candidateRerank) {
state.candidateRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.candidateRerank.currentCandidateIndex);
state.candidateRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
state.candidateRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
state.candidateRerank.currentStep = 0;
state.candidateRerank.currentTotalSteps = Number(payload.previewSteps ?? state.candidateRerank.previewSteps);
state.candidateRerank.currentLoss = null;
setStage(
"rerank",
"Candidate rerank",
`Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}: previewing ${state.candidateRerank.currentTargetWidth} x ${state.candidateRerank.currentTargetHeight}.`,
);
}
break;
case "candidate_rerank_candidate_step":
if (state.candidateRerank) {
state.candidateRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.candidateRerank.currentCandidateIndex);
state.candidateRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
state.candidateRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
state.candidateRerank.currentStep = Number(payload.step ?? 0);
state.candidateRerank.currentTotalSteps = Number(payload.totalSteps ?? 0);
state.candidateRerank.currentLoss =
payload.loss === null || payload.loss === undefined ? null : Number(payload.loss);
setStage(
"rerank",
"Candidate rerank",
state.candidateRerank.currentTotalSteps <= 0
? `Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}: preview solve skipped.`
: `Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount}, preview step ${state.candidateRerank.currentStep} / ${state.candidateRerank.currentTotalSteps} on ${state.candidateRerank.currentTargetWidth} x ${state.candidateRerank.currentTargetHeight}.`,
);
}
break;
case "candidate_rerank_candidate_completed":
if (state.candidateRerank) {
state.candidateRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.candidateRerank.currentCandidateIndex);
state.candidateRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
state.candidateRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
state.candidateRerank.completedCandidates = Number(payload.completedCandidates ?? state.candidateRerank.completedCandidates);
state.candidateRerank.currentStep = Number(payload.totalSteps ?? state.candidateRerank.currentStep);
state.candidateRerank.currentTotalSteps = Number(payload.totalSteps ?? state.candidateRerank.currentTotalSteps);
state.candidateRerank.currentLoss =
payload.finalLoss === null || payload.finalLoss === undefined ? state.candidateRerank.currentLoss : Number(payload.finalLoss);
setStage(
"rerank",
"Candidate rerank",
`Candidate ${state.candidateRerank.currentCandidateIndex} / ${state.candidateRerank.candidateCount} scored. ${state.candidateRerank.completedCandidates} of ${state.candidateRerank.candidateCount} previews finished.`,
);
}
break;
case "candidate_selection_completed":
state.inference = payload.inference as InferencePayload;
state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode;
addLog("Selection", "Committed to a lattice size.");
break;
case "analysis_completed":
addLog("Scout", "Built the edge map that tells the solver where the floorboards creak.");
break;
case "phase_field_prepared":
state.latticeImage = payload.latticeImage as ImageAsset;
state.guidanceImage = payload.guidanceImage as ImageAsset;
state.phaseFieldPrep = {
targetWidth: Number(payload.targetWidth ?? 0),
targetHeight: Number(payload.targetHeight ?? 0),
cellX: Number(payload.cellX ?? 0),
cellY: Number(payload.cellY ?? 0),
};
setStage(
"solver",
"Phase-field solve",
`Pinned ${state.phaseFieldPrep.targetWidth} x ${state.phaseFieldPrep.targetHeight} lattice. Solver is walking the field now.`,
);
addLog("Prep", `Locked ${String(payload.targetWidth)} x ${String(payload.targetHeight)} lattice centers.`);
break;
case "phase_field_initial":
case "phase_field_step":
case "phase_field_final": {
const frame = payload as unknown as FramePayload;
const existingIndex = state.frames.findIndex((candidate) => candidate.step === frame.step);
if (existingIndex >= 0) {
state.frames[existingIndex] = frame;
} else {
state.frames.push(frame);
state.frames.sort((a, b) => a.step - b.step);
}
setStage(
"solver",
"Phase-field solve",
frame.totalSteps <= 0
? "Initial placement committed. No iterative solver steps were requested."
: `Solver step ${frame.step} / ${frame.totalSteps}. Loss and drift terms stay live in this panel while it walks.`,
);
if (eventName === "phase_field_final") {
addLog("Solver", "Final nearest-input sample committed.");
}
break;
}
case "cleanup_completed":
state.cleanupImage = payload.cleanedImage as ImageAsset;
state.heatmapImage = payload.heatmapImage as ImageAsset;
setStage("cleanup", "Cleanup", "Sweeping isolated artifacts and smoothing the last stubborn junk.");
addLog("Cleanup", "Ran the local cleanup sweep.");
break;
case "palette_completed":
state.finalOutputImage = payload.outputImage as ImageAsset;
setStage("output", "Final output", "Writing the finished output and parking it in the editor.");
addLog("Output", "Final output image is ready.");
break;
case "pipeline_completed":
state.finalOutputImage = payload.outputImage as ImageAsset;
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);
await refreshQueueSummary().catch(() => undefined);
syncQueuePolling();
break;
default:
break;
}
await renderEverything();
}
function connectEventStream(jobId: string): void {
currentEventSource?.close();
const stream = new EventSource(`/api/jobs/${jobId}/events`);
currentEventSource = stream;
for (const eventType of eventTypes) {
stream.addEventListener(eventType, (raw) => {
const message = raw as MessageEvent<string>;
const payload = JSON.parse(message.data) as JsonRecord;
void handleEvent(eventType, payload);
});
}
stream.onerror = () => {
if (hasActiveJob()) {
setJobState("waiting");
setStage(state.stageKey, state.stageLabel, "Event stream hiccup. Heartbeats are still trying to keep the queue slot alive.");
}
};
}
async function startRun(): Promise<void> {
if (!state.file) {
setJobState("idle");
setStage("idle", "Waiting for input", "Pick a file first. The machine is not clairvoyant.");
return;
}
resetRunArtifacts({ preserveSourceImage: true });
renderEventLog();
runButton.disabled = true;
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", "Submitting the job and trying not to trip over our own shoelaces.");
addLog("Queued", `Starting ${state.file.name}.`);
await renderEverything();
try {
const response = await fetch("/api/jobs", {
method: "POST",
body: buildFormData(),
});
if (!response.ok) {
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 JobStateSnapshot;
state.jobId = payload.jobId;
applyJobSnapshot(payload);
state.queueSummary = queueSummaryFromState(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: File): Promise<void> {
state.file = file;
resetRunArtifacts();
dropzoneLabel.textContent = file.name;
setJobState("waiting");
setStage("loading_input", "Loading input", `Reading ${file.name} so the panels stop sitting around empty.`);
renderEventLog();
await renderEverything();
try {
const previewImage = await fileToImageAsset(file);
if (state.file !== file) {
return;
}
state.sourceImage = previewImage;
setJobState("idle");
setStage("ready", "Ready to run", `${file.name} is loaded. Hit run when you want the machine to start sweating.`);
} catch (error) {
if (state.file !== file) {
return;
}
state.sourceImage = null;
setJobState("failed");
setStage("failed", "Preview failed", error instanceof Error ? error.message : "Failed to load image preview.");
}
await renderEverything();
}
dropzone.addEventListener("click", () => fileInput.click());
pickFileButton.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (file) {
void acceptFile(file);
}
});
dropzone.addEventListener("dragover", (event) => {
event.preventDefault();
});
dropzone.addEventListener("drop", (event) => {
event.preventDefault();
const file = event.dataTransfer?.files?.[0];
if (file) {
void acceptFile(file);
}
});
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;
if (viewerInspect) {
scheduleViewerRender();
}
});
zoomInput.addEventListener("input", () => {
setZoom(Number(zoomInput.value));
});
gridToggle.addEventListener("change", () => {
state.showGrid = gridToggle.checked;
renderEditor();
});
for (const input of Object.values(paintInputs)) {
input.addEventListener("change", () => {
setPaintColor([
clampByte(Number(paintInputs.r.value)),
clampByte(Number(paintInputs.g.value)),
clampByte(Number(paintInputs.b.value)),
clampByte(Number(paintInputs.a.value)),
]);
});
}
window.addEventListener("keydown", (event) => {
if (event.key === "Alt") {
state.altHeld = true;
syncEditorCursorState();
}
});
window.addEventListener("keyup", (event) => {
if (event.key === "Alt") {
state.altHeld = false;
syncEditorCursorState();
}
});
window.addEventListener("blur", () => {
state.altHeld = false;
painting = false;
endViewerInspect();
endPan();
syncEditorCursorState();
});
window.addEventListener("pagehide", () => {
void cancelCurrentJob({ keepalive: true, silent: true });
});
for (const [panel, canvas] of [
["left", leftCanvas],
["right", rightCanvas],
] as const) {
canvas.addEventListener("pointerdown", (event) => {
beginViewerInspect(panel, event);
});
canvas.addEventListener("pointermove", (event) => {
updateViewerInspect(panel, event);
});
canvas.addEventListener("pointerup", (event) => {
endViewerInspect(event.pointerId);
});
canvas.addEventListener("pointercancel", (event) => {
endViewerInspect(event.pointerId);
});
}
editorSurface.addEventListener("mousedown", (event) => {
if (event.button === 1) {
event.preventDefault();
}
});
editorSurface.addEventListener("auxclick", (event) => {
if (event.button === 1) {
event.preventDefault();
}
});
editorSurface.addEventListener(
"wheel",
(event) => {
if (!offscreenCanvas) {
return;
}
event.preventDefault();
const delta = event.deltaY < 0 ? 1 : -1;
setZoom(state.zoom + delta, { clientX: event.clientX, clientY: event.clientY });
},
{ passive: false },
);
editorSurface.addEventListener("pointerdown", (event) => {
if (event.button !== 1 || !offscreenCanvas) {
return;
}
event.preventDefault();
beginPan(event);
});
editorSurface.addEventListener("pointermove", (event) => {
if (!panning || activePanPointerId !== event.pointerId) {
return;
}
event.preventDefault();
editorSurface.scrollLeft = panStartScrollLeft - (event.clientX - panStartClientX);
editorSurface.scrollTop = panStartScrollTop - (event.clientY - panStartClientY);
});
editorSurface.addEventListener("pointerup", (event) => {
endPan(event.pointerId);
});
editorSurface.addEventListener("pointercancel", (event) => {
endPan(event.pointerId);
});
editorCanvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) {
return;
}
const pixel = editorPixelFromPointer(event);
if (!pixel) {
return;
}
event.preventDefault();
editorCanvas.setPointerCapture(event.pointerId);
if (event.altKey || state.altHeld) {
samplePaintColor(pixel.x, pixel.y);
return;
}
painting = true;
paintPixel(pixel.x, pixel.y);
});
editorCanvas.addEventListener("pointermove", (event) => {
if (panning) {
return;
}
const pixel = editorPixelFromPointer(event);
if (!pixel) {
return;
}
if (event.altKey || state.altHeld) {
if (event.buttons === 1) {
samplePaintColor(pixel.x, pixel.y);
}
return;
}
if (painting && event.buttons === 1) {
paintPixel(pixel.x, pixel.y);
}
});
editorCanvas.addEventListener("pointerup", () => {
painting = false;
});
editorCanvas.addEventListener("pointerleave", () => {
painting = false;
});
resetEditorButton.addEventListener("click", () => {
void loadEditorAsset(state.editorBaseAsset);
});
downloadButton.addEventListener("click", () => {
exportEditorPng();
});
const lossCanvasResizeObserver = new ResizeObserver(() => {
renderLossChart();
});
lossCanvasResizeObserver.observe(lossCanvas);
void document.fonts.ready.then(() => {
renderLossChart();
});
window.addEventListener("resize", () => {
renderLossChart();
});
setPaintColor(state.paintColor);
syncEditorCursorState();
syncViewerInspectCursor();
zoomValue.textContent = `${state.zoom}x`;
renderEventLog();
async function initializeApp(): Promise<void> {
try {
await loadRuntimeConfig();
} catch (error) {
addLog("Config", error instanceof Error ? error.message : "Failed to load runtime config.");
}
if (state.runtimeConfig?.ui.showQueuePanel) {
try {
await refreshQueueSummary();
} catch (error) {
addLog("Queue", error instanceof Error ? error.message : "Failed to load queue summary.");
}
}
syncQueuePolling();
await renderEverything();
}
void initializeApp();