Improve live GUI pipeline feedback
This commit is contained in:
parent
35d5bd3409
commit
74da1dfa6a
7 changed files with 514 additions and 98 deletions
|
|
@ -41,10 +41,25 @@ type LogItem = {
|
|||
detail: string;
|
||||
};
|
||||
|
||||
type PhaseRerankInfo = {
|
||||
previewSteps: number;
|
||||
candidateCount: number;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
type PhaseFieldPrepInfo = {
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
cellX: number;
|
||||
cellY: number;
|
||||
};
|
||||
|
||||
type AppState = {
|
||||
file: File | null;
|
||||
jobId: string | null;
|
||||
status: string;
|
||||
stageKey: string;
|
||||
stageLabel: string;
|
||||
statusText: string;
|
||||
inputPreviewImage: ImageAsset | null;
|
||||
sourceImage: ImageAsset | null;
|
||||
|
|
@ -52,6 +67,9 @@ type AppState = {
|
|||
latticeImage: ImageAsset | null;
|
||||
guidanceImage: ImageAsset | null;
|
||||
inference: InferencePayload | null;
|
||||
inferenceMode: string | null;
|
||||
phaseRerank: PhaseRerankInfo | null;
|
||||
phaseFieldPrep: PhaseFieldPrepInfo | null;
|
||||
frames: FramePayload[];
|
||||
cleanupImage: ImageAsset | null;
|
||||
heatmapImage: ImageAsset | null;
|
||||
|
|
@ -87,8 +105,11 @@ const dropzoneLabel = byId<HTMLSpanElement>("dropzoneLabel");
|
|||
const inputPreviewCanvas = byId<HTMLCanvasElement>("inputPreviewCanvas");
|
||||
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");
|
||||
|
|
@ -99,7 +120,6 @@ const leftCanvas = byId<HTMLCanvasElement>("leftCanvas");
|
|||
const rightCanvas = byId<HTMLCanvasElement>("rightCanvas");
|
||||
const leftVizLabel = byId<HTMLDivElement>("leftVizLabel");
|
||||
const rightVizLabel = byId<HTMLDivElement>("rightVizLabel");
|
||||
const metricsPanel = byId<HTMLDivElement>("metricsPanel");
|
||||
const paintSwatch = byId<HTMLDivElement>("paintSwatch");
|
||||
const zoomInput = byId<HTMLInputElement>("zoomInput");
|
||||
const zoomValue = byId<HTMLSpanElement>("zoomValue");
|
||||
|
|
@ -134,6 +154,7 @@ const imageCache = new Map<string, Promise<HTMLImageElement>>();
|
|||
const eventTypes = [
|
||||
"job_state",
|
||||
"job_failed",
|
||||
"stage_started",
|
||||
"source_loaded",
|
||||
"preprocess_completed",
|
||||
"inference_candidates_ready",
|
||||
|
|
@ -153,13 +174,18 @@ const state: AppState = {
|
|||
file: null,
|
||||
jobId: null,
|
||||
status: "idle",
|
||||
statusText: "Waiting for input.",
|
||||
stageKey: "idle",
|
||||
stageLabel: "Waiting for input",
|
||||
statusText: "Choose a file, then run the machine.",
|
||||
inputPreviewImage: null,
|
||||
sourceImage: null,
|
||||
preprocessedImage: null,
|
||||
latticeImage: null,
|
||||
guidanceImage: null,
|
||||
inference: null,
|
||||
inferenceMode: null,
|
||||
phaseRerank: null,
|
||||
phaseFieldPrep: null,
|
||||
frames: [],
|
||||
cleanupImage: null,
|
||||
heatmapImage: null,
|
||||
|
|
@ -232,10 +258,21 @@ function renderEventLog(): void {
|
|||
}
|
||||
}
|
||||
|
||||
function setStatus(status: string, detail: string): void {
|
||||
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;
|
||||
statusBadge.textContent = status;
|
||||
statusStage.textContent = label;
|
||||
statusText.textContent = detail;
|
||||
}
|
||||
|
||||
|
|
@ -246,6 +283,9 @@ function resetRunArtifacts(): void {
|
|||
state.latticeImage = null;
|
||||
state.guidanceImage = null;
|
||||
state.inference = null;
|
||||
state.inferenceMode = null;
|
||||
state.phaseRerank = null;
|
||||
state.phaseFieldPrep = null;
|
||||
state.frames = [];
|
||||
state.cleanupImage = null;
|
||||
state.heatmapImage = null;
|
||||
|
|
@ -419,31 +459,40 @@ function renderInference(): void {
|
|||
}
|
||||
}
|
||||
|
||||
function renderMetrics(): void {
|
||||
metricsPanel.querySelectorAll<HTMLElement>("[data-dynamic='true']").forEach((node) => {
|
||||
node.remove();
|
||||
});
|
||||
const frame = getSelectedFrame();
|
||||
function renderStatusMetrics(): void {
|
||||
statusMetrics.innerHTML = "";
|
||||
const items: Array<[string, string]> = [];
|
||||
if (frame) {
|
||||
items.push(["Loss", frame.loss === null ? "n/a" : formatNumber(frame.loss, 4)]);
|
||||
for (const [key, value] of Object.entries(frame.terms)) {
|
||||
items.push([key.replaceAll("_", " "), formatNumber(value, 4)]);
|
||||
const frame = getSelectedFrame();
|
||||
if (state.stageKey === "inference" || state.stageKey === "analysis" || state.stageKey === "selection") {
|
||||
if (state.inference) {
|
||||
items.push(["Mode", state.inferenceMode === "fixed" ? "pinned" : state.inferenceMode ?? "search"]);
|
||||
items.push(["Grid", `${state.inference.target_width} x ${state.inference.target_height}`]);
|
||||
items.push(["Phase", `${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}`]);
|
||||
items.push(["Confidence", formatNumber(state.inference.confidence, 3)]);
|
||||
}
|
||||
} else if (state.stageKey === "rerank") {
|
||||
if (state.phaseRerank) {
|
||||
items.push(["Preview steps", String(state.phaseRerank.previewSteps)]);
|
||||
items.push(["Candidates", String(state.phaseRerank.candidateCount)]);
|
||||
items.push(["Confidence", formatNumber(state.phaseRerank.confidence, 3)]);
|
||||
}
|
||||
} else if (state.stageKey === "solver") {
|
||||
if (state.phaseFieldPrep) {
|
||||
items.push(["Grid", `${state.phaseFieldPrep.targetWidth} x ${state.phaseFieldPrep.targetHeight}`]);
|
||||
items.push(["Cell pitch", `${formatNumber(state.phaseFieldPrep.cellX, 1)} x ${formatNumber(state.phaseFieldPrep.cellY, 1)} px`]);
|
||||
}
|
||||
if (frame) {
|
||||
items.push(["Loss", frame.loss === null ? "n/a" : formatNumber(frame.loss, 4)]);
|
||||
for (const [key, value] of Object.entries(frame.terms).slice(0, 4)) {
|
||||
items.push([key.replaceAll("_", " "), formatNumber(value, 4)]);
|
||||
}
|
||||
}
|
||||
} else if (state.runSummary) {
|
||||
items.push(["Structure score", formatNumber(readNestedNumber(state.runSummary, ["source_structure", "score"]))]);
|
||||
items.push([
|
||||
"Final fidelity",
|
||||
formatNumber(readNestedNumber(state.runSummary, ["source_fidelity", "final_output", "score"])),
|
||||
]);
|
||||
items.push(["Input color ratio", formatNumber(readNestedNumber(state.runSummary, ["output_colors_from_source_ratio"]))]);
|
||||
}
|
||||
for (const [label, value] of items) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "metric-chip";
|
||||
node.dataset.dynamic = "true";
|
||||
node.className = "status-metric";
|
||||
node.innerHTML = `<strong>${label}</strong><span>${value}</span>`;
|
||||
metricsPanel.appendChild(node);
|
||||
statusMetrics.appendChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -500,7 +549,6 @@ async function renderViewer(): Promise<void> {
|
|||
leftVizLabel.textContent = leftLabel;
|
||||
rightVizLabel.textContent = rightLabel;
|
||||
await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(rightCanvas, rightAsset)]);
|
||||
renderMetrics();
|
||||
}
|
||||
|
||||
function renderSlider(): void {
|
||||
|
|
@ -522,6 +570,40 @@ function renderSlider(): void {
|
|||
stepValue.textContent = `Frame ${frame.step} / ${frame.totalSteps}${state.autoFollow ? " • following live" : ""}`;
|
||||
}
|
||||
|
||||
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.fillStyle = "rgba(255, 216, 74, 0.9)";
|
||||
context.font = "12px 'Press Start 2P', monospace";
|
||||
context.fillText(heading.toUpperCase(), 18, 14);
|
||||
context.fillStyle = "rgba(255,255,255,0.42)";
|
||||
context.font = "18px 'VT323', monospace";
|
||||
drawWrappedCanvasText(context, detail, { x: 18, y: 38, maxWidth: width - 36, lineHeight: 20 });
|
||||
}
|
||||
|
||||
function renderLossChart(): void {
|
||||
const context = lossCanvas.getContext("2d");
|
||||
if (!context) {
|
||||
|
|
@ -541,14 +623,15 @@ function renderLossChart(): void {
|
|||
context.lineTo(width, y);
|
||||
context.stroke();
|
||||
}
|
||||
if (state.stageKey !== "solver") {
|
||||
renderLossPlaceholder(context, width, state.stageLabel, state.statusText);
|
||||
return;
|
||||
}
|
||||
const losses = state.frames
|
||||
.filter((frame) => typeof frame.loss === "number")
|
||||
.map((frame) => frame.loss as number);
|
||||
if (losses.length === 0) {
|
||||
context.fillStyle = "rgba(255,255,255,0.28)";
|
||||
context.font = "14px sans-serif";
|
||||
context.textBaseline = "top";
|
||||
context.fillText("Loss curve wakes up once the solver starts moving.", 18, 16);
|
||||
renderLossPlaceholder(context, width, "Phase-field solve", "Loss samples land here once the solver starts moving.");
|
||||
return;
|
||||
}
|
||||
const minLoss = Math.min(...losses);
|
||||
|
|
@ -798,6 +881,7 @@ function buildFormData(): FormData {
|
|||
async function renderEverything(): Promise<void> {
|
||||
renderInference();
|
||||
renderSlider();
|
||||
renderStatusMetrics();
|
||||
renderLossChart();
|
||||
renderSummary();
|
||||
await renderViewer();
|
||||
|
|
@ -806,17 +890,25 @@ async function renderEverything(): Promise<void> {
|
|||
async function handleEvent(eventName: string, payload: JsonRecord): Promise<void> {
|
||||
switch (eventName) {
|
||||
case "job_state":
|
||||
setStatus(String(payload.status ?? "working"), payload.status === "completed" ? "Run complete." : "Running.");
|
||||
setJobState(String(payload.status ?? "working"));
|
||||
break;
|
||||
case "job_failed":
|
||||
setStatus("failed", String(payload.message ?? "The GUI run fell over."));
|
||||
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();
|
||||
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;
|
||||
setStatus("running", "Input loaded. Survey crew is measuring the mess.");
|
||||
addLog("Input", "Loaded the input image.");
|
||||
break;
|
||||
case "preprocess_completed":
|
||||
|
|
@ -825,9 +917,20 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
|
|||
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),
|
||||
};
|
||||
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.`,
|
||||
|
|
@ -835,6 +938,7 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
|
|||
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":
|
||||
|
|
@ -843,6 +947,17 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
|
|||
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":
|
||||
|
|
@ -856,27 +971,37 @@ async function handleEvent(eventName: string, payload: JsonRecord): Promise<void
|
|||
state.frames.push(frame);
|
||||
state.frames.sort((a, b) => a.step - b.step);
|
||||
}
|
||||
if (state.autoFollow) {
|
||||
state.selectedFrameIndex = Math.max(0, state.frames.findIndex((candidate) => candidate.step === frame.step));
|
||||
}
|
||||
if (eventName === "phase_field_final") {
|
||||
addLog("Solver", "Final nearest-input sample committed.");
|
||||
}
|
||||
break;
|
||||
if (state.autoFollow) {
|
||||
state.selectedFrameIndex = Math.max(0, state.frames.findIndex((candidate) => candidate.step === frame.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;
|
||||
setStatus("completed", "Run finished. If the machine still did something stupid, fix it pixel by pixel.");
|
||||
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();
|
||||
|
|
@ -901,20 +1026,23 @@ function connectEventStream(jobId: string): void {
|
|||
}
|
||||
stream.onerror = () => {
|
||||
if (state.status === "running") {
|
||||
setStatus("waiting", "Event stream hiccup. If it stays this way, rerun it.");
|
||||
setJobState("waiting");
|
||||
setStage(state.stageKey, state.stageLabel, "Event stream hiccup. If it stays this way, rerun it.");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function startRun(): Promise<void> {
|
||||
if (!state.file) {
|
||||
setStatus("idle", "Pick a file first. The machine is not clairvoyant.");
|
||||
setJobState("idle");
|
||||
setStage("idle", "Waiting for input", "Pick a file first. The machine is not clairvoyant.");
|
||||
return;
|
||||
}
|
||||
resetRunArtifacts();
|
||||
renderEventLog();
|
||||
runButton.disabled = true;
|
||||
setStatus("queued", "Queued the run. Small miracle pending.");
|
||||
setJobState("queued");
|
||||
setStage("queued", "Queued", "Starting the pipeline. This should wake up almost immediately.");
|
||||
addLog("Queued", `Starting ${state.file.name}.`);
|
||||
await renderEverything();
|
||||
try {
|
||||
|
|
@ -930,7 +1058,8 @@ async function startRun(): Promise<void> {
|
|||
connectEventStream(payload.jobId);
|
||||
} catch (error) {
|
||||
runButton.disabled = false;
|
||||
setStatus("failed", error instanceof Error ? error.message : "Failed to start GUI job.");
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
@ -938,7 +1067,8 @@ async function startRun(): Promise<void> {
|
|||
async function acceptFile(file: File): Promise<void> {
|
||||
state.file = file;
|
||||
dropzoneLabel.textContent = file.name;
|
||||
setStatus("idle", `${file.name} is loaded. Hit run when you want the machine to start sweating.`);
|
||||
setJobState("idle");
|
||||
setStage("ready", "Ready to run", `${file.name} is loaded. Hit run when you want the machine to start sweating.`);
|
||||
try {
|
||||
const previewImage = await fileToImageAsset(file);
|
||||
if (state.file !== file) {
|
||||
|
|
@ -950,7 +1080,8 @@ async function acceptFile(file: File): Promise<void> {
|
|||
return;
|
||||
}
|
||||
state.inputPreviewImage = null;
|
||||
setStatus("failed", error instanceof Error ? error.message : "Failed to load image preview.");
|
||||
setJobState("failed");
|
||||
setStage("failed", "Preview failed", error instanceof Error ? error.message : "Failed to load image preview.");
|
||||
}
|
||||
await renderInputPreview();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,6 +208,12 @@ class GuiJob:
|
|||
"candidateCount": int(payload["candidate_count"]),
|
||||
"confidence": float(payload["confidence"]),
|
||||
}
|
||||
if event == "stage_started":
|
||||
return {
|
||||
"stage": str(payload["stage"]),
|
||||
"label": str(payload["label"]),
|
||||
"detail": str(payload["detail"]),
|
||||
}
|
||||
if event == "analysis_completed":
|
||||
edge_map = payload["edge_map"]
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,11 @@ const dropzoneLabel = byId("dropzoneLabel");
|
|||
const inputPreviewCanvas = byId("inputPreviewCanvas");
|
||||
const pickFileButton = byId("pickFileButton");
|
||||
const runButton = byId("runButton");
|
||||
const statusPanel = byId("statusPanel");
|
||||
const statusBadge = byId("statusBadge");
|
||||
const statusStage = byId("statusStage");
|
||||
const statusText = byId("statusText");
|
||||
const statusMetrics = byId("statusMetrics");
|
||||
const inferenceSummary = byId("inferenceSummary");
|
||||
const candidateList = byId("candidateList");
|
||||
const eventLog = byId("eventLog");
|
||||
|
|
@ -24,7 +27,6 @@ const leftCanvas = byId("leftCanvas");
|
|||
const rightCanvas = byId("rightCanvas");
|
||||
const leftVizLabel = byId("leftVizLabel");
|
||||
const rightVizLabel = byId("rightVizLabel");
|
||||
const metricsPanel = byId("metricsPanel");
|
||||
const paintSwatch = byId("paintSwatch");
|
||||
const zoomInput = byId("zoomInput");
|
||||
const zoomValue = byId("zoomValue");
|
||||
|
|
@ -56,6 +58,7 @@ const imageCache = new Map();
|
|||
const eventTypes = [
|
||||
"job_state",
|
||||
"job_failed",
|
||||
"stage_started",
|
||||
"source_loaded",
|
||||
"preprocess_completed",
|
||||
"inference_candidates_ready",
|
||||
|
|
@ -74,13 +77,18 @@ const state = {
|
|||
file: null,
|
||||
jobId: null,
|
||||
status: "idle",
|
||||
statusText: "Waiting for input.",
|
||||
stageKey: "idle",
|
||||
stageLabel: "Waiting for input",
|
||||
statusText: "Choose a file, then run the machine.",
|
||||
inputPreviewImage: null,
|
||||
sourceImage: null,
|
||||
preprocessedImage: null,
|
||||
latticeImage: null,
|
||||
guidanceImage: null,
|
||||
inference: null,
|
||||
inferenceMode: null,
|
||||
phaseRerank: null,
|
||||
phaseFieldPrep: null,
|
||||
frames: [],
|
||||
cleanupImage: null,
|
||||
heatmapImage: null,
|
||||
|
|
@ -145,10 +153,19 @@ function renderEventLog() {
|
|||
eventLog.appendChild(node);
|
||||
}
|
||||
}
|
||||
function setStatus(status, detail) {
|
||||
function formatStatusBadge(status) {
|
||||
return status.replaceAll("_", " ").toUpperCase();
|
||||
}
|
||||
function setJobState(status) {
|
||||
state.status = status;
|
||||
statusBadge.textContent = formatStatusBadge(status);
|
||||
statusPanel.dataset.jobState = status;
|
||||
}
|
||||
function setStage(stageKey, label, detail) {
|
||||
state.stageKey = stageKey;
|
||||
state.stageLabel = label;
|
||||
state.statusText = detail;
|
||||
statusBadge.textContent = status;
|
||||
statusStage.textContent = label;
|
||||
statusText.textContent = detail;
|
||||
}
|
||||
function resetRunArtifacts() {
|
||||
|
|
@ -158,6 +175,9 @@ function resetRunArtifacts() {
|
|||
state.latticeImage = null;
|
||||
state.guidanceImage = null;
|
||||
state.inference = null;
|
||||
state.inferenceMode = null;
|
||||
state.phaseRerank = null;
|
||||
state.phaseFieldPrep = null;
|
||||
state.frames = [];
|
||||
state.cleanupImage = null;
|
||||
state.heatmapImage = null;
|
||||
|
|
@ -320,32 +340,42 @@ function renderInference() {
|
|||
candidateList.appendChild(node);
|
||||
}
|
||||
}
|
||||
function renderMetrics() {
|
||||
metricsPanel.querySelectorAll("[data-dynamic='true']").forEach((node) => {
|
||||
node.remove();
|
||||
});
|
||||
const frame = getSelectedFrame();
|
||||
function renderStatusMetrics() {
|
||||
statusMetrics.innerHTML = "";
|
||||
const items = [];
|
||||
if (frame) {
|
||||
items.push(["Loss", frame.loss === null ? "n/a" : formatNumber(frame.loss, 4)]);
|
||||
for (const [key, value] of Object.entries(frame.terms)) {
|
||||
items.push([key.replaceAll("_", " "), formatNumber(value, 4)]);
|
||||
const frame = getSelectedFrame();
|
||||
if (state.stageKey === "inference" || state.stageKey === "analysis" || state.stageKey === "selection") {
|
||||
if (state.inference) {
|
||||
items.push(["Mode", state.inferenceMode === "fixed" ? "pinned" : state.inferenceMode ?? "search"]);
|
||||
items.push(["Grid", `${state.inference.target_width} x ${state.inference.target_height}`]);
|
||||
items.push(["Phase", `${formatNumber(state.inference.phase_x, 2)}, ${formatNumber(state.inference.phase_y, 2)}`]);
|
||||
items.push(["Confidence", formatNumber(state.inference.confidence, 3)]);
|
||||
}
|
||||
}
|
||||
else if (state.runSummary) {
|
||||
items.push(["Structure score", formatNumber(readNestedNumber(state.runSummary, ["source_structure", "score"]))]);
|
||||
items.push([
|
||||
"Final fidelity",
|
||||
formatNumber(readNestedNumber(state.runSummary, ["source_fidelity", "final_output", "score"])),
|
||||
]);
|
||||
items.push(["Input color ratio", formatNumber(readNestedNumber(state.runSummary, ["output_colors_from_source_ratio"]))]);
|
||||
else if (state.stageKey === "rerank") {
|
||||
if (state.phaseRerank) {
|
||||
items.push(["Preview steps", String(state.phaseRerank.previewSteps)]);
|
||||
items.push(["Candidates", String(state.phaseRerank.candidateCount)]);
|
||||
items.push(["Confidence", formatNumber(state.phaseRerank.confidence, 3)]);
|
||||
}
|
||||
}
|
||||
else if (state.stageKey === "solver") {
|
||||
if (state.phaseFieldPrep) {
|
||||
items.push(["Grid", `${state.phaseFieldPrep.targetWidth} x ${state.phaseFieldPrep.targetHeight}`]);
|
||||
items.push(["Cell pitch", `${formatNumber(state.phaseFieldPrep.cellX, 1)} x ${formatNumber(state.phaseFieldPrep.cellY, 1)} px`]);
|
||||
}
|
||||
if (frame) {
|
||||
items.push(["Loss", frame.loss === null ? "n/a" : formatNumber(frame.loss, 4)]);
|
||||
for (const [key, value] of Object.entries(frame.terms).slice(0, 4)) {
|
||||
items.push([key.replaceAll("_", " "), formatNumber(value, 4)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [label, value] of items) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "metric-chip";
|
||||
node.dataset.dynamic = "true";
|
||||
node.className = "status-metric";
|
||||
node.innerHTML = `<strong>${label}</strong><span>${value}</span>`;
|
||||
metricsPanel.appendChild(node);
|
||||
statusMetrics.appendChild(node);
|
||||
}
|
||||
}
|
||||
function renderSummary() {
|
||||
|
|
@ -399,7 +429,6 @@ async function renderViewer() {
|
|||
leftVizLabel.textContent = leftLabel;
|
||||
rightVizLabel.textContent = rightLabel;
|
||||
await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(rightCanvas, rightAsset)]);
|
||||
renderMetrics();
|
||||
}
|
||||
function renderSlider() {
|
||||
if (state.frames.length === 0) {
|
||||
|
|
@ -419,6 +448,34 @@ function renderSlider() {
|
|||
}
|
||||
stepValue.textContent = `Frame ${frame.step} / ${frame.totalSteps}${state.autoFollow ? " • following live" : ""}`;
|
||||
}
|
||||
function drawWrappedCanvasText(context, text, options) {
|
||||
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, width, heading, detail) {
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = "rgba(255, 216, 74, 0.9)";
|
||||
context.font = "12px 'Press Start 2P', monospace";
|
||||
context.fillText(heading.toUpperCase(), 18, 14);
|
||||
context.fillStyle = "rgba(255,255,255,0.42)";
|
||||
context.font = "18px 'VT323', monospace";
|
||||
drawWrappedCanvasText(context, detail, { x: 18, y: 38, maxWidth: width - 36, lineHeight: 20 });
|
||||
}
|
||||
function renderLossChart() {
|
||||
const context = lossCanvas.getContext("2d");
|
||||
if (!context) {
|
||||
|
|
@ -438,14 +495,15 @@ function renderLossChart() {
|
|||
context.lineTo(width, y);
|
||||
context.stroke();
|
||||
}
|
||||
if (state.stageKey !== "solver") {
|
||||
renderLossPlaceholder(context, width, state.stageLabel, state.statusText);
|
||||
return;
|
||||
}
|
||||
const losses = state.frames
|
||||
.filter((frame) => typeof frame.loss === "number")
|
||||
.map((frame) => frame.loss);
|
||||
if (losses.length === 0) {
|
||||
context.fillStyle = "rgba(255,255,255,0.28)";
|
||||
context.font = "14px sans-serif";
|
||||
context.textBaseline = "top";
|
||||
context.fillText("Loss curve wakes up once the solver starts moving.", 18, 16);
|
||||
renderLossPlaceholder(context, width, "Phase-field solve", "Loss samples land here once the solver starts moving.");
|
||||
return;
|
||||
}
|
||||
const minLoss = Math.min(...losses);
|
||||
|
|
@ -679,6 +737,7 @@ function buildFormData() {
|
|||
async function renderEverything() {
|
||||
renderInference();
|
||||
renderSlider();
|
||||
renderStatusMetrics();
|
||||
renderLossChart();
|
||||
renderSummary();
|
||||
await renderViewer();
|
||||
|
|
@ -686,17 +745,21 @@ async function renderEverything() {
|
|||
async function handleEvent(eventName, payload) {
|
||||
switch (eventName) {
|
||||
case "job_state":
|
||||
setStatus(String(payload.status ?? "working"), payload.status === "completed" ? "Run complete." : "Running.");
|
||||
setJobState(String(payload.status ?? "working"));
|
||||
break;
|
||||
case "job_failed":
|
||||
setStatus("failed", String(payload.message ?? "The GUI run fell over."));
|
||||
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();
|
||||
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;
|
||||
setStatus("running", "Input loaded. Survey crew is measuring the mess.");
|
||||
addLog("Input", "Loaded the input image.");
|
||||
break;
|
||||
case "preprocess_completed":
|
||||
|
|
@ -705,13 +768,21 @@ async function handleEvent(eventName, payload) {
|
|||
break;
|
||||
case "inference_candidates_ready":
|
||||
state.inference = payload.inference;
|
||||
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),
|
||||
};
|
||||
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_selection_completed":
|
||||
state.inference = payload.inference;
|
||||
state.inferenceMode = typeof payload.inferenceMode === "string" ? payload.inferenceMode : state.inferenceMode;
|
||||
addLog("Selection", "Committed to a ruler and phase.");
|
||||
break;
|
||||
case "analysis_completed":
|
||||
|
|
@ -720,6 +791,13 @@ async function handleEvent(eventName, payload) {
|
|||
case "phase_field_prepared":
|
||||
state.latticeImage = payload.latticeImage;
|
||||
state.guidanceImage = payload.guidanceImage;
|
||||
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":
|
||||
|
|
@ -737,6 +815,9 @@ async function handleEvent(eventName, payload) {
|
|||
if (state.autoFollow) {
|
||||
state.selectedFrameIndex = Math.max(0, state.frames.findIndex((candidate) => candidate.step === frame.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.");
|
||||
}
|
||||
|
|
@ -745,16 +826,19 @@ async function handleEvent(eventName, payload) {
|
|||
case "cleanup_completed":
|
||||
state.cleanupImage = payload.cleanedImage;
|
||||
state.heatmapImage = payload.heatmapImage;
|
||||
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;
|
||||
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;
|
||||
state.runSummary = payload.runSummary ?? null;
|
||||
setStatus("completed", "Run finished. If the machine still did something stupid, fix it pixel by pixel.");
|
||||
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();
|
||||
|
|
@ -778,19 +862,22 @@ function connectEventStream(jobId) {
|
|||
}
|
||||
stream.onerror = () => {
|
||||
if (state.status === "running") {
|
||||
setStatus("waiting", "Event stream hiccup. If it stays this way, rerun it.");
|
||||
setJobState("waiting");
|
||||
setStage(state.stageKey, state.stageLabel, "Event stream hiccup. If it stays this way, rerun it.");
|
||||
}
|
||||
};
|
||||
}
|
||||
async function startRun() {
|
||||
if (!state.file) {
|
||||
setStatus("idle", "Pick a file first. The machine is not clairvoyant.");
|
||||
setJobState("idle");
|
||||
setStage("idle", "Waiting for input", "Pick a file first. The machine is not clairvoyant.");
|
||||
return;
|
||||
}
|
||||
resetRunArtifacts();
|
||||
renderEventLog();
|
||||
runButton.disabled = true;
|
||||
setStatus("queued", "Queued the run. Small miracle pending.");
|
||||
setJobState("queued");
|
||||
setStage("queued", "Queued", "Starting the pipeline. This should wake up almost immediately.");
|
||||
addLog("Queued", `Starting ${state.file.name}.`);
|
||||
await renderEverything();
|
||||
try {
|
||||
|
|
@ -807,14 +894,16 @@ async function startRun() {
|
|||
}
|
||||
catch (error) {
|
||||
runButton.disabled = false;
|
||||
setStatus("failed", error instanceof Error ? error.message : "Failed to start GUI job.");
|
||||
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.");
|
||||
}
|
||||
}
|
||||
async function acceptFile(file) {
|
||||
state.file = file;
|
||||
dropzoneLabel.textContent = file.name;
|
||||
setStatus("idle", `${file.name} is loaded. Hit run when you want the machine to start sweating.`);
|
||||
setJobState("idle");
|
||||
setStage("ready", "Ready to run", `${file.name} is loaded. Hit run when you want the machine to start sweating.`);
|
||||
try {
|
||||
const previewImage = await fileToImageAsset(file);
|
||||
if (state.file !== file) {
|
||||
|
|
@ -827,7 +916,8 @@ async function acceptFile(file) {
|
|||
return;
|
||||
}
|
||||
state.inputPreviewImage = null;
|
||||
setStatus("failed", error instanceof Error ? error.message : "Failed to load image preview.");
|
||||
setJobState("failed");
|
||||
setStage("failed", "Preview failed", error instanceof Error ? error.message : "Failed to load image preview.");
|
||||
}
|
||||
await renderInputPreview();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-instructions">
|
||||
<p class="eyebrow">How To Use It</p>
|
||||
<p class="instruction-line">1. Drop an input on the right or choose a file.</p>
|
||||
<p class="instruction-line">2. Leave size and phase blank to let the machine search, or pin them if you already know the lattice.</p>
|
||||
<p class="instruction-line">3. Run it, watch the status panel for the active stage, then patch the output by hand if the machine gets cute.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="hero viewer-panel">
|
||||
|
|
@ -32,9 +38,16 @@
|
|||
</div>
|
||||
</div>
|
||||
<div id="metricsPanel" class="metrics-panel stats-grid">
|
||||
<div class="metric-chip metric-status-block">
|
||||
<span id="statusBadge" class="status-badge">Idle</span>
|
||||
<p id="statusText" class="status-text metrics-status-text">Waiting for input.</p>
|
||||
<div id="statusPanel" class="metric-chip metric-status-block" data-job-state="idle">
|
||||
<div class="status-heading">
|
||||
<span id="statusBadge" class="status-badge">Idle</span>
|
||||
<p id="statusStage" class="status-stage">Waiting for input</p>
|
||||
</div>
|
||||
<p id="statusText" class="status-text metrics-status-text">Choose a file, then run the machine.</p>
|
||||
<div class="status-activity" aria-hidden="true">
|
||||
<span class="status-activity-bar"></span>
|
||||
</div>
|
||||
<div id="statusMetrics" class="status-metrics"></div>
|
||||
</div>
|
||||
<div class="metric-chip scrubber metric-scrubber">
|
||||
<label for="stepSlider">Solver Step</label>
|
||||
|
|
|
|||
|
|
@ -262,7 +262,8 @@ p {
|
|||
.brand,
|
||||
.brand-lockup,
|
||||
.copy-stack,
|
||||
.viewer-heading {
|
||||
.viewer-heading,
|
||||
.header-instructions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
|
@ -328,6 +329,31 @@ p {
|
|||
max-width: 40ch;
|
||||
}
|
||||
|
||||
.app-shell-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.9fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.header-instructions {
|
||||
align-content: start;
|
||||
padding: 12px 14px;
|
||||
border: 3px solid rgba(8, 36, 62, 0.88);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 216, 74, 0.12), transparent 32%),
|
||||
linear-gradient(180deg, rgba(18, 58, 94, 0.95), rgba(9, 33, 62, 0.92));
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 216, 74, 0.22),
|
||||
0 0 14px rgba(255, 216, 74, 0.08);
|
||||
}
|
||||
|
||||
.instruction-line {
|
||||
font-size: 1.04rem;
|
||||
line-height: 1.18;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.candidate-card,
|
||||
.event-item,
|
||||
.metric-chip,
|
||||
|
|
@ -573,7 +599,19 @@ p {
|
|||
}
|
||||
|
||||
.metrics-status-text {
|
||||
max-width: 26ch;
|
||||
max-width: 32ch;
|
||||
}
|
||||
|
||||
.status-heading {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-stage {
|
||||
margin: 0;
|
||||
font-size: 1.22rem;
|
||||
line-height: 1.1;
|
||||
color: var(--ui-text-bright);
|
||||
}
|
||||
|
||||
.scrubber {
|
||||
|
|
@ -651,8 +689,8 @@ p {
|
|||
align-self: stretch;
|
||||
justify-self: start;
|
||||
padding: 14px 16px;
|
||||
width: fit-content;
|
||||
max-width: min(100%, 26ch);
|
||||
width: 100%;
|
||||
max-width: min(100%, 34ch);
|
||||
}
|
||||
|
||||
.metric-scrubber {
|
||||
|
|
@ -661,7 +699,85 @@ p {
|
|||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: fit-content(26ch) minmax(0, 1fr);
|
||||
grid-template-columns: fit-content(34ch) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.status-activity {
|
||||
position: relative;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255, 216, 74, 0.18);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(7, 20, 36, 0.85), rgba(8, 28, 48, 0.92));
|
||||
}
|
||||
|
||||
.status-activity-bar {
|
||||
display: block;
|
||||
width: 36%;
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(90deg, transparent, rgba(255, 216, 74, 0.92), rgba(143, 226, 200, 0.9), transparent);
|
||||
transform: translateX(-120%);
|
||||
animation: status-sweep 1.35s linear infinite;
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
#statusPanel[data-job-state="queued"] .status-activity-bar,
|
||||
#statusPanel[data-job-state="running"] .status-activity-bar,
|
||||
#statusPanel[data-job-state="waiting"] .status-activity-bar {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
#statusPanel[data-job-state="failed"] .status-activity-bar {
|
||||
animation-play-state: paused;
|
||||
transform: translateX(0);
|
||||
width: 100%;
|
||||
background: linear-gradient(90deg, rgba(210, 78, 62, 0.75), rgba(255, 137, 95, 0.88));
|
||||
}
|
||||
|
||||
#statusPanel[data-job-state="completed"] .status-activity-bar {
|
||||
animation-play-state: paused;
|
||||
transform: translateX(0);
|
||||
width: 100%;
|
||||
background: linear-gradient(90deg, rgba(143, 226, 200, 0.82), rgba(255, 216, 74, 0.72));
|
||||
}
|
||||
|
||||
.status-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-metric {
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 2px solid rgba(8, 36, 62, 0.88);
|
||||
background: linear-gradient(180deg, rgba(10, 35, 58, 0.96), rgba(7, 23, 42, 0.94));
|
||||
}
|
||||
|
||||
.status-metric strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.58rem;
|
||||
color: var(--ui-accent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-metric span {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
line-height: 1.12;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
@keyframes status-sweep {
|
||||
from {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(320%);
|
||||
}
|
||||
}
|
||||
|
||||
.candidate-card,
|
||||
|
|
@ -808,6 +924,7 @@ select:focus-visible {
|
|||
}
|
||||
|
||||
@media (max-width: 940px) {
|
||||
.app-shell-header,
|
||||
.viewer-grid,
|
||||
.controls-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
|
|
@ -99,6 +99,13 @@ def run_pipeline_rgba(
|
|||
solver_params = solver_params or SolverHyperParams()
|
||||
emit_observer(observer, "source_loaded", source_rgba=source.copy())
|
||||
if strip_background:
|
||||
emit_observer(
|
||||
observer,
|
||||
"stage_started",
|
||||
stage="preprocess",
|
||||
label="Background cleanup",
|
||||
detail="Stripping edge-connected neutral junk before lattice search.",
|
||||
)
|
||||
source = strip_edge_background(source)
|
||||
emit_observer(observer, "preprocess_completed", source_rgba=source.copy(), operation="strip_background")
|
||||
fixed_dims = _resolve_requested_target_dims(
|
||||
|
|
@ -111,9 +118,23 @@ def run_pipeline_rgba(
|
|||
phase_y=phase_y,
|
||||
)
|
||||
if fixed_dims is None:
|
||||
emit_observer(
|
||||
observer,
|
||||
"stage_started",
|
||||
stage="inference",
|
||||
label="Lattice search",
|
||||
detail="Searching for the output size and phase that best fit the input.",
|
||||
)
|
||||
inference = infer_lattice(source, target_size=target_size, device=device)
|
||||
inference_mode = "searched"
|
||||
else:
|
||||
emit_observer(
|
||||
observer,
|
||||
"stage_started",
|
||||
stage="inference",
|
||||
label="Pinned lattice",
|
||||
detail="Locking in the requested size and phase instead of searching.",
|
||||
)
|
||||
inference = infer_fixed_lattice(
|
||||
source,
|
||||
target_width=fixed_dims[0],
|
||||
|
|
@ -124,12 +145,26 @@ def run_pipeline_rgba(
|
|||
)
|
||||
inference_mode = "fixed"
|
||||
emit_observer(observer, "inference_candidates_ready", inference=inference, inference_mode=inference_mode)
|
||||
emit_observer(
|
||||
observer,
|
||||
"stage_started",
|
||||
stage="analysis",
|
||||
label="Input analysis",
|
||||
detail="Mapping sharp cells, edges, and guidance before the solver starts.",
|
||||
)
|
||||
analysis = _prepare_analysis(
|
||||
source,
|
||||
seed=seed,
|
||||
device=device,
|
||||
)
|
||||
emit_observer(observer, "analysis_completed", edge_map=analysis.edge_map.copy())
|
||||
emit_observer(
|
||||
observer,
|
||||
"stage_started",
|
||||
stage="selection",
|
||||
label="Phase selection",
|
||||
detail="Choosing the lattice candidate that deserves the full solve.",
|
||||
)
|
||||
inference = _select_phase_candidate(
|
||||
source,
|
||||
inference,
|
||||
|
|
@ -142,6 +177,17 @@ def run_pipeline_rgba(
|
|||
observer=observer,
|
||||
)
|
||||
emit_observer(observer, "phase_selection_completed", inference=inference, inference_mode=inference_mode)
|
||||
emit_observer(
|
||||
observer,
|
||||
"stage_started",
|
||||
stage="solver",
|
||||
label="Phase-field solve",
|
||||
detail=(
|
||||
"Running the solver over the pinned lattice."
|
||||
if steps > 0
|
||||
else "Skipping solver drift and keeping the initial placement."
|
||||
),
|
||||
)
|
||||
solver, reconstruction_diagnostics = _run_reconstruction(
|
||||
source,
|
||||
inference=inference,
|
||||
|
|
@ -152,6 +198,13 @@ def run_pipeline_rgba(
|
|||
solver_params=solver_params,
|
||||
observer=observer,
|
||||
)
|
||||
emit_observer(
|
||||
observer,
|
||||
"stage_started",
|
||||
stage="cleanup",
|
||||
label="Cleanup",
|
||||
detail="Sweeping isolated noise and stubborn single-pixel junk.",
|
||||
)
|
||||
cleanup = cleanup_pixels(solver.target_rgba, source_guidance=solver.guidance_strength)
|
||||
emit_observer(
|
||||
observer,
|
||||
|
|
@ -160,6 +213,13 @@ def run_pipeline_rgba(
|
|||
isolated_heatmap=cleanup.isolated_heatmap.copy(),
|
||||
)
|
||||
palette = load_palette(palette_path) if palette_path else None
|
||||
emit_observer(
|
||||
observer,
|
||||
"stage_started",
|
||||
stage="output",
|
||||
label="Final output",
|
||||
detail="Writing the finished output and packaging the summary.",
|
||||
)
|
||||
palette_result = quantize_rgba(cleanup.cleaned_rgba, mode=palette_mode, palette=palette)
|
||||
output_rgba = palette_result.rgba if palette_result else cleanup.cleaned_rgba
|
||||
if output_path is not None:
|
||||
|
|
|
|||
|
|
@ -32,16 +32,15 @@ def test_run_pipeline_rgba_emits_observer_events_for_gui() -> None:
|
|||
observer=observer,
|
||||
)
|
||||
|
||||
assert events[:4] == [
|
||||
"source_loaded",
|
||||
"inference_candidates_ready",
|
||||
"analysis_completed",
|
||||
"phase_selection_completed",
|
||||
]
|
||||
assert events[0] == "source_loaded"
|
||||
assert events.count("stage_started") == 6
|
||||
assert events.index("inference_candidates_ready") < events.index("analysis_completed")
|
||||
assert events.index("analysis_completed") < events.index("phase_selection_completed")
|
||||
assert events.index("phase_selection_completed") < events.index("phase_field_prepared")
|
||||
assert "phase_field_prepared" in events
|
||||
assert "phase_field_initial" in events
|
||||
assert events.count("phase_field_step") == 2
|
||||
assert events[-3:] == ["cleanup_completed", "palette_completed", "pipeline_completed"]
|
||||
assert events[-4:] == ["cleanup_completed", "stage_started", "palette_completed", "pipeline_completed"]
|
||||
|
||||
|
||||
def test_repo_gui_runner_dispatches_to_gui_main(monkeypatch) -> None:
|
||||
|
|
|
|||
Loading…
Reference in a new issue