Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

439 lines
16 KiB
JavaScript

// Dynamic output slot visibility — auto-shows positive/negative output slots
// when the node is connected downstream to a KSampler via CLIPTextEncode.
import { app } from "../../../scripts/app.js";
import { NODE_TYPE } from "./config.js";
import { getLink } from "./slot-utils.js";
// ── detection helpers ──────────────────────────────────────────────
function isKSamplerNode(node) {
const cls = node.comfyClass || "";
if (cls === "KSampler" || cls === "KSamplerAdvanced") return true;
if (cls.includes("KSampler")) return true;
// fallback: any node with positive/negative CONDITIONING inputs
let hasPos = false, hasNeg = false;
for (const inp of node.inputs || []) {
if (inp.type === "CONDITIONING") {
const name = inp.name?.toLowerCase();
if (name === "positive") hasPos = true;
if (name === "negative") hasNeg = true;
}
}
return hasPos && hasNeg;
}
function isCLIPTextEncode(node) {
const cls = node.comfyClass || "";
return cls === "CLIPTextEncode" || cls.includes("CLIPTextEncode");
}
// ── graph tracing ──────────────────────────────────────────────────
/**
* Trace from a target node downstream to find which KSampler input the path feeds.
* Returns "positive", "negative", or null.
*/
function traceLinkToSampler(node, graph, visited) {
if (!node || visited.has(node.id)) return null;
visited.add(node.id);
if (node.comfyClass === NODE_TYPE) return null; // chaining — not a sampler path
if (isCLIPTextEncode(node)) {
for (const output of node.outputs || []) {
if (output.type !== "CONDITIONING" || !output.links?.length) continue;
for (const lid of output.links) {
const lnk = getLink(graph, lid);
if (!lnk) continue;
const down = graph.getNodeById(lnk.target_id);
if (!down) continue;
if (isKSamplerNode(down)) {
const name = down.inputs?.[lnk.target_slot]?.name?.toLowerCase();
if (name === "positive") return "positive";
if (name === "negative") return "negative";
}
const r = traceLinkToSampler(down, graph, visited);
if (r) return r;
}
}
return null;
}
// generic node — keep following outputs
for (const output of node.outputs || []) {
if (!output.links?.length) continue;
for (const lid of output.links) {
const lnk = getLink(graph, lid);
if (!lnk) continue;
const down = graph.getNodeById(lnk.target_id);
if (!down) continue;
if (isKSamplerNode(down)) {
const name = down.inputs?.[lnk.target_slot]?.name?.toLowerCase();
if (name === "positive") return "positive";
if (name === "negative") return "negative";
}
const r = traceLinkToSampler(down, graph, visited);
if (r) return r;
}
}
return null;
}
/**
* Categorize all links on out (slot 0).
* chain = another PromptChain, positive/negative = traced to KSampler,
* unknown = connected to something we can't trace (show all slots as fallback).
*/
function categorizeOutLinks(node) {
const graph = app.graph;
const cats = { chain: [], positive: [], negative: [], unknown: [] };
if (!graph) return cats;
const outSlot = node.outputs?.[0];
if (!outSlot?.links?.length) return cats;
for (const linkId of [...outSlot.links]) {
const link = getLink(graph, linkId);
if (!link) continue;
const target = graph.getNodeById(link.target_id);
if (!target) continue;
if (target.comfyClass === NODE_TYPE) {
cats.chain.push(linkId);
} else {
const cat = traceLinkToSampler(target, graph, new Set());
if (cat === "positive") cats.positive.push(linkId);
else if (cat === "negative") cats.negative.push(linkId);
else cats.unknown.push(linkId);
}
}
return cats;
}
// ── FLUX detection ─────────────────────────────────────────────────
export function isFluxWorkflow() {
const graph = app.graph;
if (!graph) return false;
if (graph._pcrFluxMode !== undefined) return graph._pcrFluxMode;
let flux = false;
for (const n of graph._nodes || []) {
if (n.comfyClass === "DualCLIPLoader") {
const w = n.widgets?.find(w => w.name === "type");
if (w?.value?.toLowerCase?.()?.includes("flux")) { flux = true; break; }
}
if (n.comfyClass === "UNETLoader") {
const w = n.widgets?.find(w => w.name === "unet_name");
if (w?.value?.toLowerCase?.()?.includes("flux")) { flux = true; break; }
}
}
graph._pcrFluxMode = flux;
return flux;
}
export function invalidateFluxCache() {
if (app.graph) delete app.graph._pcrFluxMode;
}
// ── vue reactivity ─────────────────────────────────────────────────
function forceOutputRefresh(node) {
requestAnimationFrame(() => {
// guard: node may have been deleted before this rAF fires
if (!app.graph?.getNodeById(node.id)) return;
const savedSize = node.size ? [...node.size] : null;
app.graph.onNodeRemoved?.(node);
app.graph.onNodeAdded?.(node);
// restore size — Vue re-render from onNodeAdded can reset to minimum
if (savedSize) node.setSize?.(savedSize);
app.graph.setDirtyCanvas?.(true, true);
});
}
// ── slot management ────────────────────────────────────────────────
const _mutating = new WeakSet();
// Slot meaning is positional: 0=out, 1=positive, 2=negative, 3=regions.
// THE INVARIANT: the backend returns outputs by FIXED index, so an output may
// only be removed when nothing at or after it is linked — LiteGraph's
// removeOutput slides later outputs down (regions 3→1), the save persists the
// shifted origin_slot, and execution then feeds the POSITIVE string into a
// regions input (flat text into the couple/conditioning — a real shipped bug).
function linkedAtOrAfter(node, idx) {
return (node.outputs || []).some((o, i) => i >= idx && o.links?.length > 0);
}
function ensureSlotCount(node, count) {
const cur = node.outputs?.length || 0;
if (count > cur) {
if (cur < 2 && count >= 2) node.addOutput("positive", "STRING");
if ((node.outputs?.length || 0) < 3 && count >= 3) node.addOutput("negative", "STRING");
} else if (count < cur) {
if (cur >= 3 && count < 3 && !linkedAtOrAfter(node, 2)) node.removeOutput(2);
if ((node.outputs?.length || 0) >= 2 && count < 2 && !linkedAtOrAfter(node, 1)) node.removeOutput(1);
}
}
// Two-pass disconnect-then-reconnect: LiteGraph mutates the links array
// during disconnect, so resolving every target before the first disconnect
// is the only way to keep iteration stable.
function migrateLinks(node, linkIds, toSlot, graph) {
if (!linkIds.length) return;
const targets = [];
for (const lid of linkIds) {
const link = getLink(graph, lid);
if (!link) continue;
const targetNode = graph.getNodeById(link.target_id);
if (!targetNode) continue;
targets.push({ node: targetNode, slot: link.target_slot });
}
for (const t of targets) node.disconnectOutput(0, t.node);
for (const t of targets) node.connect(toSlot, t.node, t.slot);
}
// ── public API ─────────────────────────────────────────────────────
export function updateDynamicOutputs(node) {
if (_mutating.has(node)) return;
if (node._pcrInitializing) return;
const autoSplit = app.ui?.settings?.getSettingValue?.("PromptChain.AutoSplitOutputs") ?? true;
_mutating.add(node);
try {
if (!autoSplit) {
ensureSlotCount(node, 3);
} else {
const cats = categorizeOutLinks(node);
const hasKSampler = cats.positive.length > 0 || cats.negative.length > 0;
const hasUnknown = cats.unknown.length > 0;
if (hasUnknown) {
// unknown target — show all 3, let the user wire manually
ensureSlotCount(node, 3);
} else if (hasKSampler) {
const flux = isFluxWorkflow();
ensureSlotCount(node, flux ? 2 : 3);
const graph = app.graph;
if (graph) {
migrateLinks(node, cats.positive, 1, graph);
if (!flux) migrateLinks(node, cats.negative, 2, graph);
}
} else {
// no KSampler paths — collapse the extra outputs only when NOTHING
// beyond `out` is connected (regions included: a regional graph wires
// regions while pos/neg dangle, and removing pos/neg would slide the
// linked regions output off its backend index). Collapse goes all the
// way to [out] so the remaining array stays positionally canonical.
if (!linkedAtOrAfter(node, 1)) {
while ((node.outputs?.length || 0) > 1) node.removeOutput(node.outputs.length - 1);
}
}
}
forceOutputRefresh(node);
} finally {
_mutating.delete(node);
}
}
/**
* Trace upstream through CONDITIONING links from a node to find a text encoder
* (any node with a free STRING "text" or "prompt" input).
*/
function findTextEncoderUpstream(node, graph, visited) {
if (!node || visited.has(node.id)) return null;
visited.add(node.id);
const textInput = node.inputs?.find(i =>
i.type === "STRING" && (i.name === "text" || i.name === "prompt") && i.link == null
);
if (textInput) return node;
for (const inp of node.inputs || []) {
if (inp.type !== "CONDITIONING" || inp.link == null) continue;
const link = getLink(graph, inp.link);
if (!link) continue;
const source = graph.getNodeById(link.origin_id);
const result = findTextEncoderUpstream(source, graph, visited);
if (result) return result;
}
return null;
}
/**
* Auto-connect a freshly dropped PromptChain to free text encoder nodes
* feeding a KSampler. Conditions (all must be true):
* 1. Text encoder(s) upstream of a KSampler's positive (and optionally negative)
* 2. Exactly 1 KSampler in the graph has text encoders wired to it
* 3. Those text encoder inputs have no existing connections
* 4. No other PromptChain nodes exist in the graph
*/
export function autoConnectToSampler(node) {
const autoSplit = app.ui?.settings?.getSettingValue?.("PromptChain.AutoSplitOutputs") ?? true;
if (!autoSplit) return;
const graph = app.graph;
if (!graph) return;
// condition 4: no other PromptChain nodes
for (const n of graph._nodes || []) {
if (n.comfyClass === NODE_TYPE && n.id !== node.id) return;
}
// scan for KSampler nodes with text encoders upstream of their conditioning inputs
const candidates = [];
for (const n of graph._nodes || []) {
if (!isKSamplerNode(n)) continue;
let posEncoder = null, negEncoder = null;
for (const inp of n.inputs || []) {
if (inp.type !== "CONDITIONING" || inp.link == null) continue;
const link = getLink(graph, inp.link);
if (!link) continue;
const source = graph.getNodeById(link.origin_id);
const encoder = findTextEncoderUpstream(source, graph, new Set());
if (!encoder) continue;
const name = inp.name?.toLowerCase();
if (name === "positive") posEncoder = encoder;
else if (name === "negative") negEncoder = encoder;
}
if (posEncoder) candidates.push({ posEncoder, negEncoder });
}
// condition 2: exactly 1 qualifying KSampler
if (candidates.length !== 1) return;
const { posEncoder, negEncoder } = candidates[0];
// condition 3: text encoder inputs must be free (already checked by findTextEncoderUpstream)
const posTextInput = posEncoder.inputs?.find(i =>
i.type === "STRING" && (i.name === "text" || i.name === "prompt")
);
if (!posTextInput || posTextInput.link != null) return;
let negTextInput = null;
if (negEncoder) {
negTextInput = negEncoder.inputs?.find(i =>
i.type === "STRING" && (i.name === "text" || i.name === "prompt")
);
if (!negTextInput || negTextInput.link != null) return;
}
// read existing widget text before connecting (connection converts widget to input)
const posText = posEncoder.widgets?.find(w => w.name === posTextInput.name)?.value || "";
let negText = "";
if (negEncoder && negTextInput) {
negText = negEncoder.widgets?.find(w => w.name === negTextInput.name)?.value || "";
}
// all conditions met — expand slots and connect
_mutating.add(node);
try {
const flux = isFluxWorkflow();
ensureSlotCount(node, (negEncoder && !flux) ? 3 : 2);
const posTextIdx = posEncoder.inputs.indexOf(posTextInput);
node.connect(1, posEncoder, posTextIdx);
if (negEncoder && negTextInput && !flux) {
const negTextIdx = negEncoder.inputs.indexOf(negTextInput);
node.connect(2, negEncoder, negTextIdx);
}
forceOutputRefresh(node);
} finally {
_mutating.delete(node);
}
// return captured text so caller can seed the editor
if (posText || negText) {
return { posText: posText.trim(), negText: negText.trim() };
}
return null;
}
/**
* One-time slot setup on node creation / workflow load.
* Removes pos/neg if unconnected; preserves them if workflow had connections.
*/
export function initializeOutputSlots(node, { skipRefresh = false } = {}) {
const autoSplit = app.ui?.settings?.getSettingValue?.("PromptChain.AutoSplitOutputs") ?? true;
if (!autoSplit) return; // all 3 visible from Python
// Preserve ANY connection past `out` — collapsing with a linked regions
// output slid it from slot 3 to slot 1, and the backend (fixed indices)
// then fed the positive string into the regions consumer.
if (linkedAtOrAfter(node, 1)) return;
_mutating.add(node);
try {
while ((node.outputs?.length || 0) > 1) node.removeOutput(node.outputs.length - 1);
// skip during graph load — forceOutputRefresh fires a rAF that would
// mutate the graph after ComfyUI's change-tracker snapshot, causing
// false "unsaved changes" detection
if (!skipRefresh) forceOutputRefresh(node);
} finally {
_mutating.delete(node);
}
}
// Repair slot-drifted regions links at load. The collapse bug above shipped:
// saves exist where a link into a consumer input NAMED "regions" originates
// from the positive/out output (origin_slot persisted after the slide). The
// target input name states the wiring intent unambiguously — remap the link
// to the actual regions output. Outputs are rebuilt to canonical order first
// when drifted, so the link lands on the regions BACKEND index.
const CANONICAL_OUTPUTS = ["out", "positive", "negative", "regions"];
export function healRegionsLinks(node) {
const graph = app.graph;
if (!graph || !node.outputs?.length) return;
const drifted = [];
node.outputs.forEach((o, i) => {
if (o.name === "regions") return;
for (const lid of [...(o.links || [])]) {
const link = getLink(graph, lid);
const target = link && graph.getNodeById(link.target_id);
const input = target?.inputs?.[link?.target_slot];
if (input?.name === "regions") drifted.push({ fromSlot: i, target, toSlot: link.target_slot });
}
});
const regionsAt = node.outputs.findIndex(o => o.name === "regions");
const canonical = node.outputs.every((o, i) => o.name === CANONICAL_OUTPUTS[i]);
if (!drifted.length && (canonical || regionsAt === -1 || !node.outputs[regionsAt].links?.length)) return;
_mutating.add(node);
try {
// capture every output's targets BY NAME, rebuild canonical, reconnect
const byName = {};
for (const o of node.outputs) {
byName[o.name] = (o.links || []).map(lid => getLink(graph, lid)).filter(Boolean)
.map(l => ({ target: graph.getNodeById(l.target_id), toSlot: l.target_slot }))
.filter(t => t.target);
}
// drifted links belong to regions regardless of which output held them
for (const d of drifted) {
byName[node.outputs[d.fromSlot].name] =
(byName[node.outputs[d.fromSlot].name] || []).filter(t => !(t.target === d.target && t.toSlot === d.toSlot));
(byName.regions = byName.regions || []).push({ target: d.target, toSlot: d.toSlot });
}
for (let i = node.outputs.length - 1; i >= 0; i--) node.removeOutput(i);
for (const name of CANONICAL_OUTPUTS) node.addOutput(name, "STRING");
CANONICAL_OUTPUTS.forEach((name, idx) => {
for (const t of byName[name] || []) node.connect(idx, t.target, t.toSlot);
});
if (drifted.length) console.warn(`[PromptChain] healed ${drifted.length} drifted regions link(s) on node ${node.id}`);
} finally {
_mutating.delete(node);
}
}