Files
Adam 08b2582d13 feat: add Every Nth Queue node for cache-friendly periodic input
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.
2026-10-17 18:57:56 -07:00

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);
}