Files
DemonGatanjieuandClaude Opus 5.5 ec81f8b230 Bring a save's pictures back onto the output nodes
Going back to a save left the last run's picture on the output node, so the canvas and the picture told different stories.

- api/workflow_saves.py keeps what each output node made, previews too, the newest 16, and read_group gives them as `outputs`.
- ui_snapshot_bar.js puts them on the nodes through the tab's changeTracker (store, restore): no `executed` event, so no plugin takes them for a new run. Several runs sit on the node as a batch does. A node the save has no picture for goes empty; Load Image's own picture stays (snapshot_plan.outputsAt).

The lit number went out every other time with a Preview Image on the canvas, and an empty undo step came with it: a loaded workflow serializes a little differently from its file, and a node gains an empty widget list when it shows a picture. The number now goes out only when the canvas really changed (withoutEmptyValues), and those differences take no step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:20:59 +08:00

107 lines
4.8 KiB
JavaScript

/**
* The saves bar's rules (ui_snapshot_bar.js draws it): how many runs the marks make, which seed each
* run gets and how a mark is compiled. A seed is any number widget with ComfyUI's control_after_generate beside it, a
* third party's as well, in subgraphs too. "random": every run a new seed in each. "locked": one seed
* per round, the same in every mark, so marks compare from the same noise; a fixed seed alone would
* send the same prompt again, and ComfyUI answers that from its cache with no new picture. Pure.
*/
export const MAX_COUNT = 16; // runs of one mark
export const MAX_RUNS = 64; // of a whole queue
const SEED_TOP = 1125899906842624; // ComfyUI's own widest seed
function* nodesOf(graph) {
for (const node of graph?._nodes || []) {
yield node;
if (node.subgraph) yield* nodesOf(node.subgraph);
}
}
/** The seed widgets of `graph`, at every level. */
export function seedWidgets(graph) {
const found = [];
for (const node of nodesOf(graph)) {
for (const widget of node.widgets || []) {
if (typeof widget.value === 'number' && widget.linkedWidgets?.some(item => item.name === 'control_after_generate')) found.push(widget);
}
}
return found;
}
/** A seed in `widget`'s range. */
function randomFor(widget, random) {
const low = Math.max(0, Number(widget.options?.min) || 0);
const high = Math.min(SEED_TOP, Number(widget.options?.max) || SEED_TOP);
return low + Math.floor(random() * (high - low));
}
/** Writes this run's seeds: `seed` in every widget, or with none a new one in each. */
export function applySeeds(graph, seed = null, random = Math.random) {
for (const widget of seedWidgets(graph)) widget.value = seed ?? randomFor(widget, random);
}
/** A seed to start a locked queue from, leaving room for its rounds. */
export const baseSeed = (random = Math.random) => Math.floor(random() * (SEED_TOP - MAX_COUNT));
/**
* What ComfyUI would queue for `graph`, a graph off the canvas. A plugin may put its own
* app.graphToPrompt in front of ComfyUI's and leave the graph out (ComfyUI-Manager does), and ComfyUI
* then compiles the canvas: a result that is not `graph`'s is made again by ComfyUI's own method, past them.
*/
export async function compiled(app, graph) {
const made = await app.graphToPrompt(graph);
return made.workflow?.id === graph.id ? made : Object.getPrototypeOf(app).graphToPrompt.call(app, graph);
}
/** The seeds of `mark`'s runs in order: null for a new one each time. */
export const seedsOf = (mark, mode, base) => Array.from({ length: mark.count }, (_, round) => (mode === 'locked' ? base + round : null));
export const runTotal = marks => marks.reduce((sum, mark) => sum + mark.count, 0);
/**
* A line of jobs: `add(id, job)` runs `job` once those before it are done, in the order they were added,
* and gives what it gives. A job still waiting is not added again (a double click): that gives 0.
*/
export function jobLine() {
const waiting = new Set();
let last = Promise.resolve();
return (id, job) => {
if (waiting.has(id)) return Promise.resolve(0);
waiting.add(id);
const turn = last.then(job).finally(() => waiting.delete(id));
last = turn.catch(() => {});
return turn;
};
}
/** What a save changes from the one before: nothing ('same'), only 'values', or the 'nodes' themselves. */
export function changeKind(changes) {
if (!changes.length) return 'same';
return changes.some(change => change.kind !== 'changed') ? 'nodes' : 'values';
}
/** What the user typed as a mark's runs, within what a mark takes. */
/**
* What the nodes show at a save, from ComfyUI's `nodeOutputs`: the save's pictures on the nodes that made
* them, as a batch shows, and no picture from elsewhere on the others. `saved` is the save's `outputs`.
* Load Image's own picture and what is no picture stay.
*/
export function outputsAt(nodeOutputs, saved = {}) {
const stays = ([, output]) => !output?.images?.length || output.images.every(image => image.type === 'input');
const shown = Object.fromEntries(Object.entries(nodeOutputs).filter(stays));
for (const [node, images] of Object.entries(saved)) shown[node] = { images };
return shown;
}
const blank = value => value == null || (typeof value === 'object' && !Object.keys(value).length);
/** A canvas state without what a node gains by showing a picture, empty lists of values: no change of the canvas. */
export function withoutEmptyValues(state) {
const nodes = (state?.nodes || []).map(({ widgets_values: values, widgets_values_named: named, ...node }) => ({
...node, ...(blank(values) ? {} : { widgets_values: values }), ...(blank(named) ? {} : { widgets_values_named: named }),
}));
return { ...state, nodes };
}
export const runCount = value => Math.max(1, Math.min(MAX_COUNT, Math.floor(Number(value)) || 1));