Files
ausboss-ComfyUI-AusBoss/js/shared/step_preview.mjs
ausboss 13145ed881 Save Image: say a run is going, hold the last step, keep tiles off the picture
While the models load, an empty picture area says the run is going instead
of asking for a run. With steps on, the last step stays up until the saved
picture has loaded, so it no longer blinks out. Between a tiled upscale's
split and stitch (and for Ultimate SD Upscale) the step pictures are tiles:
the whole picture stays and the chip says "upscaling in tiles".
2026-10-27 08:51:40 -05:00

277 lines
11 KiB
JavaScript

// Step pictures on the pack's faces: Save Image, Save Video and Step Preview.
//
// ComfyUI sends a small picture after each sampling step of a run that asked
// for them (nodes/_step_preview_helpers.py asks when one of these nodes
// shows them). This module listens once for the whole page, keeps what the
// run is doing (step_preview_math.mjs), and lets each face draw it:
// mountStepOverlay lays the picture, a progress line and a short caption
// over a node's own picture box, and stopStepRun is the Stop button.
//
// Runs queued from another workflow tab are left to that tab.
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { nodeByExecutionId } from "./graph_ids.mjs";
import { BRAND } from "./index.mjs";
import { outputRecordQuery } from "./input_preview.mjs";
import { isForeignRun } from "./prompt_scope.mjs";
import {
IDLE,
TILE_SPLIT,
TILE_STITCH,
consumersOf,
drawsTiles,
feedsInto,
nextRunState,
resultRank,
stepFraction,
stepText,
stoppedText,
} from "./step_preview_math.mjs";
const CSS_ID = "ausboss-step-preview-css";
// How long a replaced picture's object URL lives on, so a face that is
// still swapping to the new one never shows a broken picture.
const URL_GRACE_MS = 2000;
let run = IDLE;
let wired = false;
let waiting = 0;
const listeners = new Set();
// The save nodes whose steps switch is on: node -> () => boolean.
const consumers = new Map();
const rootGraph = () => app.rootGraph ?? app.graph ?? null;
function emit() {
for (const listener of [...listeners]) {
try {
listener(run);
} catch {
// One face failing never keeps the picture from the others.
}
}
}
function apply(event) {
const before = run;
run = nextRunState(run, event);
// A step picture the run did not take (a tile kept off the whole one).
if (event.type === "picture" && event.url && event.url !== run.url) URL.revokeObjectURL(event.url);
if (run === before) return;
if (before.url && before.url !== run.url) {
const stale = before.url;
setTimeout(() => URL.revokeObjectURL(stale), URL_GRACE_MS);
}
emit();
}
function on(type, handler) {
api.addEventListener(type, ({ detail }) => {
try {
handler(detail);
} catch {
// Never in the way of a run.
}
});
}
const nodeType = (node) => node?.type ?? node?.comfyClass ?? null;
// The api's "executing" detail is the running node's id, or an object with it.
function executingId(detail) {
if (detail && typeof detail === "object") return detail.display_node ?? detail.node ?? null;
return detail ?? null;
}
// A tiled upscale's split or stitch, by its execution id: true, false or null.
function tileEdge(id) {
if (id === null || id === undefined) return null;
const type = nodeType(nodeByExecutionId(rootGraph(), String(id)));
return type === TILE_SPLIT ? true : type === TILE_STITCH ? false : null;
}
function wire() {
if (wired) return;
wired = true;
on("execution_start", (detail) => {
if (isForeignRun(detail?.prompt_id)) return;
apply({ type: "start", promptId: detail?.prompt_id ?? null });
});
on("b_preview_with_metadata", (detail) => {
if (!detail?.blob || isForeignRun(detail.jobId)) return;
const maker = String(detail.displayNodeId ?? detail.nodeId ?? "");
apply({
type: "picture",
promptId: detail.jobId ?? null,
maker,
url: URL.createObjectURL(detail.blob),
tile: drawsTiles(nodeType(nodeByExecutionId(rootGraph(), maker))),
});
});
// The tiles of a tiled upscale run between its split and its stitch.
on("executing", (detail) => {
const promptId = typeof detail === "object" ? detail?.prompt_id : undefined;
if (isForeignRun(promptId)) return;
const edge = tileEdge(executingId(detail));
if (edge !== null) apply({ type: "tiles", promptId: promptId ?? null, on: edge });
});
// A split kept from the last run sends its tiles on without running.
on("execution_cached", (detail) => {
if (isForeignRun(detail?.prompt_id)) return;
const edges = (detail?.nodes ?? []).map(tileEdge);
// A stitch kept too means no tile runs at all.
if (edges.includes(true) && !edges.includes(false)) {
apply({ type: "tiles", promptId: detail?.prompt_id ?? null, on: true });
}
});
on("progress", (detail) => {
if (isForeignRun(detail?.prompt_id)) return;
apply({ type: "progress", promptId: detail?.prompt_id ?? null, node: detail?.node, value: detail?.value, max: detail?.max });
});
// A node of the run showed a picture: the finished one, for Step Preview.
on("executed", (detail) => {
if (run.status !== "running" || isForeignRun(detail?.prompt_id)) return;
const query = outputRecordQuery(detail?.output);
if (!query) return;
const id = String(detail.display_node ?? detail.node ?? "");
const node = nodeByExecutionId(rootGraph(), id);
const fed = Boolean(node && run.maker && feedsInto(rootGraph(), node, run.maker));
apply({
type: "result",
promptId: detail.prompt_id ?? null,
url: api.apiURL(`/view?${query}${app.getPreviewFormatParam?.() ?? ""}${app.getRandParam?.() ?? ""}`),
rank: resultRank(node?.type ?? node?.comfyClass, fed),
});
});
on("execution_success", (detail) => apply({ type: "end", promptId: detail?.prompt_id ?? null, kind: "success" }));
on("execution_interrupted", (detail) => apply({ type: "end", promptId: detail?.prompt_id ?? null, kind: "interrupted" }));
on("execution_error", (detail) => apply({ type: "end", promptId: detail?.prompt_id ?? null, kind: "error" }));
on("status", (detail) => {
const remaining = Number(detail?.exec_info?.queue_remaining);
const next = Number.isFinite(remaining) ? remaining : 0;
if (next === waiting) return;
waiting = next;
emit();
});
}
// Hear about every change to the run, starting with how it is now.
export function watchSteps(listener) {
wire();
listeners.add(listener);
try {
listener(run);
} catch {
// Same as emit().
}
return () => listeners.delete(listener);
}
export function stepRun() {
return run;
}
// Runs in the queue, the one running included.
export function queuedRuns() {
return waiting;
}
// Stop the run that is making the pictures; the queue goes on.
export async function stopStepRun() {
await api.interrupt(run.status === "running" ? run.promptId ?? undefined : undefined);
}
// Empty the queue, then stop the run.
export async function stopAllRuns() {
await api.clearItems("queue");
await api.interrupt();
}
// A save node whose steps switch is `isOn()`. Returns the way to forget it.
export function addStepConsumer(node, isOn) {
consumers.set(node, isOn);
return () => consumers.delete(node);
}
// Whether `node` (a save node) shows the run's current picture: its switch
// is on and the picture's maker feeds it, or feeds none of them.
export function showsStepsOn(node, current = run) {
if (!current.maker || current.status === "idle") return false;
const isOn = consumers.get(node);
if (!isOn?.()) return false;
const active = [...consumers].filter(([, on]) => on()).map(([consumer]) => consumer);
return consumersOf(rootGraph(), active, current.maker).includes(node);
}
export function ensureStepCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-steps{display:none;position:absolute;inset:0;z-index:4;pointer-events:none;}
.ausboss-steps.on{display:block;}
.ausboss-steps-showing>img,.ausboss-steps-showing>video,.ausboss-steps-showing>.ausboss-input-preview-hint,.ausboss-steps-showing>.ausboss-video-status{visibility:hidden;}
.ausboss-steps img,.ausboss-steps.on img{display:block;position:absolute;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;object-fit:contain;visibility:visible;}
.ausboss-steps-line{position:absolute;left:0;top:0;height:3px;width:0;background:${BRAND};box-shadow:0 0 6px rgba(0,180,170,.5);transition:width .15s linear;}
.ausboss-steps-chip{position:absolute;left:6px;right:6px;bottom:6px;box-sizing:border-box;padding:4px 7px;border:1px solid rgba(255,255,255,.08);border-radius:5px;background:rgba(8,12,13,.84);color:#d8ecea;font:600 10.5px/1.35 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.ausboss-steps.stopped img{filter:brightness(.55) saturate(.7);}
.ausboss-steps.stopped .ausboss-steps-line{display:none;}
.ausboss-steps.stopped .ausboss-steps-chip{border-color:rgba(224,96,90,.55);color:#ffd0cc;}
.ausboss-steps-hint{position:absolute;left:50%;top:50%;max-width:86%;transform:translate(-50%,-50%);color:#78908e;font:11px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;}
.ausboss-steps-stop{border-color:rgba(224,96,90,.8)!important;background:rgba(58,28,27,.9)!important;color:#ffd9d6!important;}
.ausboss-steps-stop:hover{background:rgba(120,38,33,.95)!important;color:#fff!important;}
.ausboss-steps-stop::before{content:"";display:inline-block;width:6px;height:6px;margin-right:5px;border-radius:1px;background:#e0605a;vertical-align:1px;}
.ausboss-video-tool.ausboss-steps-stop{display:none;}
.ausboss-video-tool.ausboss-steps-stop.live{display:inline-block;}
.ausboss-video-stage.is-still .ausboss-video-tools.ausboss-steps-tools{display:flex;}
.ausboss-video-stage.is-still .ausboss-steps-tools>.ausboss-steps-loop{display:none;}
`;
document.head.appendChild(style);
}
// The step picture laid over `stage` (a node's own picture box, which must
// be positioned). show() draws a run, hide() gives the box back.
export function mountStepOverlay(stage) {
ensureStepCss();
const root = document.createElement("div");
root.className = "ausboss-steps";
const img = document.createElement("img");
img.alt = "";
const line = document.createElement("div");
line.className = "ausboss-steps-line";
const chip = document.createElement("div");
chip.className = "ausboss-steps-chip";
root.append(img, line, chip);
stage.append(root);
let shown = false;
return {
root,
get shown() {
return shown;
},
// `saves`: the node saves the result, so a stopped run says nothing was.
// `result`: show the finished picture once the run has one.
show(current, { saves = false, result = false } = {}) {
const url = (result && current?.result) || current?.url;
if (!url) return this.hide();
if (img.getAttribute("src") !== url) img.src = url;
const stopped = current.status === "stopped";
root.classList.toggle("stopped", stopped);
line.style.width = `${Math.round(stepFraction(current) * 100)}%`;
chip.textContent = stopped ? stoppedText(current, { saves }) : stepText(current);
root.classList.add("on");
stage.classList.add("ausboss-steps-showing");
shown = true;
return true;
},
hide() {
root.classList.remove("on", "stopped");
stage.classList.remove("ausboss-steps-showing");
img.removeAttribute("src");
shown = false;
return false;
},
};
}