Files

186 lines
8.8 KiB
JavaScript

// Shared region-name binding between a region-mask PRODUCER (the 3D Poser, the
// Region Box canvas) and a regional CONSUMER (Attention Couple / Regional
// Conditioning) wired to a PromptChain node.
//
// A producer emits MASKS (one row per region entity, in entity order) plus a
// POSE_JSON entity list; a consumer binds each `$name{}` block in the prompt to
// its mask row BY NAME. These helpers keep the bound prompt stocked with one
// `$name{}` block per producer region and travel renames into the prompt.
//
// Generalized fork of the Poser's own (AttentionCouple-only) helpers so the Flux
// RegionalConditioning path binds region names too, and so the seed budget is
// per-producer (counting only the caller's own names) — the Poser's inline copy
// is intentionally left untouched.
import { app } from "../../../scripts/app.js";
import { getLink } from "./slot-utils.js";
import { setEditorContent } from "./editor.js";
// PromptChain consumers that take a `regions` JSON and bind each $name{} block
// to a region. AttentionCouple patches SDXL cross-attention; RegionalConditioning
// attaches the mask inside the conditioning (Flux + tiled upscale); IdeogramCaption
// places each region at its box's caption coordinates (Ideogram has no mask, so it
// reads the box from POSE_JSON, not MASKS). A producer feeds any of these.
export const REGIONAL_CONSUMERS = new Set([
"PromptChain_AttentionCouple",
"PromptChain_RegionalConditioning",
"PromptChain_IdeogramCaption",
]);
// Producer outputs that can reach a consumer: MASKS for the mask consumers,
// POSE_JSON for the Ideogram caption (mask-less).
const PRODUCER_OUTPUTS = ["MASKS", "POSE_JSON"];
// $-block names are matched as \w+ by the compiler; drop any other char so a
// stray metachar in a label can't corrupt the seed/rename regexes. (Labels are
// already constrained to word-chars at the canvas, so this is a safety net.)
function safeName(name) {
return String(name || "").replace(/[^\w]/g, "");
}
// Trace a producer node's MASKS/POSE_JSON output -> a regional consumer -> its
// `regions` input -> the PromptChain node feeding it. Returns { consumer, pc }
// or {}.
export function traceRegionConsumer(node) {
const graph = app.graph;
if (!graph) return {};
for (const outName of PRODUCER_OUTPUTS) {
const out = node.outputs?.find((o) => o.name === outName);
if (!out?.links?.length) continue;
for (const linkId of out.links) {
const link = getLink(graph, linkId);
const consumer = link && graph.getNodeById(link.target_id);
if (!REGIONAL_CONSUMERS.has(consumer?.comfyClass)) continue;
const regIn = consumer.inputs?.find((i) => i.name === "regions");
const rlink = regIn?.link != null ? getLink(graph, regIn.link) : null;
const pc = rlink && graph.getNodeById(rlink.origin_id);
if (pc?.comfyClass === "PromptChain_PromptChain") return { consumer, pc };
}
}
return {};
}
// Seed a `$name { }` block into the bound prompt for every region name that has
// no block yet (add-only, case-insensitive to match the server's name binding).
// Per-producer by construction: it only ever seeds the names passed in, so a
// Poser sharing the same prompt can't make it over/under-seed. Inserts ABOVE the
// negative section so a new block never lands inside the negative prompt.
export function reconcileRegionBlocks(node, names) {
if (!names?.length) return;
const { pc } = traceRegionConsumer(node);
if (!pc?._pcrEditor) return;
const text = pc._pcrEditor.state.doc.toString();
const missing = names.filter(
(n) => safeName(n) && !new RegExp("\\$" + safeName(n) + "\\s*\\{", "i").test(text));
if (!missing.length) return;
const blocks = missing.map((name) => "$" + safeName(name) + " {\n\n}").join("\n\n");
const neg = text.match(/negative\s+prompt\s*:/i);
if (neg) {
const head = text.slice(0, neg.index);
setEditorContent(pc._pcrEditor,
(head.trim() ? head.replace(/\s*$/, "\n\n") : "") + blocks + "\n\n" + text.slice(neg.index));
} else {
setEditorContent(pc._pcrEditor, text + (text.trim() ? "\n\n" : "") + blocks);
}
}
// Read a `$name { body }` block's body to its MATCHING close brace (brace-depth
// tracked), skipping past any EMPTY same-name blocks to the first POPULATED one.
// Depth tracking keeps a body's own {a|b} wildcard/alternation braces intact, so
// what the user typed survives the copy — the server's compile grammar is itself
// non-greedy, but preserving the authored text is strictly safer than truncating
// it here. Returns "" when the name has no populated block.
function extractRegionBody(text, sn) {
const open = new RegExp("\\$" + sn + "\\s*\\{", "gi");
let m;
while ((m = open.exec(text)) !== null) {
let depth = 1, i = m.index + m[0].length;
const start = i;
for (; i < text.length && depth > 0; i++) {
if (text[i] === "{") depth++;
else if (text[i] === "}") depth--;
}
if (depth === 0) {
const body = text.slice(start, i - 1).trim();
if (body) return body;
}
}
return "";
}
// Read each named region's typed body out of the bound prompt, so a copied
// character's prompt can travel with it to another scene. Null-prototype map so a
// region literally named "__proto__" can't poison the result. Keyed by the name
// AS PASSED (the entity's custom name), not the escaped form.
export function readRegionBlockBodies(node, names) {
const out = Object.create(null);
if (!names?.length) return out;
const { pc } = traceRegionConsumer(node);
if (!pc?._pcrEditor) return out;
const text = pc._pcrEditor.state.doc.toString();
for (const name of names) {
const sn = safeName(name);
if (!sn) continue;
const body = extractRegionBody(text, sn);
if (body) out[name] = body;
}
return out;
}
// Seed `$name { body }` blocks carrying the typed tags of region entities pasted
// from another scene — like reconcileRegionBlocks but writes each block's BODY
// (not an empty stub) and is keyed per pasted entity, so a copied character's
// prompt travels with it. Add-only/case-insensitive: never clobbers a block the
// destination already holds under that name. Returns true once the bound editor
// exists (injection done, or nothing fresh to add) so the caller can stop
// retrying; false while the editor isn't built yet (caller keeps it pending).
export function seedRegionBlocks(node, entries) {
if (!entries?.length) return true;
const { pc } = traceRegionConsumer(node);
if (!pc) return true; // not region-wired → nothing to inject into; caller must stop retrying
if (!pc._pcrEditor) return false; // consumer wired but its editor isn't built yet → retry next capture
const text = pc._pcrEditor.state.doc.toString();
const fresh = entries.filter(
(e) => safeName(e.name) && !new RegExp("\\$" + safeName(e.name) + "\\s*\\{", "i").test(text));
if (!fresh.length) return true;
const blocks = fresh.map((e) => {
const body = (e.body || "").trim();
return "$" + safeName(e.name) + " {\n" + (body ? body + "\n" : "\n") + "}";
}).join("\n\n");
// Carried region blocks LEAD the prompt: prepend at the very top so a pasted
// character's $name{ tags } is the first thing in the prompt (above the quality
// tags). Top placement is trivially above the negative section, so the
// negative-marker guard reconcileRegionBlocks needs isn't required here.
const rest = text.replace(/^\s+/, "");
setEditorContent(pc._pcrEditor, blocks + (rest ? "\n\n" + rest : ""));
return true;
}
// Rewrite every `$oldName {` block to `$newName {` so the user's typed tags
// travel with a box rename. No-op when no such block exists (reconcile seeds the
// new name instead on the next change).
export function renameRegionBlock(node, oldName, newName) {
const oldN = safeName(oldName), newN = safeName(newName);
if (!oldN || !newN || oldN === newN) return;
const { pc } = traceRegionConsumer(node);
if (!pc?._pcrEditor) return;
const text = pc._pcrEditor.state.doc.toString();
const re = new RegExp("\\$" + oldN + "(\\s*\\{)", "gi");
if (!re.test(text)) return;
re.lastIndex = 0;
setEditorContent(pc._pcrEditor, text.replace(re, (m, brace) => "$" + newN + brace));
}
// Region->entity binding is by NAME on the server, which needs the entity list —
// wire POSE_JSON into the consumer's `pose` input if it isn't already. Connects
// ONLY when the slot is empty: never steals `pose` from a 3D Poser that already
// owns it (whoever owns `pose` defines the entity list for the whole prompt).
export function ensureConsumerPoseWire(node) {
const { consumer } = traceRegionConsumer(node);
if (!consumer) return;
const poseIn = consumer.inputs?.findIndex((i) => i.name === "pose");
if (poseIn == null || poseIn < 0 || consumer.inputs[poseIn].link != null) return;
const poseOut = node.outputs?.findIndex((o) => o.name === "POSE_JSON");
if (poseOut >= 0) node.connect(poseOut, consumer, poseIn);
}