Add PromptStashEveryNthQueue, a BOOLEAN node that outputs True once every
N queued runs. The countdown is stored only in the frontend
(node.properties) and resolved in a widget beforeQueued hook into a hidden
`value` widget, so the backend sees just a constant. Off runs share one
cache key, so nothing downstream re-runs. The node has a status line
("True next run" / "True in N runs") and Next / Delay +1 / Reset buttons.
Partial runs don't advance the countdown, and muted or bypassed nodes are
skipped.
Saver and Passthrough now follow a linked use_input_text to constant
boolean sources (Every Nth Queue, core PrimitiveBoolean) via
resolveConstantBool. When the value is false they strip the unused lazy
`text` link, keeping upstream nodes out of the cache key.
384 lines
15 KiB
JavaScript
384 lines
15 KiB
JavaScript
/**
|
|
* Utility functions for ComfyUI-Prompt-Stash
|
|
*/
|
|
|
|
// ── Primitive Node Support (connectable combo inputs) ──────────────────────
|
|
//
|
|
// Since ComfyUI frontend >=1.16, widget->input "conversion" was removed and
|
|
// input sockets co-exist automatically -- but only for widgets declared in the
|
|
// node's Python INPUT_TYPES. A Primitive node reads a combo's allowed values
|
|
// from the input slot's widget via a framework-internal anonymous symbol
|
|
// (GET_CONFIG). That symbol is not importable from a custom extension, so we
|
|
// discover it at runtime and override it to expose the combo's *live* options.
|
|
|
|
/**
|
|
* The framework's GET_CONFIG symbol, used by Primitive nodes to read combo
|
|
* options from an input slot's widget. Discovered at runtime by inspecting an
|
|
* already-wired input widget for a symbol-keyed function that returns an array
|
|
* (the InputSpec shape). Cached after first discovery.
|
|
*
|
|
* @type {symbol|null}
|
|
*/
|
|
let _getConfigSymbol = null;
|
|
|
|
/**
|
|
* Find the framework's GET_CONFIG symbol by inspecting the node's input slots'
|
|
* widgets for symbol-keyed properties whose value is a function returning an
|
|
* array.
|
|
*
|
|
* @param {LGraphNode} node - The node instance.
|
|
* @returns {symbol|null} The GET_CONFIG symbol, or null if not found.
|
|
*/
|
|
export function findGetConfigSymbol(node) {
|
|
if (_getConfigSymbol) return _getConfigSymbol;
|
|
|
|
for (const input of node.inputs ?? []) {
|
|
if (!input.widget) continue;
|
|
const symbols = Object.getOwnPropertySymbols(input.widget);
|
|
for (const sym of symbols) {
|
|
const val = input.widget[sym];
|
|
if (typeof val === "function") {
|
|
try {
|
|
if (Array.isArray(val())) {
|
|
_getConfigSymbol = sym;
|
|
return sym;
|
|
}
|
|
} catch (_) {
|
|
// Skip symbols whose getter throws.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Install (or re-assert) a GET_CONFIG override on a combo widget's input slot
|
|
* so that connected Primitive nodes read the combo's live options instead of
|
|
* the static list from the Python node definition.
|
|
*
|
|
* The optional fallback guards against the global "R" refresh
|
|
* (reloadNodeDefs), which transiently resets the combo's options.values to the
|
|
* static list from the node definition. That clobber happens before our
|
|
* refresh hook can repopulate, and a connected Primitive's refreshComboInNode
|
|
* would read the stub list and reset its value to item 0. When the live
|
|
* options look like the static stub, we return the fallback (last-known-good)
|
|
* list instead so the Primitive keeps its selection.
|
|
*
|
|
* @param {LGraphNode} node - The node instance.
|
|
* @param {string} widgetName - The combo widget / input name.
|
|
* @param {Object} [opts]
|
|
* @param {() => string[]} [opts.getFallbackValues] - Returns the last-known-good
|
|
* option list, used when the live options look like the static stub.
|
|
* @param {string[]} [opts.stubValues] - Single-element option lists equal to one
|
|
* of these are treated as the static stub.
|
|
*/
|
|
export function installComboGetConfig(node, widgetName, opts = {}) {
|
|
const sym = findGetConfigSymbol(node);
|
|
if (!sym) return;
|
|
|
|
const input = node.inputs?.find((i) => i.widget?.name === widgetName);
|
|
if (!input?.widget) return;
|
|
|
|
const combo = node.widgets?.find((w) => w.name === widgetName);
|
|
if (!combo) return;
|
|
|
|
const { getFallbackValues, stubValues = [] } = opts;
|
|
|
|
// Return the live options in the InputSpec shape: [valuesArray, opts].
|
|
// The Primitive reads [0] as the combo values.
|
|
input.widget[sym] = () => {
|
|
const live = combo.options?.values;
|
|
const looksLikeStub =
|
|
!Array.isArray(live) ||
|
|
live.length === 0 ||
|
|
(live.length === 1 && stubValues.includes(live[0]));
|
|
|
|
if (looksLikeStub && getFallbackValues) {
|
|
const fb = getFallbackValues();
|
|
if (Array.isArray(fb) && fb.length > 0) {
|
|
return [fb.slice(), {}];
|
|
}
|
|
}
|
|
|
|
return [(live ?? []).slice(), {}];
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Notify any Primitive node connected to a combo's input slot that its options
|
|
* changed. The Primitive caches the input slot's widget object and reads
|
|
* GET_CONFIG lazily; calling refreshComboInNode() forces it to re-read the
|
|
* (overridden) GET_CONFIG and rebuild its own dropdown.
|
|
*
|
|
* @param {LGraphNode} node - The node instance.
|
|
* @param {string} widgetName - The combo widget / input name.
|
|
*/
|
|
export function refreshConnectedPrimitives(node, widgetName) {
|
|
const input = node.inputs?.find((i) => i.widget?.name === widgetName);
|
|
if (!input || input.link == null) return;
|
|
|
|
const graph = node.graph;
|
|
const link = graph?.links?.[input.link];
|
|
if (!link) return;
|
|
|
|
const sourceNode = graph.getNodeById?.(link.origin_id);
|
|
if (sourceNode && typeof sourceNode.refreshComboInNode === "function") {
|
|
// refreshComboInNode() updates widget.options.values, and clamps/reassigns
|
|
// widget.value ONLY if the current value fell out of range. After a
|
|
// workflow load the Primitive's value is usually still valid, so it never
|
|
// re-assigns .value -- and a bare options.values mutation is invisible to
|
|
// Vue (Nodes 2.0). The dropdown keeps showing the stale stub options with a
|
|
// red "value not in list" outline until the value flows through the
|
|
// reactive path (e.g. on Run). Force that reactive path here.
|
|
sourceNode.refreshComboInNode();
|
|
forceWidgetReactiveUpdate(sourceNode, sourceNode.widgets?.[0]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Push a combo widget's current value back onto the Primitive node connected to
|
|
* its input slot, so the Primitive's dropdown reflects a value the node changed
|
|
* itself (the value sync is normally one-way: Primitive -> node via applyToGraph).
|
|
*
|
|
* Deliberately does NOT invoke the Primitive's `callback` (which would run
|
|
* applyToGraph and push the value back into the node, causing a ping-pong); it
|
|
* only updates the value and forces a re-render. A value-difference guard makes
|
|
* it a no-op when already in sync.
|
|
*
|
|
* @param {LGraphNode} node - The node owning the combo widget.
|
|
* @param {string} widgetName - The combo widget / input name.
|
|
*/
|
|
export function syncValueToConnectedPrimitive(node, widgetName) {
|
|
const input = node.inputs?.find((i) => i.widget?.name === widgetName);
|
|
if (!input || input.link == null) return;
|
|
|
|
const graph = node.graph;
|
|
const link = graph?.links?.[input.link];
|
|
if (!link) return;
|
|
|
|
const sourceNode = graph.getNodeById?.(link.origin_id);
|
|
const primitiveWidget = sourceNode?.widgets?.[0];
|
|
if (!primitiveWidget) return;
|
|
|
|
const comboValue = node.widgets?.find((w) => w.name === widgetName)?.value;
|
|
if (primitiveWidget.value === comboValue) return;
|
|
|
|
primitiveWidget.value = comboValue;
|
|
forceWidgetReactiveUpdate(sourceNode, primitiveWidget);
|
|
}
|
|
|
|
/**
|
|
* Force a Vue (Nodes 2.0) re-render and error re-evaluation for a widget whose
|
|
* options.values were mutated directly from JS.
|
|
*
|
|
* Re-assigning `widget.value` routes through the widget's reactive setter
|
|
* (BaseWidget writes its backing reactive store entry), which invalidates the
|
|
* computed that drives the rendered dropdown. Firing `node.onWidgetChanged`
|
|
* clears the backend `value_not_in_list` execution error so the red outline goes
|
|
* away. Both are no-ops on the legacy canvas, so this is safe in either mode.
|
|
*
|
|
* @param {LGraphNode} node - The node owning the widget.
|
|
* @param {Object} widget - The widget whose options were changed.
|
|
*/
|
|
export function forceWidgetReactiveUpdate(node, widget) {
|
|
if (!widget) return;
|
|
const value = widget.value;
|
|
|
|
// Re-assigning the SAME value is a no-op: Vue's reactive setter short-circuits
|
|
// on Object.is(old, new), so processedWidgets never recomputes and the dropdown
|
|
// keeps its stale options. Write a transient sentinel first to defeat the
|
|
// equality check, then restore -- this forces the recompute that re-reads the
|
|
// updated options.values. (Mirrors triggerComboReactivity for our own widgets.)
|
|
if (typeof value === "string") {
|
|
widget.value = value + "\x00";
|
|
widget.value = value;
|
|
} else {
|
|
widget.value = value;
|
|
}
|
|
|
|
// Clear the backend `value_not_in_list` execution error (the red outline).
|
|
node?.onWidgetChanged?.(widget.name, value, value, widget);
|
|
}
|
|
|
|
/**
|
|
* Walk a graph and its subgraphs recursively, invoking the callback for each
|
|
* node.
|
|
*
|
|
* @param {LGraph} graph - A LiteGraph graph object.
|
|
* @param {(node: LGraphNode) => void} callback - Called with each node.
|
|
*/
|
|
export function walkGraph(graph, callback) {
|
|
for (const node of graph?.nodes ?? []) {
|
|
callback(node);
|
|
if (node.subgraph) walkGraph(node.subgraph, callback);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Node classes whose single BOOLEAN output (slot 0) is exactly their literal
|
|
* `value` input, so the value is known from the queued prompt before anything
|
|
* executes. Every Nth Queue writes its resolved value there at queue time.
|
|
*/
|
|
const CONSTANT_BOOL_SOURCES = new Set(["PromptStashEveryNthQueue", "PrimitiveBoolean"]);
|
|
|
|
/**
|
|
* Resolve an API-prompt input to a boolean if it is knowable at queue time:
|
|
* either a literal boolean, or a link to a constant boolean source node whose
|
|
* `value` is a literal. Returns undefined when the value is only known at
|
|
* runtime (any other link, missing, or non-boolean).
|
|
*
|
|
* @param {Object} output - The API prompt map (node_id -> {class_type, inputs}).
|
|
* @param {*} input - The input value from a node's `inputs` entry.
|
|
* @returns {boolean|undefined}
|
|
*/
|
|
export function resolveConstantBool(output, input) {
|
|
if (typeof input === "boolean") return input;
|
|
if (!Array.isArray(input) || input.length !== 2) return undefined;
|
|
|
|
const [sourceId, sourceSlot] = input;
|
|
if (Number(sourceSlot) !== 0) return undefined;
|
|
const source = output?.[String(sourceId)];
|
|
if (!source || !CONSTANT_BOOL_SOURCES.has(source.class_type)) return undefined;
|
|
|
|
const value = source.inputs?.value;
|
|
return typeof value === "boolean" ? value : undefined;
|
|
}
|
|
|
|
/**
|
|
* The id LiteGraph assigns to a node that is not (yet) part of a graph.
|
|
* ComfyUI frontend >=1.46 brands node ids as strings ("-1"); earlier versions
|
|
* used the number -1.
|
|
*/
|
|
const UNASSIGNED_NODE_ID = "-1";
|
|
|
|
/**
|
|
* Set a widget's display label, but only if the user hasn't already given
|
|
* it a custom one. Only `.label` is touched -- never `.name`, which must stay
|
|
* in sync with the Python input key.
|
|
*
|
|
* A user rename (renameWidget) sets `input.label` on the widget's backing
|
|
* input slot, which IS serialized; the frontend copies it back onto
|
|
* `widget.label` in LGraphNode.configure(). configure() normally runs after
|
|
* onNodeCreated, but guard anyway so we never stomp a custom label if the
|
|
* order differs (paste/clone, Nodes 2.0). See issue #13.
|
|
*
|
|
* @param {LGraphNode} node - The node owning the widget.
|
|
* @param {Object|undefined} widget - The widget to label.
|
|
* @param {string} label - Default label to apply.
|
|
*/
|
|
export function setDefaultLabel(node, widget, label) {
|
|
if (!widget) return;
|
|
const input = node?.inputs?.find?.((i) => i.widget?.name === widget.name);
|
|
// A truthy label that differs from the raw python key is a custom one.
|
|
const custom = input?.label || (widget.label && widget.label !== widget.name ? widget.label : "");
|
|
widget.label = custom || label;
|
|
}
|
|
|
|
/**
|
|
* Whether a node is detached / not yet assigned a real id by a graph.
|
|
*
|
|
* @param {LGraphNode} node - The node to check.
|
|
* @returns {boolean}
|
|
*/
|
|
export function isUnassignedNode(node) {
|
|
return String(node?.id) === UNASSIGNED_NODE_ID;
|
|
}
|
|
|
|
/**
|
|
* Check if a node matches a potentially-prefixed UNIQUE_ID from the backend.
|
|
* Handles subgraph paths like "54:73" or "54:62:174".
|
|
*
|
|
* All comparisons are done on strings. ComfyUI frontend >=1.46 brands
|
|
* `LGraphNode.id` as a string (`node.id === "5"`); before that it was a number
|
|
* (`node.id === 5`). Comparing the backend id as a Number therefore silently
|
|
* failed against every node on the newer frontend. Object property lookup
|
|
* coerces keys to strings, so passing a string to `getNodeById` works on both.
|
|
*
|
|
* @param {LGraphNode} node - The node to check
|
|
* @param {string|number} uniqueId - The UNIQUE_ID from backend (e.g., "54:73" or "73")
|
|
* @returns {boolean}
|
|
*/
|
|
export function nodeMatchesUniqueId(node, uniqueId) {
|
|
const parts = String(uniqueId).split(':');
|
|
const localId = parts.pop();
|
|
|
|
// Quick exit: local ID must match
|
|
if (localId !== String(node.id)) return false;
|
|
|
|
// No prefix means node should be in root graph
|
|
if (parts.length === 0) {
|
|
return node.graph?.isRootGraph ?? true;
|
|
}
|
|
|
|
// Walk the path from root to find the target subgraph's UUID
|
|
let current = node.graph?.rootGraph;
|
|
if (!current) return false;
|
|
|
|
for (const subgraphNodeId of parts) {
|
|
const subgraphNode = current.getNodeById(subgraphNodeId);
|
|
if (!subgraphNode?.subgraph) return false;
|
|
current = subgraphNode.subgraph;
|
|
}
|
|
|
|
// current.id should now be the UUID of the subgraph containing the leaf
|
|
return current.id === node.graph.id;
|
|
}
|
|
|
|
/**
|
|
* Get the full UNIQUE_ID path for a node, matching backend format.
|
|
* Returns "54:62:73" for nested subgraphs, or just "73" for root-level nodes.
|
|
*
|
|
* @param {LGraphNode} node - The node to get the path for
|
|
* @returns {string} The full UNIQUE_ID path
|
|
*/
|
|
export function getUniqueIdFromNode(node) {
|
|
const leafId = node.id;
|
|
|
|
// Easy case: node is in root graph
|
|
if (node.graph?.isRootGraph) {
|
|
return String(leafId);
|
|
}
|
|
|
|
const targetUUID = node.graph.id;
|
|
const rootGraph = node.graph.rootGraph;
|
|
|
|
if (!rootGraph) {
|
|
return String(leafId); // Fallback
|
|
}
|
|
|
|
// Get the set of subgraph UUIDs for quick lookup
|
|
const subgraphUUIDs = new Set(rootGraph.subgraphs?.keys() ?? []);
|
|
|
|
// Recursive search: find the path of node IDs leading to targetUUID
|
|
function findPathToUUID(graph, target) {
|
|
for (const graphNode of graph.nodes ?? []) {
|
|
// Check if this node is a subgraph container
|
|
if (subgraphUUIDs.has(graphNode.type) && graphNode.subgraph) {
|
|
if (graphNode.subgraph.id === target) {
|
|
// Found it! Return this node's ID as the path
|
|
return [graphNode.id];
|
|
}
|
|
|
|
// Not a direct match, search deeper
|
|
const deeperPath = findPathToUUID(graphNode.subgraph, target);
|
|
if (deeperPath) {
|
|
// Found it deeper, prepend this node's ID
|
|
return [graphNode.id, ...deeperPath];
|
|
}
|
|
}
|
|
}
|
|
return null; // Not found in this branch
|
|
}
|
|
|
|
const path = findPathToUUID(rootGraph, targetUUID);
|
|
|
|
if (path) {
|
|
return [...path, leafId].join(':');
|
|
}
|
|
|
|
// Fallback if we couldn't find the path (shouldn't happen)
|
|
console.warn('getUniqueIdFromNode: Could not resolve subgraph path for node', node.id);
|
|
return String(leafId);
|
|
}
|