2167 lines
74 KiB
TypeScript
2167 lines
74 KiB
TypeScript
type JsonRecord = Record<string, unknown>;
|
|
|
|
type ImageAsset = {
|
|
dataUrl: string;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
type InferenceCandidate = {
|
|
target_width: number;
|
|
target_height: number;
|
|
phase_x: number;
|
|
phase_y: number;
|
|
score: number;
|
|
breakdown: Record<string, number | null | undefined>;
|
|
};
|
|
|
|
type InferencePayload = {
|
|
target_width: number;
|
|
target_height: number;
|
|
phase_x: number;
|
|
phase_y: number;
|
|
confidence: number;
|
|
top_candidates: InferenceCandidate[];
|
|
};
|
|
|
|
type FramePayload = {
|
|
step: number;
|
|
totalSteps: number;
|
|
loss: number | null;
|
|
terms: Record<string, number>;
|
|
phaseMetrics: 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;
|
|
phaseSampleCount: number;
|
|
device: string | null;
|
|
currentTargetWidth: number | null;
|
|
currentTargetHeight: number | null;
|
|
bestScore: number | null;
|
|
};
|
|
|
|
type PhaseRerankInfo = {
|
|
previewSteps: number;
|
|
candidateCount: number;
|
|
confidence: number;
|
|
completedCandidates: number;
|
|
currentCandidateIndex: number;
|
|
currentTargetWidth: number | null;
|
|
currentTargetHeight: number | null;
|
|
currentPhaseX: number | null;
|
|
currentPhaseY: 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;
|
|
phaseRerank: PhaseRerankInfo | null;
|
|
phaseFieldPrep: PhaseFieldPrepInfo | null;
|
|
frames: FramePayload[];
|
|
cleanupImage: ImageAsset | null;
|
|
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[];
|
|
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 editorMeta = byId<HTMLDivElement>("editorMeta");
|
|
const summaryPanel = byId<HTMLDivElement>("summaryPanel");
|
|
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 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 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 skipRerankInput = byId<HTMLInputElement>("skipRerankInput");
|
|
|
|
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",
|
|
"phase_rerank_started",
|
|
"phase_rerank_candidate_started",
|
|
"phase_rerank_candidate_step",
|
|
"phase_rerank_candidate_completed",
|
|
"phase_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,
|
|
phaseRerank: null,
|
|
phaseFieldPrep: null,
|
|
frames: [],
|
|
cleanupImage: null,
|
|
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: [],
|
|
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.phaseRerank = null;
|
|
state.phaseFieldPrep = null;
|
|
state.frames = [];
|
|
state.cleanupImage = null;
|
|
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 = [];
|
|
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 },
|
|
): 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 viewportWidth = Math.max(1, Math.min(Math.floor(displayWidth / zoomMultiplier), imageWidth));
|
|
const viewportHeight = Math.max(1, Math.min(Math.floor(displayHeight / zoomMultiplier), imageHeight));
|
|
const destWidth = Math.min(displayWidth, viewportWidth * zoomMultiplier);
|
|
const destHeight = Math.min(displayHeight, viewportHeight * zoomMultiplier);
|
|
const focusX = Math.max(0, Math.min(1, focus.xRatio));
|
|
const focusY = Math.max(0, Math.min(1, focus.yRatio));
|
|
const centerX = imageWidth <= displayWidth ? imageWidth * 0.5 : focusX * imageWidth;
|
|
const centerY = imageHeight <= displayHeight ? 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 readNestedNumber(root: JsonRecord | null, path: string[]): number | null {
|
|
let current: unknown = root;
|
|
for (const key of path) {
|
|
if (!current || typeof current !== "object" || !(key in current)) {
|
|
return null;
|
|
}
|
|
current = (current as JsonRecord)[key];
|
|
}
|
|
return typeof current === "number" ? current : null;
|
|
}
|
|
|
|
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 formatPhaseValue(phaseX: number | null | undefined, phaseY: number | null | undefined, fallback = "pending"): string {
|
|
if (phaseX === null || phaseX === undefined || phaseY === null || phaseY === undefined) {
|
|
return fallback;
|
|
}
|
|
return `${formatNumber(phaseX, 2)}, ${formatNumber(phaseY, 2)}`;
|
|
}
|
|
|
|
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>
|
|
<span>${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}</span>
|
|
</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["phase_rerank_rank"];
|
|
node.innerHTML = `
|
|
<strong>${candidate.target_width} x ${candidate.target_height}</strong>
|
|
<span>phase ${formatNumber(candidate.phase_x, 2)}, ${formatNumber(candidate.phase_y, 2)}</span><br />
|
|
<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: "Phases", value: String(state.latticeSearch.phaseSampleCount) });
|
|
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: "Phase", value: `${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}` });
|
|
items.push({ label: "Confidence", value: formatNumber(state.inference.confidence, 3) });
|
|
}
|
|
} else if (state.stageKey === "rerank") {
|
|
if (state.phaseRerank) {
|
|
items.push({
|
|
label: "Done",
|
|
value: `${state.phaseRerank.completedCandidates} / ${state.phaseRerank.candidateCount}`,
|
|
});
|
|
items.push({
|
|
label: "Candidate",
|
|
value:
|
|
state.phaseRerank.currentCandidateIndex > 0
|
|
? `${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}`
|
|
: `0 / ${state.phaseRerank.candidateCount}`,
|
|
});
|
|
items.push({
|
|
label: "Preview",
|
|
value:
|
|
state.phaseRerank.currentTotalSteps > 0
|
|
? `${state.phaseRerank.currentStep} / ${state.phaseRerank.currentTotalSteps}`
|
|
: state.phaseRerank.previewSteps > 0
|
|
? `0 / ${state.phaseRerank.previewSteps}`
|
|
: "skipped",
|
|
});
|
|
items.push({
|
|
label: "Grid",
|
|
value: formatGridValue(state.phaseRerank.currentTargetWidth, state.phaseRerank.currentTargetHeight),
|
|
});
|
|
items.push({
|
|
label: "Phase",
|
|
value: formatPhaseValue(state.phaseRerank.currentPhaseX, state.phaseRerank.currentPhaseY),
|
|
});
|
|
items.push({ label: "Loss", value: formatNumber(state.phaseRerank.currentLoss, 4) });
|
|
items.push({ label: "Confidence", value: formatNumber(state.phaseRerank.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 renderSummary(): void {
|
|
summaryPanel.innerHTML = "";
|
|
if (!state.runSummary) {
|
|
summaryPanel.innerHTML = `<p class="muted">Final metrics show up here after the run finishes.</p>`;
|
|
return;
|
|
}
|
|
const entries: Array<[string, number | null]> = [
|
|
["Structure score", readNestedNumber(state.runSummary, ["source_structure", "score"])],
|
|
["Edge F1", readNestedNumber(state.runSummary, ["source_structure", "edge_f1"])],
|
|
["Exact match", readNestedNumber(state.runSummary, ["source_structure", "exact_match_ratio"])],
|
|
["Final fidelity", readNestedNumber(state.runSummary, ["source_fidelity", "final_output", "score"])],
|
|
["Mean displacement", readNestedNumber(state.runSummary, ["optimizer_displacement", "final_output", "mean_magnitude_px"])],
|
|
];
|
|
for (const [label, value] of entries) {
|
|
const card = document.createElement("div");
|
|
card.className = "info-card summary-card";
|
|
card.innerHTML = `<strong>${label}</strong><span>${formatNumber(value, 3)}</span>`;
|
|
summaryPanel.appendChild(card);
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
async function renderViewer(): Promise<void> {
|
|
currentViewerPanels = resolveViewerPanels();
|
|
leftVizLabel.textContent = currentViewerPanels.left.label;
|
|
rightVizLabel.textContent = currentViewerPanels.right.label;
|
|
await Promise.all([
|
|
viewerInspect
|
|
? drawAssetInspection(leftCanvas, currentViewerPanels.left.asset, viewerInspect)
|
|
: drawAsset(leftCanvas, currentViewerPanels.left.asset),
|
|
viewerInspect
|
|
? drawAssetInspection(rightCanvas, currentViewerPanels.right.asset, viewerInspect)
|
|
: 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;
|
|
editorMeta.innerHTML = `<p class="muted">Final output lands here once the solver finishes its little pilgrimage.</p>`;
|
|
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();
|
|
}
|
|
}
|
|
|
|
editorMeta.innerHTML = `
|
|
<div class="info-card summary-card">
|
|
<strong>Canvas</strong>
|
|
<span>${offscreenCanvas.width} x ${offscreenCanvas.height}</span>
|
|
</div>
|
|
<div class="info-card summary-card">
|
|
<strong>State</strong>
|
|
<span>${state.editorDirty ? "Edited" : "Matches solver output"}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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);
|
|
}
|
|
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(normalizedSteps)],
|
|
["seed", String(parseOptionalInteger(seedInput) ?? 7)],
|
|
["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) {
|
|
if (value !== null) {
|
|
data.set(key, value);
|
|
}
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function renderEverything(): Promise<void> {
|
|
renderQueueSummary();
|
|
renderInference();
|
|
renderStatusMetrics();
|
|
renderLossChart();
|
|
renderSummary();
|
|
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,
|
|
phaseSampleCount: Number(payload.phaseSampleCount ?? 0),
|
|
device: typeof payload.device === "string" ? payload.device : null,
|
|
currentTargetWidth: null,
|
|
currentTargetHeight: null,
|
|
bestScore: null,
|
|
};
|
|
setStage(
|
|
"inference",
|
|
"Lattice search",
|
|
`Testing ${state.latticeSearch.candidateCount} size candidates with ${state.latticeSearch.phaseSampleCount} phase samples each.`,
|
|
);
|
|
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,
|
|
phaseSampleCount: Number(payload.phaseSampleCount ?? 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.phaseSampleCount = Number(payload.phaseSampleCount ?? state.latticeSearch.phaseSampleCount);
|
|
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} over ${state.latticeSearch.phaseSampleCount} phase samples.`,
|
|
);
|
|
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 grids and phase offsets.");
|
|
break;
|
|
case "phase_rerank_started":
|
|
state.phaseRerank = {
|
|
previewSteps: Number(payload.previewSteps ?? 0),
|
|
candidateCount: Number(payload.candidateCount ?? 0),
|
|
confidence: Number(payload.confidence ?? 0),
|
|
completedCandidates: 0,
|
|
currentCandidateIndex: 0,
|
|
currentTargetWidth: null,
|
|
currentTargetHeight: null,
|
|
currentPhaseX: null,
|
|
currentPhaseY: null,
|
|
currentStep: 0,
|
|
currentTotalSteps: 0,
|
|
currentLoss: null,
|
|
};
|
|
setStage(
|
|
"rerank",
|
|
"Phase rerank",
|
|
`Previewing ${state.phaseRerank.candidateCount} low-confidence candidates over ${state.phaseRerank.previewSteps} short solver steps.`,
|
|
);
|
|
addLog(
|
|
"Rerank",
|
|
`Running ${String(payload.previewSteps)} preview steps across ${String(payload.candidateCount)} low-confidence candidates.`,
|
|
);
|
|
break;
|
|
case "phase_rerank_candidate_started":
|
|
if (state.phaseRerank) {
|
|
state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex);
|
|
state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
|
state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
|
state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0);
|
|
state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0);
|
|
state.phaseRerank.currentStep = 0;
|
|
state.phaseRerank.currentTotalSteps = Number(payload.previewSteps ?? state.phaseRerank.previewSteps);
|
|
state.phaseRerank.currentLoss = null;
|
|
setStage(
|
|
"rerank",
|
|
"Phase rerank",
|
|
`Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}: previewing ${state.phaseRerank.currentTargetWidth} x ${state.phaseRerank.currentTargetHeight} at phase ${formatNumber(state.phaseRerank.currentPhaseX, 2)}, ${formatNumber(state.phaseRerank.currentPhaseY, 2)}.`,
|
|
);
|
|
}
|
|
break;
|
|
case "phase_rerank_candidate_step":
|
|
if (state.phaseRerank) {
|
|
state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex);
|
|
state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
|
state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
|
state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0);
|
|
state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0);
|
|
state.phaseRerank.currentStep = Number(payload.step ?? 0);
|
|
state.phaseRerank.currentTotalSteps = Number(payload.totalSteps ?? 0);
|
|
state.phaseRerank.currentLoss =
|
|
payload.loss === null || payload.loss === undefined ? null : Number(payload.loss);
|
|
setStage(
|
|
"rerank",
|
|
"Phase rerank",
|
|
state.phaseRerank.currentTotalSteps <= 0
|
|
? `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}: preview solve skipped.`
|
|
: `Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount}, preview step ${state.phaseRerank.currentStep} / ${state.phaseRerank.currentTotalSteps} on ${state.phaseRerank.currentTargetWidth} x ${state.phaseRerank.currentTargetHeight}.`,
|
|
);
|
|
}
|
|
break;
|
|
case "phase_rerank_candidate_completed":
|
|
if (state.phaseRerank) {
|
|
state.phaseRerank.currentCandidateIndex = Number(payload.candidateIndex ?? state.phaseRerank.currentCandidateIndex);
|
|
state.phaseRerank.currentTargetWidth = Number(payload.targetWidth ?? 0);
|
|
state.phaseRerank.currentTargetHeight = Number(payload.targetHeight ?? 0);
|
|
state.phaseRerank.currentPhaseX = Number(payload.phaseX ?? 0);
|
|
state.phaseRerank.currentPhaseY = Number(payload.phaseY ?? 0);
|
|
state.phaseRerank.completedCandidates = Number(payload.completedCandidates ?? state.phaseRerank.completedCandidates);
|
|
state.phaseRerank.currentStep = Number(payload.totalSteps ?? state.phaseRerank.currentStep);
|
|
state.phaseRerank.currentTotalSteps = Number(payload.totalSteps ?? state.phaseRerank.currentTotalSteps);
|
|
state.phaseRerank.currentLoss =
|
|
payload.finalLoss === null || payload.finalLoss === undefined ? state.phaseRerank.currentLoss : Number(payload.finalLoss);
|
|
setStage(
|
|
"rerank",
|
|
"Phase rerank",
|
|
`Candidate ${state.phaseRerank.currentCandidateIndex} / ${state.phaseRerank.candidateCount} scored. ${state.phaseRerank.completedCandidates} of ${state.phaseRerank.candidateCount} previews finished.`,
|
|
);
|
|
}
|
|
break;
|
|
case "phase_selection_completed":
|
|
state.inference = payload.inference as InferencePayload;
|
|
state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode;
|
|
addLog("Selection", "Committed to a ruler and phase.");
|
|
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;
|
|
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);
|
|
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();
|