Files
ausboss-ComfyUI-AusBoss/js/shared/input_preview.mjs
T
ausboss bf2f75d487 Compare and Save Image: step through several pictures
A list of pictures runs these nodes once per picture, and the panels
only showed one of them (#204). Arrows (2 / 5) now appear on the node
when a run brings more than one picture: Compare steps through its
pairs, A and B together, and Erase saves the pair on screen. The shared
preview panel gets the same arrows, so Save Image, Mask Refine, Mask by
Name, LaMa Inpaint and Select Frame step through their results. A node
too narrow for the arrows widens to fit while they show.
2026-10-18 17:50:53 +00:00

130 lines
5.4 KiB
JavaScript

// Pure resolution rules for the upstream-input preview panel. DOM-free so
// node:test covers them without a browser; the wiring lives in
// js/input_preview/index.js.
//
// Best-effort order for a connected source node:
// 1. cached execution previews (sourceNode.imgs[0].src)
// 2. an uploadable image/video file combo widget -> deterministic /view query
// 3. nothing -> the caller shows a quiet placeholder
import { splitMediaName } from "./video_preview.mjs";
// Core loader nodes keep the uploaded file name in a combo widget with one of
// these names (LoadImage uses "image", video loaders use "video").
const FILE_WIDGET_NAMES = new Set(["image", "video"]);
// Graph link stores differ by frontend generation: a Map on the modern
// frontend, a plain id-keyed object on legacy LiteGraph.
export function linkById(graph, linkId) {
const links = graph?.links;
if (links == null || linkId == null) return null;
const link = typeof links.get === "function" ? links.get(linkId) : links[linkId];
return link ?? null;
}
// Walk the named input's link back to the node feeding it, or null.
export function upstreamNode(node, inputName) {
const graph = node?.graph;
if (!graph) return null;
const input = node.inputs?.find((candidate) => candidate?.name === inputName);
const link = linkById(graph, input?.link);
if (!link) return null;
return graph.getNodeById?.(link.origin_id) ?? null;
}
// The widget whose callback fires when the user picks a different file.
export function sourceFileWidget(sourceNode) {
return sourceNode?.widgets?.find((widget) => FILE_WIDGET_NAMES.has(widget?.name)) ?? null;
}
// Deterministic /view query (no cache buster) for an input-folder file name,
// tolerating Windows separators and " [input]" annotations.
export function viewQueryForFile(name) {
const { filename, subfolder } = splitMediaName(name);
return new URLSearchParams({ filename, subfolder, type: "input" }).toString();
}
// Describe how to preview a source node:
// { kind: "url", url } cached preview image
// { kind: "view", isVideo, query } file widget, serve via api /view
// null nothing resolvable
export function describeSourcePreview(sourceNode) {
if (!sourceNode) return null;
const cached = sourceNode.imgs?.[0]?.src;
if (typeof cached === "string" && cached) return { kind: "url", url: cached };
const widget = sourceFileWidget(sourceNode);
if (widget && typeof widget.value === "string" && widget.value) {
return {
kind: "view",
isVideo: widget.name === "video",
query: viewQueryForFile(widget.value),
};
}
return null;
}
// The node's own latest result, if it has produced one.
//
// imgs is where the frontend leaves both the streamed progress frames of a
// running node and the images from its ui payload, so the last entry is the
// most recent thing this node made. The panel prefers it over the upstream
// input for the obvious reason: once a node has a result, its result is what
// you want to look at.
export function describeOwnResult(node) {
const images = node?.imgs;
if (!Array.isArray(images) || images.length === 0) return null;
const src = images[images.length - 1]?.src;
return typeof src === "string" && src ? { kind: "url", url: src } : null;
}
// The key the frontend files a node's outputs under (app.nodeOutputs): the
// node's id in the root graph, "<subgraph id>:<id>" inside a subgraph.
export function outputLocatorId(node) {
const graph = node?.graph;
if (!graph || node.id == null) return null;
const root = graph.rootGraph ?? graph;
return graph !== root && graph.id ? `${graph.id}:${node.id}` : String(node.id);
}
// The /view query for an image in a node's output record
// ({ images: [{ filename, subfolder, type }] }): the newest one, or the one
// at `index` when given. Null when it has none.
// Nodes 2.0 fills node.imgs only from its own output preview, which the panel
// stands down, so there this record is the node's result.
export function outputRecordQuery(record, index = null) {
const images = record?.images;
const count = outputRecordCount(record);
const at = Number.isInteger(index) && index >= 0 && index < count ? index : count - 1;
const image = count ? images[at] : null;
if (!image || typeof image.filename !== "string" || !image.filename) return null;
return new URLSearchParams({
filename: image.filename,
subfolder: image.subfolder ?? "",
type: image.type ?? "output",
}).toString();
}
// How many pictures the node's last run left in its output record.
export function outputRecordCount(record) {
return Array.isArray(record?.images) ? record.images.length : 0;
}
// What the panel shows, best first: this node's result (node.imgs, then the
// URL of its stored output), then whatever feeds it, then nothing.
export function describeNodePreview(node, inputName, storedUrl = null) {
return describeOwnResult(node)
?? (storedUrl ? { kind: "url", url: storedUrl } : null)
?? describeSourcePreview(upstreamNode(node, inputName));
}
// Quiet ASCII placeholder copy; never an error.
export function placeholderText(connected, noun = "an image") {
return connected ? "Run the workflow to see it here" : `Connect ${noun} to see it here`;
}
// The node ran while its preview was off, so its last picture is older
// than its result.
export function staleText() {
return "Run the workflow again to see it here";
}