Files
ausboss-ComfyUI-AusBoss/js/shared/input_preview.mjs
T
ausboss 52b23691dc Save Image shows a small copy of each picture on its face
The node's face loaded the whole saved file: 31 MB for a 9232 x 4608
sheet, where the panel draws a few hundred pixels of it. On a 2.8 MB/s
link that took half a minute.

Save Image now writes a WebP copy of each picture, at most 2048 pixels on
the long side, into the temp folder (ausboss_preview) and names them in
its result. The panel shows that copy and goes back to the file itself
when the copy is gone, as after a restart. The queue still lists the
saved file, and preview off writes no copy.

Same link, same picture: the new picture shows 0.3 s after the run
(0.2 MB), where it took 32.6 s.
2026-11-12 10:13:23 -06:00

180 lines
8.0 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 { parseImageReference } from "./pad_canvas.mjs";
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 a file widget's name,
// tolerating Windows separators. A name without a tag is in the input
// folder; "photo.png [output]" or "[temp]" is read from that folder.
export function viewQueryForFile(name) {
const { filename, subfolder } = splitMediaName(name);
const type = parseImageReference(name)?.type ?? "input";
return new URLSearchParams({ filename, subfolder, type }).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();
}
// The /view query of the small copy Save Image writes for its face
// ({ ausboss_preview: ["ausboss_preview/<name>.webp"] }, one for each entry
// of images, in the temp folder). Null when the record has none: an older
// run, another node, or the preview switched off.
export function lightPreviewQuery(record, index = null) {
const places = record?.ausboss_preview;
const count = outputRecordCount(record);
if (!count || !Array.isArray(places) || places.length !== count) return null;
const at = Number.isInteger(index) && index >= 0 && index < count ? index : count - 1;
const place = places[at];
if (typeof place !== "string" || !place) return null;
const slash = place.lastIndexOf("/");
return new URLSearchParams({
filename: place.slice(slash + 1),
subfolder: slash >= 0 ? place.slice(0, slash) : "",
type: "temp",
}).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;
}
// Is node.imgs the picture of this output record?
//
// The frontend loads a result into node.imgs with a time limit (two tries of
// about 8 seconds). A picture that takes longer, a big one over a slow link,
// is dropped without a word and node.imgs keeps the run before. `loadedFor`
// is the record that was current when node.imgs was last set; the file name
// is compared as well, for a newer run that arrived while a picture loaded.
export function ownResultIsCurrent(node, record, loadedFor) {
const images = node?.imgs;
const src = Array.isArray(images) && images.length ? images[images.length - 1]?.src : null;
if (typeof src !== "string" || !src) return false;
const wanted = outputRecordQuery(record);
// No stored picture to compare with, or a frame streamed during the run.
if (!wanted || !/[?&]filename=/.test(src)) return true;
if (loadedFor !== record) return false;
const have = new URLSearchParams(src.slice(src.indexOf("?") + 1));
const want = new URLSearchParams(wanted);
return ["filename", "subfolder", "type"].every((key) => (have.get(key) ?? "") === (want.get(key) ?? ""));
}
// 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.
// `ownIsCurrent` false: node.imgs is an older run's (ownResultIsCurrent), so
// the stored output goes first.
export function describeNodePreview(node, inputName, storedUrl = null, ownIsCurrent = true) {
const stored = storedUrl ? { kind: "url", url: storedUrl } : null;
return (ownIsCurrent ? null : stored)
?? describeOwnResult(node)
?? stored
?? describeSourcePreview(upstreamNode(node, inputName));
}
// Quiet ASCII placeholder copy; never an error. `running`: a run is on its
// way to this node, which shows its steps ("steps") or only the result.
export function placeholderText(connected, noun = "an image", running = false) {
if (!connected) return `Connect ${noun} to see it here`;
if (running === "steps") return "Running. The steps show here once the picture starts";
if (running) return "Running. The picture shows here when it is saved";
return "Run the workflow 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";
}