One module, prompt_boxes.js, now decides which boxes of a live node take a
prompt and whether each is positive or negative. A prompt box is a
multiline STRING input by the node's ComfyUI definition, not a widget
whose name looks right; its role is its own name (positive / negative) or
the wiring down to a sampler's positive / negative input, not the title.
- Material drops, Prompt Studio, the translator and recipe capture use it;
their name regexes and fallbacks ("any string widget") are gone.
- Prompt Studio's role detection read graph.links[id], which is a Map in
current ComfyUI, so it never followed a link; it uses the module now.
- Material writes call onWidgetChanged(name, ...) as ComfyUI expects.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
6.3 KiB
JavaScript
145 lines
6.3 KiB
JavaScript
/**
|
|
* Prompt boxes on the live canvas: which text boxes of a node take a prompt, and whether
|
|
* that prompt is positive or negative. A prompt box is a multiline STRING input by the
|
|
* node's own definition (ComfyUI's object_info), whatever the box is called. Its role is
|
|
* the box's name when the node's author named it positive / negative; otherwise it comes
|
|
* from the wiring: the node's outputs are followed down to an input named positive or
|
|
* negative (a guider's single conditioning input counts as positive). No DOM here.
|
|
*/
|
|
|
|
const POSITIVE_NAME = /^(positive|positive_prompt|text_positive|正面|正向|正面提示词|正向提示词)$/i;
|
|
const NEGATIVE_NAME = /^(negative|negative_prompt|text_negative|负面|反向|负面提示词|反向提示词)$/i;
|
|
const MAX_HOPS = 16;
|
|
|
|
/** 'positive' | 'negative' | '' for a box (or serialized widget) name that says so itself. */
|
|
export function promptNameRole(name) {
|
|
const trimmed = String(name || '').trim();
|
|
if (POSITIVE_NAME.test(trimmed)) return 'positive';
|
|
if (NEGATIVE_NAME.test(trimmed)) return 'negative';
|
|
return '';
|
|
}
|
|
|
|
function definition(type) {
|
|
return globalThis.LiteGraph?.registered_node_types?.[type]?.nodeData || null;
|
|
}
|
|
|
|
function inputSpec(nodeData, name) {
|
|
const input = nodeData?.input || {};
|
|
return input.required?.[name] || input.optional?.[name] || null;
|
|
}
|
|
|
|
const isMultilineString = (spec) => Array.isArray(spec) && spec[0] === 'STRING' && Boolean(spec[1]?.multiline);
|
|
|
|
/** Whether nodes of this type have a prompt box (by their definition). */
|
|
export function typeTakesPrompt(type) {
|
|
const input = definition(type)?.input || {};
|
|
return [...Object.values(input.required || {}), ...Object.values(input.optional || {})].some(isMultilineString);
|
|
}
|
|
|
|
export function isPromptBox(node, widget) {
|
|
const nodeData = node?.constructor?.nodeData || definition(node?.type);
|
|
return typeof widget?.value === 'string' && isMultilineString(inputSpec(nodeData, widget.name));
|
|
}
|
|
|
|
function downstream(node, graph) {
|
|
const found = [];
|
|
for (const output of node.outputs || []) {
|
|
for (const linkId of output.links || []) {
|
|
const link = graph.getLink?.(linkId) ?? graph.links?.[linkId];
|
|
const target = link && graph.getNodeById(link.target_id);
|
|
if (target) found.push({ target, input: String(target.inputs?.[link.target_slot]?.name || '') });
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/**
|
|
* Where a node's output ends up: { role: 'positive' | 'negative' | 'both' | '', samplers }
|
|
* with the ids of the nodes whose positive / negative input it reaches.
|
|
*/
|
|
export function wiredRole(node, graph = node?.graph) {
|
|
const roles = new Set();
|
|
const samplers = new Set();
|
|
if (!node || !graph) return { role: '', samplers };
|
|
const seen = new Set([node.id]);
|
|
let frontier = [node];
|
|
for (let hop = 0; hop < MAX_HOPS && frontier.length; hop++) {
|
|
const next = [];
|
|
for (const current of frontier) {
|
|
for (const { target, input } of downstream(current, graph)) {
|
|
const guider = /guider/i.test(target.type) && /^(conditioning|cond\d*)$/i.test(input);
|
|
const role = /positive/i.test(input) || guider ? 'positive' : /negative/i.test(input) ? 'negative' : '';
|
|
if (role) {
|
|
roles.add(role);
|
|
samplers.add(target.id);
|
|
} else if (!seen.has(target.id)) {
|
|
seen.add(target.id);
|
|
next.push(target);
|
|
}
|
|
}
|
|
}
|
|
frontier = next;
|
|
}
|
|
return { role: roles.size === 2 ? 'both' : [...roles][0] || '', samplers };
|
|
}
|
|
|
|
/** A node's prompt boxes: [{ index, widget, name, role }], role '' when nothing tells. */
|
|
export function promptBoxes(node) {
|
|
const boxes = (node?.widgets || []).flatMap((widget, index) => (
|
|
isPromptBox(node, widget) ? [{ index, widget, name: widget.name, role: '' }] : []
|
|
));
|
|
let wired = null;
|
|
for (const box of boxes) {
|
|
box.role = promptNameRole(box.name) || (wired ||= wiredRole(node)).role;
|
|
}
|
|
return boxes;
|
|
}
|
|
|
|
/** The text of a prompt envelope ({ positive, negative, singleText, primaryRole }) for a box of `role`. */
|
|
export function textForRole(envelope, role) {
|
|
if (role === 'negative') return envelope.negative || '';
|
|
if (role === 'positive' || role === 'both') return envelope.positive || '';
|
|
return envelope.primaryRole === 'negative' ? envelope.negative : (envelope.positive || envelope.singleText || envelope.negative || '');
|
|
}
|
|
|
|
/**
|
|
* What filling `node` with an envelope writes: [{ index, value, role }]. With `box` (one of
|
|
* the node's boxes, chosen by the user) only that box, with the text of its role, or the
|
|
* envelope's main text when the envelope has none of that role. Without it: every box that
|
|
* has text of its own role; a node with one box of unknown role takes the main text.
|
|
*/
|
|
export function planPromptFill(node, envelope, box = null) {
|
|
if (box) {
|
|
const value = textForRole(envelope, box.role) || textForRole(envelope, '');
|
|
return value ? [{ index: box.index, value, role: box.role }] : [];
|
|
}
|
|
const boxes = promptBoxes(node);
|
|
const known = boxes.filter(item => item.role && textForRole(envelope, item.role));
|
|
if (known.length) return known.map(item => ({ index: item.index, value: textForRole(envelope, item.role), role: item.role }));
|
|
if (boxes.length === 1 && !boxes[0].role) {
|
|
const value = textForRole(envelope, '');
|
|
return value ? [{ index: boxes[0].index, value, role: '' }] : [];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* The one box on another node that takes the opposite role for the same sampler as
|
|
* `node`'s output, or null (none, or more than one).
|
|
*/
|
|
export function partnerBox(node, role, graph = node?.graph) {
|
|
if (!graph || (role !== 'positive' && role !== 'negative')) return null;
|
|
const { samplers } = wiredRole(node, graph);
|
|
if (!samplers.size) return null;
|
|
const wanted = role === 'positive' ? 'negative' : 'positive';
|
|
const found = [];
|
|
for (const other of graph._nodes || []) {
|
|
if (other === node) continue;
|
|
const boxes = promptBoxes(other).filter(box => box.role === wanted);
|
|
if (!boxes.length) continue;
|
|
const reach = wiredRole(other, graph).samplers;
|
|
if ([...reach].some(id => samplers.has(id))) found.push(...boxes.map(box => ({ node: other, box })));
|
|
}
|
|
return found.length === 1 ? found[0] : null;
|
|
}
|