Files
chrmc40 9fd6498ba6 Publish snapshot: v1->v2 character port + accumulated fixes
- Tag-builder: port ~1,477 v1 characters into the v2 chip format so the
  character picker shows 1,486 characters (was 9); 1,390 carry rich per-chip
  descriptions restructured from each character's own bio. Adds
  scripts/port_v1_characters_to_v2.py.
- Ideogram: rewrite caption to the real Ideogram-4 schema + broaden refusal
  detection; sampler updates.
- Pose-studio, regional-detailer, attention-couple: assorted fixes.
- Add validate_templates / test_regions / test_crop_windows scripts.
2026-06-28 20:49:04 -07:00

3163 lines
146 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Model Settings bridge — lazy-loads the Svelte model settings modal
// and manages its lifecycle. Contains graph scanning, node injection,
// template capture, and all constants that the Svelte side consumes.
import { app } from "../../../scripts/app.js";
import { getLink, getLinkInfo } from "./slot-utils.js";
import { showCatalogDownloadModal, showDownloadModal, registerModelModal, applyTemplate, MODEL_WIDGET_NAMES, showNodePackInstallModal } from "./model-bridge.js";
import { ensureSvelteCSS, createModuleLoader } from "./lazy-load.js";
// ── architecture + family definitions ──────────────────────────────
// Duplicated here because this file runs as a raw ES module (not bundled by Vite),
// while the Svelte components import from svelte-src/lib/model-constants.js.
export const ARCHITECTURES = [
{ id: "sdxl", label: "SDXL" }, { id: "sd15", label: "SD 1.5" },
{ id: "flux", label: "Flux" }, { id: "flux2", label: "Flux 2" },
{ id: "sd3", label: "SD3" }, { id: "zimage", label: "Z-Image" },
{ id: "qwen_image", label: "Qwen Image" }, { id: "qwen_edit", label: "Qwen Edit" },
{ id: "wan22", label: "Wan 2.2" }, { id: "ltx", label: "LTX Video" },
{ id: "hunyuan_video", label: "HunyuanVideo" }, { id: "hidream", label: "HiDream" },
{ id: "ernie", label: "ERNIE Image" },
{ id: "ideogram", label: "Ideogram" },
];
export const FAMILIES = {
sdxl: [{ id: "base_sdxl", label: "Base SDXL" }, { id: "pony", label: "Pony" }, { id: "illustrious", label: "Illustrious" }, { id: "noobai", label: "NoobAI" }],
sd15: [{ id: "base_sd15", label: "Base SD 1.5" }, { id: "nai", label: "NAI" }],
flux: [{ id: "flux_dev", label: "Flux Dev" }, { id: "flux_schnell", label: "Flux Schnell" }, { id: "flux_fill", label: "Flux Fill" }],
flux2: [{ id: "flux2", label: "Flux 2" }, { id: "flux2_gguf", label: "Flux 2 GGUF" }, { id: "flux2_klein", label: "Flux 2 Klein" }],
sd3: [{ id: "sd3", label: "SD3" }, { id: "sd3.5", label: "SD 3.5" }],
zimage: [{ id: "zimage_base", label: "Z-Image Base" }, { id: "zimage_turbo", label: "Z-Image Turbo" }],
qwen_image: [{ id: "qwen_image", label: "Qwen Image" }],
qwen_edit: [{ id: "qwen_edit", label: "Qwen Edit" }, { id: "qwen_aio", label: "Qwen AIO" }],
wan22: [{ id: "wan22_i2v", label: "Wan I2V 14B" }, { id: "wan22_i2v_gguf", label: "Wan I2V 14B (GGUF)" }, { id: "wan22_t2v", label: "Wan T2V 14B" }, { id: "wan22_t2v_gguf", label: "Wan T2V 14B (GGUF)" }, { id: "wan22_5b", label: "Wan TI2V 5B" }, { id: "wan22_5b_gguf", label: "Wan TI2V 5B (GGUF)" }],
ltx: [{ id: "ltx23", label: "LTX 2.3" }],
hunyuan_video: [{ id: "hunyuan_video_15", label: "HunyuanVideo 1.5" }],
hidream: [{ id: "hidream", label: "HiDream-I1" }],
ernie: [{ id: "ernie_base", label: "ERNIE Image" }, { id: "ernie_turbo", label: "ERNIE Image Turbo" }],
ideogram: [{ id: "ideogram4", label: "Ideogram 4" }],
};
// ── supported node types + widgets ─────────────────────────────────
export const SUPPORTED_NODES = {
KSampler: {
label: "KSampler",
collapsible: true,
widgets: {
steps: { type: "slider", min: 1, max: 60, step: 1 },
cfg: { type: "slider", min: 1, max: 15, step: 0.1 },
sampler_name: { type: "combo", pair: "scheduler" },
scheduler: { type: "combo", paired: true },
denoise: { type: "slider", min: 0, max: 1, step: 0.01 },
},
},
KSamplerAdvanced: {
label: "KSampler (Advanced)",
widgets: {
steps: { type: "slider", min: 1, max: 60, step: 1 },
cfg: { type: "slider", min: 1, max: 15, step: 0.1 },
sampler_name: { type: "combo", pair: "scheduler" },
scheduler: { type: "combo", paired: true },
start_at_step: { type: "slider", min: 0, max: 60, step: 1 },
end_at_step: { type: "slider", min: 0, max: 60, step: 1 },
},
},
PromptChain_RegionalDetailer: {
label: "FaceDetailer (Regional)",
collapsible: true,
widgets: {
cfg: { type: "slider", min: 1, max: 15, step: 0.1 },
denoise: { type: "slider", min: 0, max: 1, step: 0.01 },
face_source: { type: "combo" },
max_face_angle: { type: "slider", min: 0, max: 180, step: 5, collapsed: true },
steps: { type: "slider", min: 1, max: 60, step: 1, collapsed: true },
sampler_name: { type: "combo", collapsed: true, pair: "scheduler" },
scheduler: { type: "combo", collapsed: true, paired: true },
guide_size: { type: "slider", min: 64, max: 1024, step: 8, collapsed: true },
max_size: { type: "slider", min: 256, max: 2048, step: 64, collapsed: true },
crop_factor: { type: "slider", min: 1, max: 8, step: 0.1, collapsed: true },
feather: { type: "slider", min: 0, max: 128, step: 1, collapsed: true },
bbox_threshold: { type: "slider", min: 0, max: 1, step: 0.01, collapsed: true },
bbox_dilation: { type: "slider", min: 0, max: 128, step: 1, collapsed: true },
mask_dilation: { type: "slider", min: 0, max: 128, step: 1, collapsed: true },
drop_size: { type: "slider", min: 1, max: 512, step: 1, collapsed: true },
},
},
ApplyPulidAdvanced: {
label: "PuLID",
collapsible: true,
widgets: {
weight: { type: "slider", min: 0, max: 1, step: 0.05 },
fidelity: { type: "slider", min: 0, max: 20, step: 1, collapsed: true },
noise: { type: "slider", min: 0, max: 1, step: 0.01, collapsed: true },
start_at: { type: "slider", min: 0, max: 1, step: 0.01, collapsed: true },
end_at: { type: "slider", min: 0, max: 1, step: 0.01, collapsed: true },
projection: { type: "combo", collapsed: true },
},
},
IPAdapterAdvanced: {
label: "Style Reference",
collapsible: true,
widgets: {
weight: { type: "slider", min: 0, max: 2, step: 0.05 },
weight_type: { type: "combo", collapsed: true },
start_at: { type: "slider", min: 0, max: 1, step: 0.01, collapsed: true },
end_at: { type: "slider", min: 0, max: 1, step: 0.01, collapsed: true },
combine_embeds: { type: "combo", collapsed: true },
embeds_scaling: { type: "combo", collapsed: true },
},
},
ApplyPulidFlux: {
label: "PuLID (Flux)",
collapsible: true,
widgets: {
weight: { type: "slider", min: 0, max: 2, step: 0.05 },
start_at: { type: "slider", min: 0, max: 1, step: 0.01, collapsed: true },
end_at: { type: "slider", min: 0, max: 1, step: 0.01, collapsed: true },
},
},
// iFayens ComfyUI-PuLID-Flux2: strength is the only tuning knob (no
// start_at/end_at — the patch wraps the unet for the whole denoise).
ApplyPuLIDFlux2: {
label: "PuLID (Flux 2)",
collapsible: true,
widgets: {
strength: { type: "slider", min: 0, max: 2, step: 0.05 },
face_index: { type: "slider", min: 0, max: 9, step: 1, collapsed: true },
},
},
UltimateSDUpscale: {
label: "Upscaler",
collapsible: true,
widgets: {
upscale_by: { type: "slider", min: 1, max: 4, step: 0.01, ticks: "resolution" },
denoise: { type: "slider", min: 0, max: 1, step: 0.01 },
steps: { type: "slider", min: 1, max: 60, step: 1, collapsed: true },
cfg: { type: "slider", min: 1, max: 15, step: 0.1, collapsed: true },
sampler_name: { type: "combo", collapsed: true, pair: "scheduler" },
scheduler: { type: "combo", collapsed: true, paired: true },
tile_width: { type: "slider", min: 256, max: 2048, step: 64, collapsed: true },
tile_height: { type: "slider", min: 256, max: 2048, step: 64, collapsed: true },
mask_blur: { type: "slider", min: 0, max: 64, step: 1, collapsed: true },
tile_padding: { type: "slider", min: 0, max: 128, step: 1, collapsed: true },
mode_type: { type: "combo", collapsed: true },
seam_fix_mode: { type: "combo", collapsed: true },
seam_fix_denoise: { type: "slider", min: 0, max: 1, step: 0.01, collapsed: true },
},
},
};
export const CLIP_NODES = {
CLIPSetLastLayer: {
label: "Clip Skip",
widgets: {
stop_at_clip_layer: { type: "slider", min: -4, max: -1, step: 1 },
},
},
};
export const LATENT_NODES = {
EmptyLatentImage: {
label: "Latent Image",
widgets: {
width: { type: "resolution" },
height: { type: "resolution" },
},
},
EmptySD3LatentImage: {
label: "Latent Image",
widgets: {
width: { type: "resolution" },
height: { type: "resolution" },
},
},
EmptyFlux2LatentImage: {
label: "Latent Image",
widgets: {
width: { type: "resolution" },
height: { type: "resolution" },
},
},
};
export const FLUX_NODES = {
FluxGuidance: {
label: "Guidance",
widgets: {
guidance: { type: "slider", min: 0, max: 100, step: 0.1 },
},
},
KSamplerSelect: {
label: "Sampler",
widgets: {
sampler_name: { type: "combo" },
},
},
BasicScheduler: {
label: "Scheduler",
widgets: {
scheduler: { type: "combo" },
steps: { type: "slider", min: 1, max: 10000, step: 1 },
denoise: { type: "slider", min: 0, max: 1, step: 0.01 },
},
},
Flux2Scheduler: {
label: "Scheduler",
widgets: {
steps: { type: "slider", min: 1, max: 4096, step: 1 },
},
},
RandomNoise: {
label: "Noise",
widgets: {},
},
};
// Ideogram 4 tuning nodes. Its t2i graph samples through PromptChain_IdeogramSampler
// (a SamplerCustomAdvanced+VAEDecode drop-in), fed by these. Surfaced in the model
// Settings panel like FLUX_NODES so an Ideogram model has real controls instead of
// an empty pane. Resolution stays on the EmptyFlux2LatentImage row (LATENT_NODES);
// the scheduler's own width/height are intentionally not exposed here to avoid a
// second resolution row.
export const IDEOGRAM_NODES = {
Ideogram4Scheduler: {
label: "Scheduler",
widgets: {
steps: { type: "slider", min: 1, max: 200, step: 1 },
},
},
DualModelGuider: {
label: "Guidance",
widgets: {
cfg: { type: "slider", min: 0, max: 30, step: 0.1 },
},
},
CFGOverride: {
label: "CFG Override",
widgets: {
cfg: { type: "slider", min: 0, max: 30, step: 0.1 },
start_percent: { type: "slider", min: 0, max: 1, step: 0.01 },
end_percent: { type: "slider", min: 0, max: 1, step: 0.01 },
},
},
};
const FLUX_NODE_TYPES = new Set(Object.keys(FLUX_NODES));
const IDEOGRAM_NODE_TYPES = new Set(Object.keys(IDEOGRAM_NODES));
const SAMPLER_INCLUDES = ["KSampler"];
const LATENT_NODE_TYPES = new Set(Object.keys(LATENT_NODES));
const CLIP_NODE_TYPES = new Set(Object.keys(CLIP_NODES));
const DEFERRED_NODES = new Set(["PromptChain_RegionalDetailer", "ApplyPulidAdvanced", "ApplyPulidFlux", "ApplyPuLIDFlux2", "IPAdapterAdvanced", "UltimateSDUpscale"]);
// Node types each injectable needs to be registered before it can be built.
// If any are missing we offer to install the
// pack(s) — the node_install_api allowlist maps the injectable to its repos.
const INJECT_REQUIREMENTS = {
// Our RegionalDetailer node is always registered; the install gate fetches the
// ultralytics runtime + face_yolov8n.pt (server keys `present` off the model).
RegionalDetailer: { nodes: ["PromptChain_RegionalDetailer"] },
PuLID: { nodes: ["ApplyPulidAdvanced"] },
PuLIDFlux: { nodes: ["ApplyPulidFlux", "FluxForwardOverrider"] },
// Standalone patch — no forward-overrider companion (unlike the Flux.1 fork).
PuLIDFlux2: { nodes: ["ApplyPuLIDFlux2"] },
StyleReference: { nodes: ["IPAdapterUnifiedLoader", "IPAdapterAdvanced"] },
Upscaler: { nodes: ["UltimateSDUpscale"] },
// Native preprocessors (always registered) satisfy these; ControlNetLoader/
// ApplyAdvanced/SetUnionControlNetType are ComfyUI core (assumed present). The
// gate is server-driven (/nodepacks/status) and now fetches only models + pip.
ControlNet: { nodes: ["PromptChain_DepthAnything", "PromptChain_OpenPose"] },
FluxControlNet: { nodes: ["PromptChain_DepthAnything", "PromptChain_OpenPose"] },
// coreNodes can't be installed by a pack — they ship with ComfyUI. If absent
// the inject tells the user to update ComfyUI instead of offering an install.
ZImageControlNet: { nodes: ["PromptChain_DepthAnything", "PromptChain_OpenPose"], coreNodes: ["ZImageFunControlnet", "ModelPatchLoader"] },
ZImageControlNetBase: { nodes: ["PromptChain_DepthAnything", "PromptChain_OpenPose"], coreNodes: ["ZImageFunControlnet", "ModelPatchLoader"] },
};
// Per control type: which model to load (union promax / dedicated tile /
// dedicated MistoLine), the matching comfyui_controlnet_aux preprocessor node,
// the SetUnionControlNetType id (null = dedicated model, no union node), and
// default ControlNetApplyAdvanced strength/end. Verified against node source.
export const CONTROLNET_TYPES = {
depth: { model: "union", pre: "DepthAnythingV2Preprocessor", unionType: "depth", strength: 0.7, end: 0.8 },
canny: { model: "union", pre: "CannyEdgePreprocessor", unionType: "canny/lineart/anime_lineart/mlsd", strength: 0.9, end: 1.0 },
pose: { model: "union", pre: "DWPreprocessor", unionType: "openpose", strength: 1.0, end: 0.7 },
softedge: { model: "union", pre: "HEDPreprocessor", unionType: "hed/pidi/scribble/ted", strength: 0.6, end: 0.8 },
scribble: { model: "union", pre: "ScribblePreprocessor", unionType: "hed/pidi/scribble/ted", strength: 0.6, end: 0.8 },
lineart: { model: "mistoline", pre: "AnyLineArtPreprocessor_aux", unionType: null, strength: 0.8, end: 1.0 },
tile: { model: "tile", pre: "TilePreprocessor", unionType: null, strength: 0.6, end: 1.0 },
};
// Flux ControlNet (Shakker FLUX.1-dev-ControlNet-Union-Pro-2.0). Differs from
// SDXL: v2 dropped the mode embedding so there is NO SetUnionControlNetType
// (unionType always null), and the ControlNetApplyAdvanced node MUST receive a
// VAE (Flux ControlNets encode the hint into latent space). Per-mode
// strength/end are the model card's recommended values. gray = recolor mode,
// fed a luminance map. Modes verified against the v2 card (canny/softedge/
// depth/pose/gray; v2 dropped tile + lineart/scribble).
const FLUX_CONTROLNET_TYPES = {
// card recommends 0.8/0.8, but that grips the mannequin silhouette too hard
// and jaggies the edges; 0.6/0.6 renders cleaner (user-tuned vs Krea).
depth: { pre: "DepthAnythingV2Preprocessor", strength: 0.6, end: 0.6 },
canny: { pre: "CannyEdgePreprocessor", strength: 0.7, end: 0.8 },
pose: { pre: "DWPreprocessor", strength: 0.9, end: 0.65 },
softedge: { pre: "HEDPreprocessor", strength: 0.7, end: 0.8 },
gray: { pre: "ImageLuminanceDetector", strength: 0.9, end: 0.8 },
};
// Z-Image ControlNet (alibaba-pai Fun-Controlnet-Union-2.1). Uses the DiffSynth
// model-patch path: ZImageFunControlnet patches the MODEL (no conditioning
// re-emit), so the cluster splices onto the model line like PuLID, not the
// conditioning line. Same comfyui_controlnet_aux preprocessors as SDXL/Flux.
// Turbo runs ~8 steps / cfg 1.3 (set by the model preset, not here); per-mode
// strength stays in the 0.65–1.0 band.
const ZIMAGE_CONTROLNET_TYPES = {
depth: { pre: "DepthAnythingV2Preprocessor", strength: 0.8 },
canny: { pre: "CannyEdgePreprocessor", strength: 0.7 },
pose: { pre: "DWPreprocessor", strength: 0.9 },
softedge: { pre: "HEDPreprocessor", strength: 0.9 },
scribble: { pre: "ScribblePreprocessor", strength: 0.9 },
gray: { pre: "ImageLuminanceDetector", strength: 0.9 },
};
// PromptChain ships native, license-clean replacements for every preprocessor
// the [add] clusters use, so a fresh install never needs comfyui_controlnet_aux.
// resolvePre keeps a real controlnet_aux node first when present (coexistence —
// never overwrite/duplicate a pack the user already runs), falls back to our
// native, and finally leaves the original id so the inject's null-guard surfaces
// the missing node exactly as before.
const NATIVE_PRE = {
DepthAnythingV2Preprocessor: "PromptChain_DepthAnything",
CannyEdgePreprocessor: "PromptChain_Canny",
DWPreprocessor: "PromptChain_OpenPose",
HEDPreprocessor: "PromptChain_SoftEdge",
AnyLineArtPreprocessor_aux: "PromptChain_LineArt",
ScribblePreprocessor: "PromptChain_Scribble",
TilePreprocessor: "PromptChain_Tile",
ImageLuminanceDetector: "PromptChain_Luminance",
};
export function resolvePre(preId) {
const LG = window.LiteGraph;
if (LG?.registered_node_types?.[preId]) return preId;
const native = NATIVE_PRE[preId];
if (native && LG?.registered_node_types?.[native]) return native;
return preId;
}
// True when a preprocessor is available at all — the real controlnet_aux node OR
// our native fallback. For availability gates that used to check only the
// controlnet_aux id directly (so depth features light up on a native-only install).
export function hasPre(preId) {
const reg = window.LiteGraph?.registered_node_types;
return !!(reg?.[preId] || (NATIVE_PRE[preId] && reg?.[NATIVE_PRE[preId]]));
}
// Z-Image control models are family-specific (NOT cross-compatible): Turbo's
// carry "turbo"/"8steps" (step-distilled), base's don't. Match by family and
// pre-seed the installer's canonical filename for the fresh-download combo lag.
const ZIMAGE_CN_TURBO_FILE = "z-image-turbo-fun-union-2.1.safetensors";
const ZIMAGE_CN_BASE_FILE = "z-image-base-fun-union-2.1.safetensors";
function pickZImageModelPatch(loaderNode, isBase) {
const w = loaderNode.widgets?.find(x => x.name === "name");
const opts = w?.options?.values || [];
const isTurboFile = o => /turbo|8steps/i.test(o);
const isUnionFile = o => /z-?image.*union/i.test(o);
const hit = isBase
? opts.find(o => isUnionFile(o) && !isTurboFile(o))
: (opts.find(o => isUnionFile(o) && isTurboFile(o)) || opts.find(o => isTurboFile(o)));
return { hit, canonical: isBase ? ZIMAGE_CN_BASE_FILE : ZIMAGE_CN_TURBO_FILE };
}
// Filename patterns to resolve the right checkpoint in the ControlNetLoader
// combo — never hardcode a filename (machines name them differently).
const CN_MODEL_MATCH = {
union: [/union.*promax/i, /union/i],
tile: [/xinsir.*tile|tile.*sdxl/i, /tile/i],
mistoline: [/mistoline/i],
fluxunion: [/flux.*union.*pro.*2/i, /flux.*union/i, /union.*pro.*2/i],
};
// The exact filename the installer writes for each model key (basename of the
// NODE_PACKS dest). Used as a fallback when the combo doesn't yet list the
// just-downloaded file — ComfyUI's controlnet folder scan is cached, so a fresh
// download isn't in ControlNetLoader's options until a rescan. We pre-seed this
// name so the loader selects the right model instead of falling back to a
// wrong one (e.g. an SDXL net loaded into a Flux graph). Keep in sync with
// node_install_api.py NODE_PACKS dests.
const CN_CANONICAL_FILE = {
union: "xinsir-union-promax-sdxl.safetensors",
tile: "xinsir-tile-sdxl.safetensors",
mistoline: "mistoLine_rank256.safetensors",
fluxunion: "flux-union-pro2.safetensors",
};
const CN_LABELS = {
depth: "Depth", canny: "Canny", pose: "Pose", lineart: "Lineart",
softedge: "Soft Edge", scribble: "Scribble", tile: "Tile", gray: "Gray",
// Flux branches carry a "flux-" prefixed control type so their settings
// sections + recommended markers don't collide with the SDXL defaults.
"flux-depth": "Depth", "flux-canny": "Canny", "flux-pose": "Pose",
"flux-softedge": "Soft Edge", "flux-gray": "Gray",
"zimage-depth": "Depth", "zimage-canny": "Canny", "zimage-pose": "Pose",
"zimage-softedge": "Soft Edge", "zimage-scribble": "Scribble", "zimage-gray": "Gray",
};
// Slider section for a ControlNet branch's ControlNetApplyAdvanced node.
// Reused per branch via a per-control-type `type` key so the existing
// section/range/recommended/save machinery isolates each control.
const CONTROLNET_APPLY_CONFIG = {
label: "ControlNet",
collapsible: true,
widgets: {
strength: { type: "slider", min: 0, max: 2, step: 0.05 },
end_percent: { type: "slider", min: 0, max: 1, step: 0.01 },
start_percent: { type: "slider", min: 0, max: 1, step: 0.01, collapsed: true },
},
};
// Z-Image's ZImageFunControlnet exposes only a strength knob (it patches the
// model rather than applying timed conditioning, so there's no start/end).
const ZIMAGE_CN_CONFIG = {
label: "ControlNet",
collapsible: true,
widgets: {
strength: { type: "slider", min: 0, max: 2, step: 0.05 },
},
};
// ── graph scanning ─────────────────────────────────────────────────
function findDownstreamNodes(pcNode) {
const graph = pcNode.graph || app.graph; // honor off-screen graphs (upscale graft builds)
if (!graph) return [];
const found = [];
const visited = new Set();
const queue = [pcNode];
while (queue.length) {
const n = queue.shift();
if (visited.has(n.id)) continue;
visited.add(n.id);
for (const output of n.outputs || []) {
if (!output.links?.length) continue;
for (const lid of output.links) {
const link = getLink(graph, lid);
if (!link) continue;
const target = graph.getNodeById(link.target_id);
if (target) { found.push(target); queue.push(target); }
}
}
}
return found;
}
export function readWidgetValue(node, name) {
return node.widgets?.find(w => w.name === name)?.value;
}
export function readWidgetOptions(node, name) {
const opts = node.widgets?.find(w => w.name === name)?.options?.values || [];
// RegionalDetailer's "mannequin heads" face source reads the Poser's exported
// head boxes off the `pose` input; with nothing wired there it's a dead choice
// (the node just falls back to YOLO), so hide it from the dropdown.
if (name === "face_source") {
const poseWired = node.inputs?.some(i => i.name === "pose" && i.link != null);
if (!poseWired) return opts.filter(o => !/mannequin/i.test(o));
}
return opts;
}
export function writeWidgetValue(node, name, value) {
const w = node.widgets?.find(w => w.name === name);
if (!w) return;
w.value = value;
w.callback?.(value);
// A model-patch injectable (Style Reference / PuLID) has a twin on the
// RegionalDetailer's model line (the "face detail" leg), hidden from the panel
// as a duplicate section. Mirror the sampler-section edit onto the leg so the
// detailed face keeps the same style/identity strength as the base render.
if (MODEL_PATCH_LEG_TYPES.has(node.comfyClass) && !feedsRegionalDetailerModel(app.graph, node)) {
for (const other of app.graph?._nodes || []) {
if (other === node || other.comfyClass !== node.comfyClass) continue;
if (!feedsRegionalDetailerModel(app.graph, other)) continue;
const ow = other.widgets?.find(x => x.name === name);
if (ow) { ow.value = value; ow.callback?.(value); }
}
}
app.graph?.setDirtyCanvas?.(true, true);
}
function findUpstreamByType(startNode, targetTypes) {
const graph = startNode.graph || app.graph; // honor off-screen graphs (upscale graft builds)
if (!graph) return null;
const visited = new Set();
const queue = [startNode];
while (queue.length) {
const n = queue.shift();
if (visited.has(n.id)) continue;
visited.add(n.id);
for (const input of n.inputs || []) {
if (input.link == null) continue;
const link = getLink(graph, input.link);
if (!link) continue;
const source = graph.getNodeById(link.origin_id);
if (!source) continue;
if (targetTypes.has(source.comfyClass)) return source;
queue.push(source);
}
}
return null;
}
// A ModelSamplingFlux belongs to a Krea 2 RAW graph when its model line traces up
// to a krea2_raw UNET. Keeps the resolution→shift mirror scoped to RAW (Turbo bakes
// a fixed mu; Flux owns its own shift), so only RAW's shift node is slaved.
function isKrea2RawShift(shiftNode) {
const unet = findUpstreamByType(shiftNode, new Set(["UNETLoader"]));
if (!unet) return false;
const name = readWidgetValue(unet, "unet_name");
return typeof name === "string" && /krea2_raw/i.test(name);
}
// The face-detail leg of a model-patch injectable (PuLID / Style Reference) is a
// second Apply node feeding the RegionalDetailer's couple-free model line. It's
// an implementation detail, not its own tunable — the settings panel shows one
// section for the sampler-line node, so detection skips the leg. In a regional
// graph the whole patch cluster is forward-reachable (the couple's model output
// is downstream), so without this both nodes would render duplicate sections.
const MODEL_PATCH_LEG_TYPES = new Set(["IPAdapterAdvanced", "ApplyPulidAdvanced", "ApplyPulid", "ApplyPulidFlux"]);
function feedsRegionalDetailerModel(graph, node) {
for (const output of node.outputs || []) {
for (const lid of output.links || []) {
const link = getLink(graph, lid);
if (!link) continue;
const target = graph.getNodeById(link.target_id);
if (target?.comfyClass !== "PromptChain_RegionalDetailer") continue;
if (target.inputs?.[link.target_slot]?.name === "model") return true;
}
}
return false;
}
const LOADER_TYPES = new Set([
"CheckpointLoaderSimple", "CheckpointLoader",
"UNETLoader", "DiffusersLoader",
]);
// Swap the upstream loader's model widget to `filename` in place. Used
// for both manual version switching and post-download auto-apply — in
// the latter case the new file may not exist in the combo options yet
// (ComfyUI hasn't rescanned), but writeWidgetValue pushes the value
// through anyway and it matches once the server restarts.
function swapNodeCheckpointToFilename(pcNode, filename) {
if (!filename) return false;
const downstream = findDownstreamNodes(pcNode);
const ksNode = downstream.find(n =>
n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced"
);
if (!ksNode) return false;
const loaderNode = findUpstreamByType(ksNode, LOADER_TYPES);
if (!loaderNode) return false;
for (const wName of MODEL_WIDGET_NAMES) {
const w = loaderNode.widgets?.find(w => w.name === wName);
if (!w) continue;
// Pre-seed the combo option so the widget renders the new value
// now — without this, freshly-downloaded filenames aren't in the
// combo list until ComfyUI rescans on restart.
if (w.options?.values && !w.options.values.includes(filename)) {
w.options.values.push(filename);
}
writeWidgetValue(loaderNode, wName, filename);
return true;
}
return false;
}
export function detectSupportedNodes(pcNode) {
const downstream = findDownstreamNodes(pcNode);
const detected = [];
const deferred = [];
const seenIds = new Set();
let ksamplerNode = null;
let fluxSamplerNode = null;
let ideogramSamplerNode = null;
for (const node of downstream) {
if (seenIds.has(node.id)) continue;
const cls = node.comfyClass || "";
if (cls === "SamplerCustomAdvanced") {
seenIds.add(node.id);
fluxSamplerNode = node;
continue;
}
// Ideogram samples through PromptChain_IdeogramSampler (a SamplerCustomAdvanced
// drop-in). Reuse the Flux pivot machinery (latent/sampler/noise upstream walk)
// and additionally walk the Ideogram-only tuning nodes below.
if (cls === "PromptChain_IdeogramSampler") {
seenIds.add(node.id);
ideogramSamplerNode = node;
fluxSamplerNode = node;
continue;
}
// ControlNet branches: one deferred section per branch, keyed by control
// type (depth/canny/…) so ranges + recommended don't collide when stacked.
if (cls === "ControlNetApplyAdvanced") {
seenIds.add(node.id);
const ct = node.properties?.pcrControlType || "controlnet";
deferred.push({
node,
config: { ...CONTROLNET_APPLY_CONFIG, label: `ControlNet — ${CN_LABELS[ct] || ct}` },
type: `ControlNet:${ct}`,
});
continue;
}
if (SUPPORTED_NODES[cls]) {
seenIds.add(node.id);
// The detail-leg Apply node mirrors the sampler section; don't list it twice.
if (MODEL_PATCH_LEG_TYPES.has(cls) && feedsRegionalDetailerModel(app.graph, node)) continue;
const entry = { node, config: SUPPORTED_NODES[cls], type: cls };
if (DEFERRED_NODES.has(cls)) { deferred.push(entry); }
else { detected.push(entry); ksamplerNode = node; }
continue;
}
for (const pattern of SAMPLER_INCLUDES) {
// KSamplerSelect just PICKS a sampler (outputs SAMPLER); it's not a real
// sampler pivot — exclude it so it never gets adopted as the latent anchor.
if (cls.includes(pattern) && cls !== "KSamplerSelect") {
seenIds.add(node.id);
detected.push({ node, config: SUPPORTED_NODES.KSampler, type: cls });
ksamplerNode = node;
break;
}
}
}
if (fluxSamplerNode) {
for (const cls of FLUX_NODE_TYPES) {
const fluxNode = findUpstreamByType(fluxSamplerNode, new Set([cls]));
if (fluxNode && !seenIds.has(fluxNode.id)) {
seenIds.add(fluxNode.id);
detected.push({ node: fluxNode, config: FLUX_NODES[cls], type: cls });
}
}
}
// Ideogram-only tuning nodes upstream of the Ideogram sampler (steps / guidance /
// cfg-override). Resolution + sampler + noise are already picked up via the Flux
// walk above and the latent walk below.
if (ideogramSamplerNode) {
for (const cls of IDEOGRAM_NODE_TYPES) {
const n = findUpstreamByType(ideogramSamplerNode, new Set([cls]));
if (n && !seenIds.has(n.id)) {
seenIds.add(n.id);
detected.push({ node: n, config: IDEOGRAM_NODES[cls], type: cls });
}
}
}
const pivotNode = ksamplerNode || fluxSamplerNode;
if (pivotNode) {
if (!fluxSamplerNode) {
const clipNode = findUpstreamByType(pivotNode, CLIP_NODE_TYPES);
if (clipNode && !seenIds.has(clipNode.id)) {
const cls = clipNode.comfyClass;
seenIds.add(clipNode.id);
detected.push({ node: clipNode, config: CLIP_NODES[cls], type: cls });
}
}
const latentNode = findUpstreamByType(pivotNode, LATENT_NODE_TYPES);
if (latentNode && !seenIds.has(latentNode.id)) {
const cls = latentNode.comfyClass;
seenIds.add(latentNode.id);
detected.push({ node: latentNode, config: LATENT_NODES[cls], type: cls });
}
// Krea 2 RAW's ModelSamplingFlux sets the resolution-dynamic flow-shift (mu),
// whose width/height MUST track the render size. Detected only so the resolution
// row can slave its width/height to the latent — mirrorOnly: no visible section
// and never written into editors, so it is never baked back into the saved
// config (the shift must stay dynamic). Gated to RAW (see isKrea2RawShift).
const shiftNode = findUpstreamByType(pivotNode, new Set(["ModelSamplingFlux"]));
if (shiftNode && !seenIds.has(shiftNode.id) && isKrea2RawShift(shiftNode)) {
seenIds.add(shiftNode.id);
detected.push({ node: shiftNode, config: { label: "Shift", widgets: {}, mirrorOnly: true }, type: "ModelSamplingFlux" });
}
const pulidTypes = new Set(["ApplyPulidAdvanced", "ApplyPulid", "ApplyPulidFlux", "ApplyPuLIDFlux2"]);
const pulidNode = findUpstreamByType(pivotNode, pulidTypes);
if (pulidNode && !seenIds.has(pulidNode.id)) {
const cls = pulidNode.comfyClass;
if (SUPPORTED_NODES[cls]) {
seenIds.add(pulidNode.id);
deferred.push({ node: pulidNode, config: SUPPORTED_NODES[cls], type: cls });
}
}
const ipAdapterNode = findUpstreamByType(pivotNode, new Set(["IPAdapterAdvanced"]));
if (ipAdapterNode && !seenIds.has(ipAdapterNode.id)) {
seenIds.add(ipAdapterNode.id);
deferred.push({ node: ipAdapterNode, config: SUPPORTED_NODES.IPAdapterAdvanced, type: "IPAdapterAdvanced" });
}
// Z-Image ControlNet patches the model, so its nodes sit on the model line
// upstream of the sampler (not downstream of the PromptChain node). Walk the
// model-input chain, one settings section per stacked branch.
const zGraph = app.graph;
let mNode = pivotNode;
while (mNode) {
const mIn = mNode.inputs?.find(i => i.name === "model");
if (mIn?.link == null) break;
const mLink = getLink(zGraph, mIn.link);
const src = mLink ? zGraph.getNodeById(mLink.origin_id) : null;
if (src?.comfyClass === "ZImageFunControlnet" && !seenIds.has(src.id)) {
seenIds.add(src.id);
const ct = src.properties?.pcrControlType || "controlnet";
deferred.push({
node: src,
config: { ...ZIMAGE_CN_CONFIG, label: `ControlNet — ${CN_LABELS[ct] || ct}` },
type: `ControlNet:${ct}`,
});
mNode = src;
} else {
break;
}
}
}
detected.push(...deferred);
return detected;
}
// ── node injection ────────────────────────────────────────────────
// Override an inject's internal-sampler defaults to match the architecture's
// native sampler — the shared SDXL-oriented defaults (cfg 5, dpmpp_2m_sde/
// karras) burn or artifact on distilled archs. Values mirror PromptChain's own
// t2i templates: Flux = euler/simple at cfg 1 (guidance-distilled, no real
// negative); Z-Image Turbo = res_multistep/beta at cfg 1.3 / ~8 steps. Mutates
// DEFAULTS in place; SDXL/SD1.5 keep their curated values.
function applyArchSamplerDefaults(defaults, architecture, family) {
const arch = architecture || "";
if (arch === "flux" || arch === "flux2") {
defaults.cfg = 1.0;
defaults.sampler_name = "euler";
defaults.scheduler = "simple";
} else if (arch === "zimage") {
// Sampler is FAMILY-AWARE: res_multistep is Turbo's deterministic few-step sampler;
// on non-distilled BASE it sterilizes skin (no micro-texture). Base wants a
// stochastic sampler (dpmpp_sde) for realistic skin; Turbo keeps res_multistep.
defaults.scheduler = "beta";
if (family === "zimage_base") {
defaults.sampler_name = "dpmpp_sde";
defaults.cfg = 3.0;
// Non-distilled: a low-denoise tile pass resolves the truncated tail cleanly.
if ("denoise" in defaults) defaults.denoise = 0.15;
} else {
defaults.sampler_name = "res_multistep";
defaults.cfg = 1.3;
if ("steps" in defaults) defaults.steps = 8;
// Distilled: at low denoise ComfyUI runs only the schedule's low-σ tail, which
// the few-step field never resolves -> blotchy skin. 0.7 re-enters its trained
// high-σ regime where tiles come out clean (heavier repaint is the trade).
if ("denoise" in defaults) defaults.denoise = 0.7;
}
} else if (arch === "krea2") {
// Krea 2 Turbo: distilled 8-step, guidance baked (cfg 1), euler/simple. Qwen-family
// is quant/prompt-sensitive, so a tile re-render wants a higher denoise floor than
// Z-Image-Base to avoid underbaking the few-step tail (GPU-tunable).
defaults.cfg = 1.0;
defaults.sampler_name = "euler";
defaults.scheduler = "simple";
if ("steps" in defaults) defaults.steps = 8;
if ("denoise" in defaults) defaults.denoise = 0.45;
}
}
const PULID_APPLY_TYPES = new Set(["ApplyPulidAdvanced", "ApplyPulid"]);
function tracePulidApplySources(graph, applyNode) {
const sources = {};
for (const name of ["pulid", "eva_clip", "face_analysis", "image"]) {
const input = applyNode.inputs?.find(i => i.name === name);
if (!input?.link) return null;
const link = getLink(graph, input.link);
const node = link && graph.getNodeById(link.origin_id);
if (!node) return null;
sources[name] = { node, slot: link.origin_slot };
}
return sources;
}
// The RegionalDetailer re-denoises exactly the faces PuLID anchors, so it needs
// its own identity patch — but the sampler-line ApplyPulid sits AFTER the
// Attention Couple, whose region-mask patch is wrong for single-face crops
// (the reason the detailer takes the couple-free base model). So: a second
// ApplyPulidAdvanced spliced onto the detailer's own model line, sharing the
// sampler cluster's loaders + face reference image.
function splicePulidOnDetailerModelLine(graph, LG, detailNode, sources, widgetValues) {
const modelIdx = detailNode.inputs?.findIndex(i => i.name === "model");
if (modelIdx == null || modelIdx < 0) return false;
const modelInput = detailNode.inputs[modelIdx];
if (!modelInput?.link) return false;
const modelLink = getLink(graph, modelInput.link);
if (!modelLink) return false;
const modelSource = graph.getNodeById(modelLink.origin_id);
if (!modelSource) return false;
if (PULID_APPLY_TYPES.has(modelSource.comfyClass)) return false;
const originSlot = modelLink.origin_slot;
const applyPulid = LG.createNode("ApplyPulidAdvanced");
if (!applyPulid) return false;
applyPulid.pos = [detailNode.pos[0] - 330, detailNode.pos[1]];
graph.add(applyPulid);
applyPulid.title = "Apply PuLID (face detail)";
for (const w of applyPulid.widgets || []) {
if (w.name in widgetValues && widgetValues[w.name] != null) {
w.value = widgetValues[w.name];
w.callback?.(widgetValues[w.name]);
}
}
detailNode.disconnectInput(modelIdx);
modelSource.connect(originSlot, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "model") ?? 0);
sources.pulid.node.connect(sources.pulid.slot, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "pulid") ?? 1);
sources.eva_clip.node.connect(sources.eva_clip.slot, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "eva_clip") ?? 2);
sources.face_analysis.node.connect(sources.face_analysis.slot, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "face_analysis") ?? 3);
sources.image.node.connect(sources.image.slot, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "image") ?? 4);
applyPulid.connect(0, detailNode, modelIdx);
return true;
}
function injectPuLID(pcNode) {
const graph = app.graph;
if (!graph) return false;
const LG = window.LiteGraph || graph.constructor?.LiteGraph;
if (!LG) return false;
const downstream = findDownstreamNodes(pcNode);
const fdNode = downstream.find(n => n.comfyClass === "FaceDetailer");
const ksNode = downstream.find(n =>
n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced"
);
const targetNode = fdNode || ksNode;
if (!targetNode) return false;
const modelInput = targetNode.inputs?.find(i => i.name === "model");
if (!modelInput?.link) return false;
const modelLink = getLink(graph, modelInput.link);
if (!modelLink) return false;
const sourceNode = graph.getNodeById(modelLink.origin_id);
if (!sourceNode) return false;
if (sourceNode.comfyClass === "ApplyPulidAdvanced" || sourceNode.comfyClass === "ApplyPulid") return false;
const pulidLoader = LG.createNode("PulidModelLoader");
const insightFace = LG.createNode("PulidInsightFaceLoader");
const evaClip = LG.createNode("PulidEvaClipLoader");
const loadImage = LG.createNode("LoadImage");
const applyPulid = LG.createNode("ApplyPulidAdvanced");
if (!pulidLoader || !insightFace || !evaClip || !loadImage || !applyPulid) return false;
const baseX = pcNode.pos[0];
const baseY = pcNode.pos[1];
loadImage.pos = [baseX + 840, baseY + 665];
pulidLoader.pos = [baseX + 1178, baseY + 671];
insightFace.pos = [baseX + 1178, baseY + 801];
evaClip.pos = [baseX + 1178, baseY + 931];
applyPulid.pos = [baseX + 1508, baseY + 666];
graph.add(pulidLoader);
graph.add(insightFace);
graph.add(evaClip);
graph.add(loadImage);
graph.add(applyPulid);
loadImage.title = "Face Reference Image";
for (const w of insightFace.widgets || []) {
if (w.name === "provider") { w.value = "CUDA"; w.callback?.("CUDA"); break; }
}
// Prefer the SDXL PuLID weights (what the installer fetches and what these
// SDXL graphs need) rather than leaning on the combo's default ordering —
// a stray Flux weight would otherwise win and produce a dead node.
for (const w of pulidLoader.widgets || []) {
if (w.name === "pulid_file" || w.name === "model_name") {
const opts = w.options?.values || [];
const val =
opts.find(o => /sdxl/i.test(o)) ||
opts.find(o => /pulid/i.test(o)) ||
opts[0];
if (val) { w.value = val; w.callback?.(val); }
break;
}
}
const pulidDefaults = { weight: 0.45, projection: "ortho_v2", fidelity: 4, noise: 0, start_at: 0, end_at: 1 };
for (const w of applyPulid.widgets || []) {
if (w.name in pulidDefaults) { w.value = pulidDefaults[w.name]; w.callback?.(pulidDefaults[w.name]); }
}
const modelInputIdx = targetNode.inputs?.findIndex(i => i.name === "model") ?? 0;
targetNode.disconnectInput(modelInputIdx);
const sourceModelSlot = sourceNode.outputs?.findIndex(o => o.type === "MODEL") ?? 0;
const apModelIdx = applyPulid.inputs?.findIndex(i => i.name === "model") ?? 0;
sourceNode.connect(sourceModelSlot, applyPulid, apModelIdx);
pulidLoader.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "pulid") ?? 1);
evaClip.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "eva_clip") ?? 2);
insightFace.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "face_analysis") ?? 3);
loadImage.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "image") ?? 4);
applyPulid.connect(0, targetNode, modelInputIdx);
// Regional: the RegionalDetailer runs on the couple-free base model, so the
// sampler-line splice never reaches it — it needs its OWN patch or the face
// re-pass paints the PuLID identity right back out. Non-regional: it can share
// the sampler's patched model — branch this node's output, no second node.
const detailNode = downstream.find(n => n.comfyClass === "PromptChain_RegionalDetailer");
if (detailNode) {
const hasCouple = downstream.some(n => n.comfyClass === "PromptChain_AttentionCouple");
if (hasCouple) {
splicePulidOnDetailerModelLine(graph, LG, detailNode, {
pulid: { node: pulidLoader, slot: 0 },
eva_clip: { node: evaClip, slot: 0 },
face_analysis: { node: insightFace, slot: 0 },
image: { node: loadImage, slot: 0 },
}, pulidDefaults);
} else {
const mIdx = detailNode.inputs?.findIndex(i => i.name === "model");
if (mIdx != null && mIdx >= 0) { detailNode.disconnectInput(mIdx); applyPulid.connect(0, detailNode, mIdx); }
}
}
graph.setDirtyCanvas(true, true);
return true;
}
// ── Krea 2 style LoRA (always-present "Style" panel section) ────────
// One swappable model-only LoRA on the sampler's model line, paired with a
// trigger word in the prompt. Selecting a style swaps both the LoRA file and
// the tracked trigger fragment; null removes the node and the trigger. The
// panel downloads the file first, so it's assumed on disk here (the combo-option
// push covers a lora list ComfyUI hasn't rescanned yet).
function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function setStyleWidget(node, name, value, isCombo) {
const w = node.widgets?.find(x => x.name === name);
if (!w) return;
if (isCombo && w.options?.values && !w.options.values.includes(value)) w.options.values.push(value);
w.value = value;
w.callback?.(value);
}
function spliceStyleLora(graph, loraNode, ksNode) {
const modelIdx = ksNode.inputs?.findIndex(i => i.name === "model");
if (modelIdx == null || modelIdx < 0 || ksNode.inputs[modelIdx].link == null) return;
const link = getLink(graph, ksNode.inputs[modelIdx].link);
if (!link) return;
const source = graph.getNodeById(link.origin_id);
if (!source) return;
const sourceSlot = link.origin_slot ?? (source.outputs?.findIndex(o => o.type === "MODEL") ?? 0);
const loraIn = loraNode.inputs?.findIndex(i => i.name === "model") ?? 0;
ksNode.disconnectInput(modelIdx);
source.connect(sourceSlot, loraNode, loraIn);
loraNode.connect(0, ksNode, modelIdx);
}
function removeStyleLora(graph, loraNode, ksNode) {
// Reconnect the LoRA's upstream model source straight to the sampler so the
// model line survives the node's removal.
const loraIn = loraNode.inputs?.findIndex(i => i.name === "model") ?? 0;
const inLink = loraNode.inputs?.[loraIn]?.link;
const modelIdx = ksNode.inputs?.findIndex(i => i.name === "model");
if (inLink != null && modelIdx != null && modelIdx >= 0) {
const l = getLink(graph, inLink);
const source = l && graph.getNodeById(l.origin_id);
if (source) {
ksNode.disconnectInput(modelIdx);
source.connect(l.origin_slot ?? 0, ksNode, modelIdx);
}
}
graph.remove(loraNode);
}
// Keep a single tracked trigger (pcNode.properties.pcrStyleTrigger) in the
// positive half of the prompt. Swapping replaces the old fragment with the new;
// only the tracked substring is touched, never other user text.
function swapStyleTrigger(pcNode, newTrigger) {
const prev = pcNode.properties?.pcrStyleTrigger || "";
if (prev === (newTrigger || "")) return;
const view = pcNode._pcrEditor;
const promptWidget = pcNode.widgets?.find(w => w.name === "prompt");
const doc = view ? view.state.doc.toString() : String(promptWidget?.value || "");
// Never insert past the "Negative Prompt:" marker — triggers are positive.
const NEG_RE = /(^|\n)[ \t]*negative\s+prompt\s*:/i;
const m = doc.match(NEG_RE);
const splitAt = m ? m.index : doc.length;
let pos = doc.slice(0, splitAt);
const tail = doc.slice(splitAt);
const strip = (text, trig) => trig
? text.replace(new RegExp("(,\\s*)?" + escapeRegExp(trig) + "(\\s*,)?", "i"),
(_, a, b) => (a && b) ? ", " : "")
: text;
pos = strip(pos, prev);
if (newTrigger) pos = strip(pos, newTrigger); // guard against a pre-existing copy
pos = pos.replace(/,\s*,/g, ",").replace(/\s+$/, "");
if (newTrigger) pos = pos ? pos + ", " + newTrigger : newTrigger;
const next = tail ? (pos ? pos + tail : tail.replace(/^\n/, "")) : pos;
if (view) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: next } });
} else if (promptWidget) {
promptWidget.value = next;
promptWidget.callback?.(next);
}
pcNode.properties.pcrStyleTrigger = newTrigger || "";
}
// Apply / swap / clear the Krea 2 style LoRA. `lora` is a catalog entry
// {id, filename, trigger, ...} or null to remove. Returns true on success.
export function applyStyleLora(pcNode, lora, strength) {
const graph = pcNode.graph || app.graph;
if (!graph) return false;
const LG = window.LiteGraph || graph.constructor?.LiteGraph;
if (!LG) return false;
const ksNode = findDownstreamNodes(pcNode).find(
n => n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced"
);
if (!ksNode) return false;
pcNode.properties = pcNode.properties || {};
const existing = (graph._nodes || []).find(
n => n.comfyClass === "LoraLoaderModelOnly" && n.properties?.pcrStyleLora
);
if (!lora) {
if (existing) removeStyleLora(graph, existing, ksNode);
swapStyleTrigger(pcNode, null);
pcNode.properties.pcrStyleLora = "";
graph.setDirtyCanvas(true, true);
return true;
}
let loraNode = existing;
if (!loraNode) {
loraNode = LG.createNode("LoraLoaderModelOnly");
if (!loraNode) return false;
loraNode.properties = loraNode.properties || {};
loraNode.properties.pcrStyleLora = true;
loraNode.title = "Style LoRA";
loraNode.pos = [pcNode.pos[0] + 420, pcNode.pos[1] + 520];
graph.add(loraNode);
spliceStyleLora(graph, loraNode, ksNode);
}
setStyleWidget(loraNode, "lora_name", lora.filename, true);
setStyleWidget(loraNode, "strength_model", strength, false);
swapStyleTrigger(pcNode, lora.trigger || null);
pcNode.properties.pcrStyleLora = lora.id || lora.filename;
graph.setDirtyCanvas(true, true);
return true;
}
// IPAdapter style transfer ("Style Reference", cubiq's ComfyUI_IPAdapter_plus).
// The UnifiedLoader bundles the ipadapter weights + CLIP-Vision encoder into
// one IPADAPTER pipe; IPAdapterAdvanced with weight_type "style transfer"
// injects only the style-carrying attention layers of the reference image —
// look without composition/content. Same model-line splice as PuLID, and the
// same RegionalDetailer leg so the face re-pass keeps the style.
// weight/end_at 0.7: full weight over the whole denoise washes out contrast
// (style layers pushed the entire way); 0.7 + early stop lets the model finish
// clean. README's own advice is weight <= 0.8.
const STYLE_REF_DEFAULTS = { weight: 0.7, weight_type: "style transfer", combine_embeds: "concat", start_at: 0, end_at: 0.7, embeds_scaling: "V only" };
function spliceStyleOnDetailerModelLine(graph, LG, detailNode, sources, widgetValues) {
const modelIdx = detailNode.inputs?.findIndex(i => i.name === "model");
if (modelIdx == null || modelIdx < 0) return false;
const modelInput = detailNode.inputs[modelIdx];
if (!modelInput?.link) return false;
const modelLink = getLink(graph, modelInput.link);
if (!modelLink) return false;
const modelSource = graph.getNodeById(modelLink.origin_id);
if (!modelSource) return false;
if (modelSource.comfyClass === "IPAdapterAdvanced") return false;
const originSlot = modelLink.origin_slot;
const ipAdapter = LG.createNode("IPAdapterAdvanced");
if (!ipAdapter) return false;
ipAdapter.pos = [detailNode.pos[0] - 330, detailNode.pos[1] + 360];
graph.add(ipAdapter);
ipAdapter.title = "Style Reference (face detail)";
for (const w of ipAdapter.widgets || []) {
if (w.name in widgetValues && widgetValues[w.name] != null) {
w.value = widgetValues[w.name];
w.callback?.(widgetValues[w.name]);
}
}
detailNode.disconnectInput(modelIdx);
modelSource.connect(originSlot, ipAdapter, ipAdapter.inputs?.findIndex(i => i.name === "model") ?? 0);
sources.ipadapter.node.connect(sources.ipadapter.slot, ipAdapter, ipAdapter.inputs?.findIndex(i => i.name === "ipadapter") ?? 1);
sources.image.node.connect(sources.image.slot, ipAdapter, ipAdapter.inputs?.findIndex(i => i.name === "image") ?? 2);
ipAdapter.connect(0, detailNode, modelIdx);
return true;
}
function injectStyleReference(pcNode) {
const graph = app.graph;
if (!graph) return false;
const LG = window.LiteGraph || graph.constructor?.LiteGraph;
if (!LG) return false;
const downstream = findDownstreamNodes(pcNode);
const ksNode = downstream.find(n =>
n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced"
);
if (!ksNode) return false;
const modelInput = ksNode.inputs?.find(i => i.name === "model");
if (!modelInput?.link) return false;
const modelLink = getLink(graph, modelInput.link);
if (!modelLink) return false;
const sourceNode = graph.getNodeById(modelLink.origin_id);
if (!sourceNode) return false;
if (sourceNode.comfyClass === "IPAdapterAdvanced") return false;
const unifiedLoader = LG.createNode("IPAdapterUnifiedLoader");
const loadImage = LG.createNode("LoadImage");
const ipAdapter = LG.createNode("IPAdapterAdvanced");
if (!unifiedLoader || !loadImage || !ipAdapter) return false;
// Below the PuLID cluster's slots (baseY+665..931) so both fit side by side.
const baseX = pcNode.pos[0];
const baseY = pcNode.pos[1];
loadImage.pos = [baseX + 840, baseY + 1090];
unifiedLoader.pos = [baseX + 1178, baseY + 1096];
ipAdapter.pos = [baseX + 1508, baseY + 1090];
graph.add(unifiedLoader);
graph.add(loadImage);
graph.add(ipAdapter);
loadImage.title = "Style Reference Image";
// PLUS = the high-strength vit-h adapter the installer fetches.
for (const w of unifiedLoader.widgets || []) {
if (w.name === "preset") { w.value = "PLUS (high strength)"; w.callback?.(w.value); break; }
}
for (const w of ipAdapter.widgets || []) {
if (w.name in STYLE_REF_DEFAULTS) { w.value = STYLE_REF_DEFAULTS[w.name]; w.callback?.(STYLE_REF_DEFAULTS[w.name]); }
}
const modelInputIdx = ksNode.inputs?.findIndex(i => i.name === "model") ?? 0;
ksNode.disconnectInput(modelInputIdx);
// The UnifiedLoader inspects the model to pick matching weights, then passes
// it through — source → loader → IPAdapterAdvanced → sampler.
sourceNode.connect(modelLink.origin_slot, unifiedLoader, unifiedLoader.inputs?.findIndex(i => i.name === "model") ?? 0);
const loaderModelOut = unifiedLoader.outputs?.findIndex(o => o.type === "MODEL") ?? 0;
const loaderIpaOut = unifiedLoader.outputs?.findIndex(o => o.type === "IPADAPTER") ?? 1;
unifiedLoader.connect(loaderModelOut, ipAdapter, ipAdapter.inputs?.findIndex(i => i.name === "model") ?? 0);
unifiedLoader.connect(loaderIpaOut, ipAdapter, ipAdapter.inputs?.findIndex(i => i.name === "ipadapter") ?? 1);
loadImage.connect(0, ipAdapter, ipAdapter.inputs?.findIndex(i => i.name === "image") ?? 2);
ipAdapter.connect(0, ksNode, modelInputIdx);
const detailNode = downstream.find(n => n.comfyClass === "PromptChain_RegionalDetailer");
if (detailNode) {
// Regional: the detailer runs on the couple-free base model, so it needs its
// OWN IPAdapter on that line. Non-regional: the detailer can share the
// sampler's patched model — branch this node's output, no second node.
const hasCouple = downstream.some(n => n.comfyClass === "PromptChain_AttentionCouple");
if (hasCouple) {
spliceStyleOnDetailerModelLine(graph, LG, detailNode, {
ipadapter: { node: unifiedLoader, slot: loaderIpaOut },
image: { node: loadImage, slot: 0 },
}, STYLE_REF_DEFAULTS);
} else {
const mIdx = detailNode.inputs?.findIndex(i => i.name === "model");
if (mIdx != null && mIdx >= 0) { detailNode.disconnectInput(mIdx); ipAdapter.connect(0, detailNode, mIdx); }
}
}
graph.setDirtyCanvas(true, true);
return true;
}
// Flux PuLID (balazik nodes). Same model-line splice as injectPuLID but with the
// PulidFlux* loaders + ApplyPulidFlux, which patches the Flux MODEL between the
// diffusion-model loader and the sampler. Schema verified against the repo.
function injectPuLIDFlux(pcNode) {
const graph = app.graph;
if (!graph) return false;
const LG = window.LiteGraph || graph.constructor?.LiteGraph;
if (!LG) return false;
const downstream = findDownstreamNodes(pcNode);
const fdNode = downstream.find(n => n.comfyClass === "FaceDetailer");
const ksNode = downstream.find(n =>
n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced"
);
const targetNode = fdNode || ksNode;
if (!targetNode) return false;
const modelInput = targetNode.inputs?.find(i => i.name === "model");
if (!modelInput?.link) return false;
const modelLink = getLink(graph, modelInput.link);
if (!modelLink) return false;
const sourceNode = graph.getNodeById(modelLink.origin_id);
if (!sourceNode) return false;
if (sourceNode.comfyClass === "ApplyPulidFlux") return false;
const pulidLoader = LG.createNode("PulidFluxModelLoader");
const insightFace = LG.createNode("PulidFluxInsightFaceLoader");
const evaClip = LG.createNode("PulidFluxEvaClipLoader");
const loadImage = LG.createNode("LoadImage");
const applyPulid = LG.createNode("ApplyPulidFlux");
// The lldacing fork needs Patches_ll's FluxForwardOverrider on the model line
// (after ApplyPulidFlux) — it installs the **kwargs forward override that
// tolerates current ComfyUI's timestep_zero_index arg. Without it the sampler
// throws "forward_orig() got an unexpected keyword argument".
const fwdOverride = LG.createNode("FluxForwardOverrider");
if (!pulidLoader || !insightFace || !evaClip || !loadImage || !applyPulid || !fwdOverride) return false;
// Layout matches the hand-arranged flux-dev-controlnet-pulid workflow: cluster
// sits to the LEFT of the PromptChain node — face image far left, the three
// loaders + the forward overrider stacked in a middle column, ApplyPulidFlux
// nearest the node.
const baseX = pcNode.pos[0];
const baseY = pcNode.pos[1];
loadImage.pos = [baseX - 990, baseY + 8];
pulidLoader.pos = [baseX - 650, baseY + 8];
insightFace.pos = [baseX - 650, baseY + 138];
evaClip.pos = [baseX - 650, baseY + 278];
fwdOverride.pos = [baseX - 650, baseY + 388];
applyPulid.pos = [baseX - 320, baseY + 8];
graph.add(pulidLoader);
graph.add(insightFace);
graph.add(evaClip);
graph.add(loadImage);
graph.add(applyPulid);
graph.add(fwdOverride);
// Sizes set after add (litegraph size setter needs a registered node).
loadImage.size = [283, 340];
pulidLoader.size = [270, 82];
insightFace.size = [298, 82];
evaClip.size = [225, 48];
applyPulid.size = [270, 288];
fwdOverride.size = [225, 48];
loadImage.title = "Face Reference Image";
for (const w of insightFace.widgets || []) {
if (w.name === "provider") { w.value = "CUDA"; w.callback?.("CUDA"); break; }
}
// PulidFluxModelLoader combo reads models/pulid. Prefer a flux pulid file;
// else pre-seed the installer's filename (the freshly-downloaded weight may
// not be in the combo yet — same rescan lag as the ControlNet loader).
for (const w of pulidLoader.widgets || []) {
if (w.name === "pulid_file") {
const opts = w.options?.values || [];
let val = opts.find(o => /flux/i.test(o)) || opts.find(o => /pulid/i.test(o));
if (!val) {
val = "pulid_flux_v0.9.1.safetensors";
if (w.options?.values && !w.options.values.includes(val)) w.options.values.push(val);
}
if (val) { w.value = val; w.callback?.(val); }
break;
}
}
const pulidDefaults = { weight: 1.0, start_at: 0, end_at: 1 };
for (const w of applyPulid.widgets || []) {
if (w.name in pulidDefaults) { w.value = pulidDefaults[w.name]; w.callback?.(pulidDefaults[w.name]); }
}
const modelInputIdx = targetNode.inputs?.findIndex(i => i.name === "model") ?? 0;
targetNode.disconnectInput(modelInputIdx);
const sourceModelSlot = sourceNode.outputs?.findIndex(o => o.type === "MODEL") ?? 0;
sourceNode.connect(sourceModelSlot, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "model") ?? 0);
pulidLoader.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "pulid_flux") ?? 1);
evaClip.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "eva_clip") ?? 2);
insightFace.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "face_analysis") ?? 3);
loadImage.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "image") ?? 4);
// Model line: source → ApplyPulidFlux → FluxForwardOverrider → sampler.
applyPulid.connect(0, fwdOverride, fwdOverride.inputs?.findIndex(i => i.name === "model") ?? 0);
fwdOverride.connect(0, targetNode, modelInputIdx);
graph.setDirtyCanvas(true, true);
return true;
}
// Flux.2 PuLID (iFayens ComfyUI-PuLID-Flux2). Same model-line splice as
// injectPuLIDFlux, but the patch is standalone (no FluxForwardOverrider) and
// Flux.2 graphs sample via SamplerCustomAdvanced, so the model input to splice
// lives on the guider (CFGGuider on base templates, BasicGuider on distilled).
function injectPuLIDFlux2(pcNode) {
const graph = app.graph;
if (!graph) return false;
const LG = window.LiteGraph || graph.constructor?.LiteGraph;
if (!LG) return false;
const downstream = findDownstreamNodes(pcNode);
const targetNode = downstream.find(n =>
n.comfyClass === "CFGGuider" || n.comfyClass === "BasicGuider"
) || downstream.find(n =>
n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced"
);
if (!targetNode) return false;
const modelInput = targetNode.inputs?.find(i => i.name === "model");
if (!modelInput?.link) return false;
const modelLink = getLink(graph, modelInput.link);
if (!modelLink) return false;
const sourceNode = graph.getNodeById(modelLink.origin_id);
if (!sourceNode) return false;
if (sourceNode.comfyClass === "ApplyPuLIDFlux2") return false;
const pulidLoader = LG.createNode("PuLIDModelLoader");
const insightFace = LG.createNode("PuLIDInsightFaceLoader");
const evaClip = LG.createNode("PuLIDEVACLIPLoader");
const loadImage = LG.createNode("LoadImage");
const applyPulid = LG.createNode("ApplyPuLIDFlux2");
if (!pulidLoader || !insightFace || !evaClip || !loadImage || !applyPulid) return false;
const baseX = pcNode.pos[0];
const baseY = pcNode.pos[1];
loadImage.pos = [baseX - 990, baseY + 8];
pulidLoader.pos = [baseX - 650, baseY + 8];
insightFace.pos = [baseX - 650, baseY + 138];
evaClip.pos = [baseX - 650, baseY + 278];
applyPulid.pos = [baseX - 320, baseY + 8];
graph.add(pulidLoader);
graph.add(insightFace);
graph.add(evaClip);
graph.add(loadImage);
graph.add(applyPulid);
loadImage.size = [283, 340];
pulidLoader.size = [270, 82];
insightFace.size = [298, 82];
evaClip.size = [225, 48];
applyPulid.size = [270, 220];
loadImage.title = "Face Reference Image";
for (const w of insightFace.widgets || []) {
if (w.name === "provider") { w.value = "CUDA"; w.callback?.("CUDA"); break; }
}
// PuLIDModelLoader combo reads models/pulid. Only a flux2-trained file is
// valid — the loader hard-crashes on Flux.1 pulid_flux_v0.9.1 (no id_former
// keys), so never fall back to a generic /pulid/ match. Pre-seed the
// installer's filename when the fresh download isn't in the combo yet.
for (const w of pulidLoader.widgets || []) {
if (w.name === "pulid_file") {
const opts = w.options?.values || [];
let val = opts.find(o => /flux2/i.test(o));
if (!val) {
val = "pulid_flux2_klein_v2.safetensors";
if (w.options?.values && !w.options.values.includes(val)) w.options.values.push(val);
}
w.value = val;
w.callback?.(val);
break;
}
}
// The native weights are calibrated on the 4-step distilled model (author
// ships strength 1.3). On base (CFGGuider, full step counts) the per-block
// correction applies every step and burns — the repo's issue-#11 heuristic
// scales strength by the step ratio, ≈0.3 at 20-30 steps.
const isBase = targetNode.comfyClass === "CFGGuider";
const pulidDefaults = { strength: isBase ? 0.3 : 1.3 };
for (const w of applyPulid.widgets || []) {
if (w.name in pulidDefaults) { w.value = pulidDefaults[w.name]; w.callback?.(pulidDefaults[w.name]); }
}
const modelInputIdx = targetNode.inputs?.findIndex(i => i.name === "model") ?? 0;
targetNode.disconnectInput(modelInputIdx);
const sourceModelSlot = sourceNode.outputs?.findIndex(o => o.type === "MODEL") ?? 0;
sourceNode.connect(sourceModelSlot, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "model") ?? 0);
pulidLoader.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "pulid_model") ?? 1);
evaClip.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "eva_clip") ?? 2);
insightFace.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "face_analysis") ?? 3);
loadImage.connect(0, applyPulid, applyPulid.inputs?.findIndex(i => i.name === "image") ?? 4);
applyPulid.connect(0, targetNode, modelInputIdx);
graph.setDirtyCanvas(true, true);
return true;
}
// opts.regional === false skips the regional-conditioning branch even when an
// Attention Couple is present — the viewer's upscale modal uses it for the
// "prompt only" / "prompt + depth" modes on regional workflows.
export function injectUpscaler(pcNode, modelConfig, opts = {}) {
const graph = pcNode.graph || app.graph; // honor off-screen graphs (upscale graft builds)
if (!graph) return false;
const LG = window.LiteGraph || graph.constructor?.LiteGraph;
if (!LG) return false;
const downstream = findDownstreamNodes(pcNode);
// Upscale the most-finished image: after the face-detail pass when one
// exists (regional or classic), else straight off the decode.
const detailNode = downstream.find(n => n.comfyClass === "PromptChain_RegionalDetailer")
|| downstream.find(n => n.comfyClass === "FaceDetailer");
const vaeDecodeNode = downstream.find(n => n.comfyClass === "VAEDecode"
|| n.comfyClass === "VAEUtils_VAEDecodeTiled" || n.comfyClass === "VAEDecodeTiled");
const imageSource = detailNode || vaeDecodeNode;
if (!imageSource) {
console.warn(`[PromptChain][graft] FAIL: no decode/detail image source. downstream=[${downstream.map((n) => n.comfyClass).join(",")}]`);
return false;
}
// Sampler anchor: classic KSampler graphs, or the Flux 2 split where
// SamplerCustomAdvanced samples and a guider (CFGGuider on Klein base,
// BasicGuider on guidance-distilled Dev) carries model + conditioning.
const ksNode = downstream.find(n =>
n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced"
);
const customSampler = ksNode ? null
: downstream.find(n => n.comfyClass === "SamplerCustomAdvanced");
if (!ksNode && !customSampler) return false;
// Keep the origin SLOT, not just the node: in depth/regional graphs the
// sampler's positive AND negative both come from one ControlNetApplyAdvanced
// (outputs 0 and 1) — connecting from a hardcoded slot 0 fed the POSITIVE
// conditioning into the upscaler's negative.
const traceInput = (node, inputName) => {
const input = node.inputs?.find(i => i.name === inputName);
if (!input?.link) return null;
const link = getLink(graph, input.link);
const origin = link && graph.getNodeById(link.origin_id);
return origin ? { node: origin, slot: link.origin_slot } : null;
};
const samplerNode = ksNode || customSampler;
const guiderNode = ksNode ? null : traceInput(customSampler, "guider")?.node;
if (!ksNode && !guiderNode) return false;
const condAnchor = guiderNode || ksNode;
// USDU can't digest ReferenceLatent conditioning (ssitu#163) — when a
// reference chain feeds the guider, tap the raw text encode beneath it (and
// beneath any FluxGuidance wrapper; the model falls back to its default
// guidance embed). Reference-free conditioning passes through untouched.
const pastReferenceLatents = (src) => {
let probe = src, sawReference = false;
while (probe?.node && (probe.node.comfyClass === "ReferenceLatent" || probe.node.comfyClass === "FluxGuidance")) {
if (probe.node.comfyClass === "ReferenceLatent") sawReference = true;
probe = traceInput(probe.node, "conditioning");
}
return sawReference ? probe : src;
};
const isBasicGuider = guiderNode?.comfyClass === "BasicGuider";
const positiveSource = pastReferenceLatents(
traceInput(condAnchor, isBasicGuider ? "conditioning" : "positive")
);
// BasicGuider has no negative branch — a zeroed-out positive stands in
// when wiring below (inert at the distilled cfg 1.0 default anyway).
const negativeSource = isBasicGuider ? null
: pastReferenceLatents(traceInput(condAnchor, "negative"));
let ckptNode = null;
for (const n of downstream) {
if (n.comfyClass === "CheckpointLoaderSimple" || n.comfyClass === "CheckpointLoader") {
ckptNode = n; break;
}
}
if (!ckptNode) {
const modelSource = traceInput(condAnchor, "model")?.node;
if (modelSource) {
const visited = new Set();
const queue = [modelSource];
while (queue.length) {
const n = queue.shift();
if (visited.has(n.id)) continue;
visited.add(n.id);
if (n.comfyClass === "CheckpointLoaderSimple" || n.comfyClass === "CheckpointLoader") {
ckptNode = n; break;
}
for (const inp of n.inputs || []) {
if (inp.link == null) continue;
const lnk = getLink(graph, inp.link);
if (lnk) { const src = graph.getNodeById(lnk.origin_id); if (src) queue.push(src); }
}
}
}
}
let splitModelNode = null, splitVaeNode = null;
if (!ckptNode) {
let ms = traceInput(condAnchor, "model")?.node;
// Trace past PuLID AND the pose-control / regional-couple model patches: the
// tile pass must re-detail through the CLEAN model. Leaving ZImageFunControlnet
// in re-applies the source's full-frame pose DEPTH to every tile, baking the
// mannequin silhouette into the upscale; the couple re-applies full-canvas
// regional masks that are wrong per-tile. (SDXL avoids this by finding its
// CheckpointLoader above; Z-Image's UNETLoader chain has no such anchor.) Stop
// at ModelSamplingAuraFlow — Z-Image still needs its sigma shift.
const STRIP_FOR_TILE = new Set(["ApplyPulidAdvanced", "ApplyPulid", "ApplyPuLIDFlux2",
"ZImageFunControlnet", "PromptChain_ZImageRegionalCouple", "PromptChain_AttentionCouple"]);
while (ms && STRIP_FOR_TILE.has(ms.comfyClass)) {
ms = traceInput(ms, "model")?.node;
}
splitModelNode = ms;
splitVaeNode = findUpstreamByType(vaeDecodeNode, new Set(["VAELoader"]));
if (!splitVaeNode) {
// The source decodes through a custom/tiled VAE (e.g. the spacepxl
// VAEUtils decoder on High-Detail krea2 renders) whose 2x/12-ch output
// can't drive USDU's encode->sample->decode tiling. Graft a fresh stock
// VAELoader on the shared krea2/qwen latent — prefer the clean wan_2.1_vae
// (no grid artifact), else qwen_image_vae, else whatever is installed.
const freshVae = LG.createNode("VAELoader");
const vaeW = freshVae?.widgets?.find(w => w.name === "vae_name");
const vaeOpts = vaeW?.options?.values || [];
const pick = vaeOpts.find(o => /wan.*2[._]?1.*vae/i.test(o) && !/upscale|2x/i.test(o))
|| vaeOpts.find(o => /qwen.*image.*vae/i.test(o)) || vaeOpts[0];
if (freshVae && vaeW && pick) {
vaeW.value = pick; vaeW.callback?.(pick);
freshVae.pos = [vaeDecodeNode.pos[0], vaeDecodeNode.pos[1] + 220];
graph.add(freshVae);
splitVaeNode = freshVae;
}
}
}
const loaderNode = LG.createNode("UpscaleModelLoader");
const upscaleNode = LG.createNode("UltimateSDUpscale");
if (!loaderNode || !upscaleNode) return false;
// The save node the upscaler sits beside (and inherits the prefix from):
// whatever consumes the image we're upscaling, falling back to the decode's.
let anchorSave = null;
for (const src of [imageSource, vaeDecodeNode]) {
if (anchorSave || !src) continue;
for (const lid of src.outputs?.[0]?.links || []) {
const link = getLink(graph, lid);
if (!link) continue;
const target = graph.getNodeById(link.target_id);
if (target?.comfyClass === "SaveImage" || target?.comfyClass === "PreviewImage") {
anchorSave = target; break;
}
}
}
const anchor = anchorSave || imageSource;
const anchorX = anchor.pos[0];
const anchorY = anchor.pos[1];
upscaleNode.pos = [anchorX + 384, anchorY];
loaderNode.pos = [anchorX + 716, anchorY];
graph.add(loaderNode);
graph.add(upscaleNode);
// Flux 2 tile-model pin (viewer grafts only, via opts.pinFlux2TileModel):
// non-Klein flux2 sources re-render their tiles on Klein 9B base instead of
// the source model — nobody tiles through the 32B Dev (every tile is a full
// render through a streamed 22 GB model), and cross-model refine is the
// family's established practice. Dev and Klein use DIFFERENT text encoders
// (Mistral vs Qwen3-8B) whose embeddings are not interchangeable, so the pin
// must also retarget the CLIPLoader feeding the upscaler's encodes — which
// is only safe on an off-screen graph whose gen path gets pruned afterward,
// never on a live Add-menu graph. Both picks come from the install's combos;
// if either the Klein UNET or the Qwen encoder is missing, the whole pin is
// abandoned (a mismatched TE+UNET pair is worse than a slow source model).
let pinnedModelNode = null;
if (opts.pinFlux2TileModel && modelConfig?.architecture === "flux2" && modelConfig?.family !== "flux2_klein") {
const pickFromCombo = (widget, patterns) => {
const values = widget?.options?.values || [];
for (const re of patterns) {
const hit = values.find(v => re.test(v));
if (hit) return hit;
}
return null;
};
const unetPin = LG.createNode("UNETLoader");
const unetW = unetPin?.widgets?.find(w => w.name === "unet_name");
const kleinFile = pickFromCombo(unetW, [/klein.*base.*9b|klein.*9b.*base/i, /klein.*9b/i, /klein/i]);
const clipLoader = positiveSource && findUpstreamByType(positiveSource.node, new Set(["CLIPLoader"]));
const clipW = clipLoader?.widgets?.find(w => w.name === "clip_name");
const kleinTE = clipW && (/qwen[_-]?3[_-]?8b/i.test(String(clipW.value))
? String(clipW.value)
: pickFromCombo(clipW, [/qwen[_-]?3[_-]?8b/i, /qwen.*8b/i]));
if (unetPin && kleinFile && kleinTE) {
unetW.value = kleinFile; unetW.callback?.(kleinFile);
if (clipW.value !== kleinTE) { clipW.value = kleinTE; clipW.callback?.(kleinTE); }
unetPin.pos = [anchorX + 384, anchorY - 160];
graph.add(unetPin);
pinnedModelNode = unetPin;
}
}
let computedScale = 2;
const latentSource = traceInput(samplerNode, "latent_image")?.node;
if (latentSource) {
const wW = latentSource.widgets?.find(w => w.name === "width");
const wH = latentSource.widgets?.find(w => w.name === "height");
if (wW && wH) {
const longestSide = Math.max(Number(wW.value) || 1024, Number(wH.value) || 1024);
computedScale = Math.round((4096 / longestSide) * 100) / 100;
}
}
const usSettings = modelConfig?.nodes?.UltimateSDUpscale || {};
const DEFAULTS = {
upscale_by: computedScale, steps: 15, cfg: 5,
sampler_name: "dpmpp_2m_sde", scheduler: "karras",
denoise: 0.3, mode_type: "Chess",
tile_width: 1024, tile_height: 1024,
mask_blur: 12, tile_padding: 32,
seam_fix_mode: "Half Tile", seam_fix_denoise: 0.25,
seam_fix_width: 64, seam_fix_mask_blur: 8, seam_fix_padding: 16,
force_uniform_tiles: true, tiled_decode: false,
};
applyArchSamplerDefaults(DEFAULTS, modelConfig?.architecture, modelConfig?.family);
if (pinnedModelNode) {
// Tiles run Klein base, not the distilled source — its official recipe
// (Comfy-Org templates) is 20 steps / CFG 5; the arch default cfg 1.0
// would mute the negative entirely on a base model.
DEFAULTS.steps = 20;
DEFAULTS.cfg = 5;
} else if (guiderNode?.comfyClass === "CFGGuider") {
// Klein base samples with real CFG — continue in the gen-time choice
// rather than the distilled-flux 1.0 arch default.
const genCfg = Number(guiderNode.widgets?.find(w => w.name === "cfg")?.value);
if (genCfg > 0) DEFAULTS.cfg = genCfg;
}
for (const w of upscaleNode.widgets || []) {
if (w.name in usSettings) { w.value = usSettings[w.name]; w.callback?.(usSettings[w.name]); }
else if (w.name in DEFAULTS) { w.value = DEFAULTS[w.name]; w.callback?.(DEFAULTS[w.name]); }
}
for (const w of loaderNode.widgets || []) {
if (w.name === "model_name") {
// Pick a model that actually exists in this install's combo — never
// hardcode a filename (the installer fetches 4x-UltraSharp, but a
// fixed default errors on any machine that has a different set).
const opts = w.options?.values || [];
const preferred = usSettings.upscale_model;
const val =
(preferred && opts.includes(preferred) && preferred) ||
opts.find(o => /ultrasharp/i.test(o)) ||
opts.find(o => /^4x/i.test(o)) ||
opts[0] ||
preferred || "4x-UltraSharp.pth";
w.value = val; w.callback?.(val); break;
}
}
// Regional graphs: upscale tiles must sample with each figure's own prompt.
// The couple's attn patch can't survive tiling (its full-canvas masks
// re-interpolate into every tile), but native cond masks travel inside the
// conditioning and USDU's crop_cond slices them per tile — so build the
// upscaler's conditioning from PromptChain_RegionalConditioning. Every guard
// falls back to the plain wiring below, so non-regional workflows (and
// regional ones on a backend that predates the node) are untouched.
let regionalCond = null;
const couple = opts.regional === false ? null
: downstream.find(n => n.comfyClass === "PromptChain_AttentionCouple");
if (couple) {
regionalCond = buildRegionalUpscaleCond(graph, LG, pcNode, couple, anchorX, anchorY);
}
const imageSlot = imageSource.outputs?.findIndex(o => o.type === "IMAGE") ?? 0;
imageSource.connect(imageSlot, upscaleNode, upscaleNode.inputs?.findIndex(i => i.name === "image") ?? 0);
if (ckptNode) {
const modelSlot = ckptNode.outputs?.findIndex(o => o.type === "MODEL") ?? 0;
const vaeSlot = ckptNode.outputs?.findIndex(o => o.type === "VAE") ?? 2;
ckptNode.connect(modelSlot, upscaleNode, upscaleNode.inputs?.findIndex(i => i.name === "model") ?? 1);
ckptNode.connect(vaeSlot, upscaleNode, upscaleNode.inputs?.findIndex(i => i.name === "vae") ?? 4);
} else {
const tileModelNode = pinnedModelNode || splitModelNode;
if (tileModelNode) {
const ms = tileModelNode.outputs?.findIndex(o => o.type === "MODEL") ?? 0;
tileModelNode.connect(ms, upscaleNode, upscaleNode.inputs?.findIndex(i => i.name === "model") ?? 1);
}
if (splitVaeNode) {
const vs = splitVaeNode.outputs?.findIndex(o => o.type === "VAE") ?? 0;
splitVaeNode.connect(vs, upscaleNode, upscaleNode.inputs?.findIndex(i => i.name === "vae") ?? 4);
}
}
if (regionalCond) {
regionalCond.connect(0, upscaleNode, upscaleNode.inputs?.findIndex(i => i.name === "positive") ?? 2);
regionalCond.connect(1, upscaleNode, upscaleNode.inputs?.findIndex(i => i.name === "negative") ?? 3);
} else {
if (positiveSource) positiveSource.node.connect(positiveSource.slot, upscaleNode, upscaleNode.inputs?.findIndex(i => i.name === "positive") ?? 2);
if (negativeSource) {
negativeSource.node.connect(negativeSource.slot, upscaleNode, upscaleNode.inputs?.findIndex(i => i.name === "negative") ?? 3);
} else if (positiveSource && isBasicGuider) {
const zeroNeg = LG.createNode("ConditioningZeroOut");
if (zeroNeg) {
zeroNeg.pos = [anchorX + 384, anchorY + 330];
graph.add(zeroNeg);
positiveSource.node.connect(positiveSource.slot, zeroNeg, 0);
zeroNeg.connect(0, upscaleNode, upscaleNode.inputs?.findIndex(i => i.name === "negative") ?? 3);
}
}
}
loaderNode.connect(0, upscaleNode, upscaleNode.inputs?.findIndex(i => i.name === "upscale_model") ?? 5);
const usSaveNode = LG.createNode("SaveImage");
if (usSaveNode) {
usSaveNode.pos = [anchorX + 716, anchorY + 160];
graph.add(usSaveNode);
if (anchorSave) {
const prefixWidget = anchorSave.widgets?.find(w => w.name === "filename_prefix");
const usPrefix = usSaveNode.widgets?.find(w => w.name === "filename_prefix");
if (prefixWidget && usPrefix) {
const base = prefixWidget.value;
const dateIdx = base.indexOf("%date");
if (dateIdx > 0) {
const before = base.substring(0, dateIdx).replace(/\/?$/, "/upscale/");
usPrefix.value = before + base.substring(dateIdx);
} else {
usPrefix.value = base.replace(/\/?$/, "/upscale");
}
usPrefix.callback?.(usPrefix.value);
}
}
upscaleNode.connect(0, usSaveNode, 0);
}
graph.setDirtyCanvas(true, true);
return true;
}
// Build the per-tile regional conditioning an UltimateSDUpscale needs in a
// regional graph: the couple's attn patch can't survive tiling (its full-canvas
// masks re-interpolate into every tile), but native cond masks travel inside
// the conditioning and USDU's crop_cond slices them per tile. Traces the
// couple's clip/masks sources and wires a PromptChain_RegionalConditioning.
// Returns the rc node, or null when the couple's wiring doesn't qualify.
// Exported for the viewer upscale graft, which re-targets an existing USDU.
export function buildRegionalUpscaleCond(graph, LG, pcNode, couple, anchorX, anchorY) {
const traceInput = (node, inputName) => {
const input = node.inputs?.find(i => i.name === inputName);
if (!input?.link) return null;
const link = getLink(graph, input.link);
const origin = link && graph.getNodeById(link.origin_id);
return origin ? { node: origin, slot: link.origin_slot } : null;
};
const poseNode = traceInput(couple, "masks")?.node;
const clipSrc = traceInput(couple, "clip");
const regOut = pcNode.outputs?.findIndex(o => o.name === "regions") ?? -1;
const maskOut = poseNode?.outputs?.findIndex(o => o.name === "MASKS") ?? -1;
if (poseNode?.comfyClass !== "PromptChain_PoseStudio" || !clipSrc || regOut < 0 || maskOut < 0) return null;
const rc = LG.createNode("PromptChain_RegionalConditioning");
if (!rc) return null;
rc.pos = [anchorX + 384, anchorY + 330];
graph.add(rc);
clipSrc.node.connect(clipSrc.slot, rc, rc.inputs?.findIndex(i => i.name === "clip") ?? 0);
pcNode.connect(regOut, rc, rc.inputs?.findIndex(i => i.name === "regions") ?? 1);
poseNode.connect(maskOut, rc, rc.inputs?.findIndex(i => i.name === "masks") ?? 2);
const poseOut = poseNode.outputs?.findIndex(o => o.name === "POSE_JSON") ?? -1;
const poseIn = rc.inputs?.findIndex(i => i.name === "pose") ?? -1;
if (poseOut >= 0 && poseIn >= 0) poseNode.connect(poseOut, rc, poseIn);
return rc;
}
// Resolve the right checkpoint from the loader's combo by pattern. Returns null
// when nothing matches — callers must NOT silently fall back to opts[0] (that's
// how an SDXL net got loaded into a Flux graph).
function pickControlNetModel(loaderNode, modelKey) {
const w = loaderNode.widgets?.find(x => x.name === "control_net_name");
const opts = w?.options?.values || [];
for (const re of CN_MODEL_MATCH[modelKey] || []) {
const hit = opts.find(o => re.test(o));
if (hit) return hit;
}
return null;
}
// Set the ControlNetLoader to the model for `modelKey`: prefer a pattern match
// against the combo (lets a user's own differently-named file win), else
// pre-seed the canonical installer filename so a just-downloaded model that the
// folder scan hasn't picked up yet is still selectable. Mirrors
// swapNodeCheckpointToFilename's pre-seed for the same rescan lag.
export function setControlNetModel(loaderNode, modelKey) {
const w = loaderNode.widgets?.find(x => x.name === "control_net_name");
if (!w) return;
let file = pickControlNetModel(loaderNode, modelKey);
if (!file) {
file = CN_CANONICAL_FILE[modelKey];
if (file && w.options?.values && !w.options.values.includes(file)) {
w.options.values.push(file);
}
}
if (file) { w.value = file; w.callback?.(file); }
}
// Inject one ControlNet branch and splice it into the conditioning. Stacks:
// reading the sampler's CURRENT positive/negative source means a second add
// chains onto the first apply node's output automatically.
function injectControlNet(pcNode, controlType) {
const graph = app.graph;
if (!graph) return false;
const LG = window.LiteGraph || graph.constructor?.LiteGraph;
if (!LG) return false;
// "depth-pose" is the depth branch sourced from a 3D Poser figure instead of a
// LoadImage. The Poser renders its OWN true geometric depth, so it wires straight
// into Apply ControlNet — no DepthAnything estimator (that exists only for the
// uploaded-image branches, which have no scene geometry to read).
const usePose = controlType === "depth-pose";
const cfg = CONTROLNET_TYPES[usePose ? "depth" : controlType];
if (!cfg) return false;
const downstream = findDownstreamNodes(pcNode);
const ksNode = downstream.find(n => n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced");
if (!ksNode) return false;
const traceInput = (node, inputName) => {
const input = node.inputs?.find(i => i.name === inputName);
if (!input?.link) return null;
const link = getLink(graph, input.link);
return link ? { node: graph.getNodeById(link.origin_id), slot: link.origin_slot } : null;
};
const posSrc = traceInput(ksNode, "positive");
const negSrc = traceInput(ksNode, "negative");
if (!posSrc?.node || !negSrc?.node) return false;
// Image source: a shared LoadImage (uploaded reference) reused across stacked
// branches — or, for "depth + 3D pose", a Pose Studio node whose true-depth
// render IS the control map. Pose nodes aren't shared (one figure per branch).
let loadImage = null, newLoadImage = false, poseNode = null;
if (usePose) {
poseNode = LG.createNode("PromptChain_PoseStudio");
if (!poseNode) return false;
// Switch the Poser to true geometric depth before its mount seeds the first
// capture (read live from pcrOutputMode) — the default disparity curve matches
// what the depth ControlNets were trained on.
poseNode.properties = { ...(poseNode.properties || {}), pcrOutputMode: "depth", pcrTrueDepth: true };
} else {
loadImage = graph._nodes?.find(n => n.comfyClass === "LoadImage" && n.title === "ControlNet Reference");
newLoadImage = !loadImage;
if (!loadImage) loadImage = LG.createNode("LoadImage");
}
const sourceNode = poseNode || loadImage;
// Pose-direct depth needs no preprocessor — the Poser's IMAGE already IS the map.
const preNode = usePose ? null : LG.createNode(resolvePre(cfg.pre));
const loaderNode = LG.createNode("ControlNetLoader");
const applyNode = LG.createNode("ControlNetApplyAdvanced");
const setUnion = cfg.unionType ? LG.createNode("SetUnionControlNetType") : null;
const preview = LG.createNode("PreviewImage");
if ((!usePose && !preNode) || !loaderNode || !applyNode) return false;
if (cfg.unionType && !setUnion) return false;
// Layout mirrors the hand-arranged reference (janku-depth-3): the cluster
// sits to the LEFT of the PromptChain node. Stacked branches step down one
// cluster height; the reference LoadImage is shared (created once).
const existingApplies = downstream.filter(n => n.comfyClass === "ControlNetApplyAdvanced").length;
const baseX = pcNode.pos?.[0] ?? 0;
const baseY = pcNode.pos?.[1] ?? 0;
const dy = existingApplies * 660; // step past the tallest cluster (pose)
// Loader sits just below the preprocessor, whose height varies a lot by type
// (pose tallest, scribble shortest) — derive from the live node height so one
// rule fits all controls. SetUnion is 150 below the loader.
const preH = preNode?.size?.[1] || 130;
const loaderY = preH + 65;
const setUnionY = loaderY + 150;
if (poseNode) {
poseNode.pos = [baseX - 1383, baseY + dy];
graph.add(poseNode);
poseNode.size = [360, 470];
} else if (newLoadImage) {
loadImage.title = "ControlNet Reference";
loadImage.pos = [baseX - 1223, baseY];
graph.add(loadImage);
loadImage.size = [250, 340];
}
if (preNode) preNode.pos = [baseX - 643, baseY + dy];
loaderNode.pos = [baseX - 633, baseY + loaderY + dy];
if (setUnion) setUnion.pos = [baseX - 633, baseY + setUnionY + dy];
applyNode.pos = [baseX - 313, baseY + dy];
if (preview) preview.pos = [baseX - 933, baseY + dy];
if (preNode) graph.add(preNode);
graph.add(loaderNode);
if (setUnion) graph.add(setUnion);
graph.add(applyNode);
if (preview) graph.add(preview);
// Sizes set after add so the litegraph size setter runs on a registered node.
// Preprocessor: fix width, keep its auto height (varies by widget count —
// canny has more rows than depth).
if (preNode) preNode.size = [291, preNode.size?.[1] ?? 130];
loaderNode.size = [270, 83];
if (setUnion) setUnion.size = [270, 83];
applyNode.size = [270, 240];
if (preview) preview.size = [250, 360];
// Tag the apply node so the settings panel can show a per-control section
// (keyed by control type, not node class — survives in the workflow JSON).
const tagType = usePose ? "depth" : controlType; // pose branch tunes like depth
applyNode.title = `ControlNet — ${usePose ? "Depth (pose)" : (CN_LABELS[controlType] || controlType)}`;
applyNode.properties = { ...(applyNode.properties || {}), pcrControlType: tagType };
setControlNetModel(loaderNode, cfg.model);
if (setUnion) {
const w = setUnion.widgets?.find(x => x.name === "type");
if (w) { w.value = cfg.unionType; w.callback?.(cfg.unionType); }
}
for (const w of applyNode.widgets || []) {
if (w.name === "strength") { w.value = cfg.strength; w.callback?.(cfg.strength); }
else if (w.name === "start_percent") { w.value = 0.0; w.callback?.(0.0); }
else if (w.name === "end_percent") { w.value = cfg.end; w.callback?.(cfg.end); }
}
for (const w of preNode?.widgets || []) {
if (w.name === "resolution") { w.value = 1024; w.callback?.(1024); }
// DWPose ships a toggle specifically for xinsir ControlNet stick scaling.
else if (w.name === "scale_stick_for_xinsr_cn") { w.value = "enable"; w.callback?.("enable"); }
}
const applyImageIdx = applyNode.inputs?.findIndex(i => i.name === "image") ?? 3;
if (preNode) {
sourceNode.connect(0, preNode, preNode.inputs?.findIndex(i => i.name === "image") ?? 0);
preNode.connect(0, applyNode, applyImageIdx);
if (preview) preNode.connect(0, preview, 0); // show the preprocessed map
} else {
// Pose-direct: the Poser's true-depth IMAGE is the control map itself.
sourceNode.connect(0, applyNode, applyImageIdx);
if (preview) sourceNode.connect(0, preview, 0); // the depth-map flash
}
if (setUnion) {
loaderNode.connect(0, setUnion, 0);
setUnion.connect(0, applyNode, applyNode.inputs?.findIndex(i => i.name === "control_net") ?? 2);
} else {
loaderNode.connect(0, applyNode, applyNode.inputs?.findIndex(i => i.name === "control_net") ?? 2);
}
posSrc.node.connect(posSrc.slot, applyNode, applyNode.inputs?.findIndex(i => i.name === "positive") ?? 0);
negSrc.node.connect(negSrc.slot, applyNode, applyNode.inputs?.findIndex(i => i.name === "negative") ?? 1);
// Splice: the apply node's conditioning outputs now feed the sampler
// (connecting to an input replaces its existing link).
applyNode.connect(0, ksNode, ksNode.inputs?.findIndex(i => i.name === "positive") ?? 1);
applyNode.connect(1, ksNode, ksNode.inputs?.findIndex(i => i.name === "negative") ?? 2);
graph.setDirtyCanvas(true, true);
return true;
}
// Regional conditioning: splice a PromptChain Attention Couple onto the MODEL line so
// each $mannequin{} block paints only on its figure. REUSES the existing 3D Poser (the
// user's actual posed scene) for the masks — never spawns a duplicate — and reroutes the
// conditioning that currently feeds the sampler THROUGH the couple, preserving any depth
// ControlNet already in the chain (no second depth branch, so the model isn't over-cooked).
function injectRegional(pcNode, modelConfig) {
const graph = app.graph;
if (!graph) return false;
const LG = window.LiteGraph || graph.constructor?.LiteGraph;
if (!LG) return false;
const downstream = findDownstreamNodes(pcNode);
// Samplers that expose positive/negative/model directly so the reroute below
// can trace them. Custom/RES4LYF recipes (e.g. krea2 realism's
// ClownsharKSampler_Beta) were silently unsupported before — Regional bailed
// with a mislabeled "no attachment point" toast.
const REGIONAL_SAMPLERS = ["KSampler", "KSamplerAdvanced", "SamplerCustom", "ClownsharKSampler_Beta"];
const ksNode = downstream.find(n => REGIONAL_SAMPLERS.includes(n.comfyClass));
if (!ksNode) return false;
// Mask producer: a 3D Poser (preferred — richer scene) OR a Region Box. Both
// expose the same MASKS + POSE_JSON contract, so either can drive Regional;
// previously only a Poser was accepted, so a Region-Box-only graph couldn't
// use the one-click path despite region-box.js documenting parity.
const nodes = graph._nodes || [];
const poseNode = nodes.find(n => n.comfyClass === "PromptChain_PoseStudio")
|| nodes.find(n => n.comfyClass === "PromptChain_RegionBox");
if (!poseNode) {
app.extensionManager?.toast?.add({
severity: "warn", summary: "Add a pose or region box first",
detail: "Regional needs a PromptChain 3D Poser or Region Box in the graph — add one, place your figures/boxes, then add Regional.",
life: 7000,
});
return false;
}
const traceInput = (node, inputName) => {
const input = node.inputs?.find(i => i.name === inputName);
if (!input?.link) return null;
const link = getLink(graph, input.link);
return link ? { node: graph.getNodeById(link.origin_id), slot: link.origin_slot } : null;
};
const outIdx = (n, name) => { const i = n.outputs?.findIndex(o => o.name === name); return i == null ? -1 : i; };
const ensureOut = (node, name) => {
let i = outIdx(node, name);
if (i < 0) {
const ref = LG.createNode(node.comfyClass || node.type);
if (ref?.outputs) for (let k = node.outputs?.length || 0; k < ref.outputs.length; k++) node.addOutput(ref.outputs[k].name, ref.outputs[k].type);
i = outIdx(node, name);
}
return i;
};
const modelSrc = traceInput(ksNode, "model");
const posSrc = traceInput(ksNode, "positive");
const negSrc = traceInput(ksNode, "negative");
if (!modelSrc?.node || !posSrc?.node || !negSrc?.node) return false;
// CLIP source: reuse whatever fed the positive encode (respects clip-skip);
// else the clip feeding any CLIPTextEncode; else any CLIP output. Shared by
// both regional terminals below.
const traceClip = () => {
let s = traceInput(posSrc.node, "clip");
if (!s?.node) {
const enc = (graph._nodes || []).find(n => n.comfyClass === "CLIPTextEncode" && n.inputs?.some(i => i.name === "clip" && i.link != null));
if (enc) s = traceInput(enc, "clip");
}
if (!s?.node) {
for (const n of graph._nodes || []) { const o = outIdx(n, "CLIP"); if (o >= 0) { s = { node: n, slot: o }; break; } }
}
return s;
};
// Repair stale/misnamed PromptChain outputs so 'regions' lands at its true slot.
const repairPcOutputs = () => {
const refPc = LG.createNode(pcNode.comfyClass || pcNode.type);
if (refPc?.outputs) {
for (let i = 0; i < refPc.outputs.length; i++) {
if (!pcNode.outputs?.[i]) pcNode.addOutput(refPc.outputs[i].name, refPc.outputs[i].type);
else if (pcNode.outputs[i].name !== refPc.outputs[i].name) pcNode.outputs[i].name = refPc.outputs[i].name;
}
while ((pcNode.outputs?.length || 0) > refPc.outputs.length) pcNode.removeOutput(pcNode.outputs.length - 1);
}
};
// DiT / joint-attention models (Flux, Z-Image, SD3, …) have NO UNet attn2 for
// the Attention Couple to patch, so the couple would silently do nothing. They
// use ComfyUI-native masked conditioning instead (PromptChain_RegionalConditioning)
// — the exact terminal the Flux/Z-Image 3D-regional templates ship. It emits the
// global prompt plus each $mannequin{} masked to its figure and REPLACES the
// conditioning feeding the sampler; the depth ControlNet on these archs rides the
// MODEL line (e.g. ZImageFunControlnet), so there's no conditioning-side apply to
// route through, and the model line is left untouched.
// Z-Image (single-stream DiT) gets a TRUE attention couple via the shared model-line
// splice below (PromptChain_ZImageRegionalCouple patches NextDiT's joint attention so
// $blocks cohere into ONE scene). Other DiTs (Flux/SD3/…) have no ported couple yet, so
// they fall back to ComfyUI-native masked conditioning here.
const arch = modelConfig?.architecture || "";
// krea2 deliberately excluded: native cond-mask regional doesn't localize on
// Flux-based DiT (GPU-tested — regions smear onto the subject).
const NATIVE_REGIONAL_ARCH = new Set(["flux", "flux2", "sd3", "hidream", "qwen_image", "qwen_edit"]);
if (NATIVE_REGIONAL_ARCH.has(arch)) {
const rc = LG.createNode("PromptChain_RegionalConditioning");
if (!rc) return false;
rc.pos = [(pcNode.pos?.[0] || 0) - 360, (pcNode.pos?.[1] || 0) + 360];
graph.add(rc);
rc.size = [300, 230];
const clipSrc = traceClip();
if (clipSrc?.node) clipSrc.node.connect(clipSrc.slot, rc, "clip");
repairPcOutputs();
const regOut = outIdx(pcNode, "regions");
if (regOut >= 0) pcNode.connect(regOut, rc, "regions");
// masks ← the existing Poser scene (force-add MASKS if its def is stale).
let maskOut = outIdx(poseNode, "MASKS");
if (maskOut < 0) { poseNode.addOutput("MASKS", "MASK"); maskOut = outIdx(poseNode, "MASKS"); }
if (maskOut >= 0) poseNode.connect(maskOut, rc, "masks");
// pose ← POSE_JSON: figure names so renamed $blocks bind to the right mask.
const poseJsonOut = outIdx(poseNode, "POSE_JSON");
const poseInIdx = rc.inputs?.findIndex(i => i.name === "pose");
if (poseJsonOut >= 0 && poseInIdx != null && poseInIdx >= 0) poseNode.connect(poseJsonOut, rc, poseInIdx);
// Replace the conditioning feeding the sampler with the regional outputs,
// KEEPING any ControlNet apply in between (none on the DiT model-patch path).
const reroute = (rcOut, src, ksInput) => {
if (src.node?.comfyClass === "ControlNetApplyAdvanced") rc.connect(outIdx(rc, rcOut), src.node, ksInput);
else rc.connect(outIdx(rc, rcOut), ksNode, ksInput);
};
reroute("positive", posSrc, "positive");
reroute("negative", negSrc, "negative");
poseNode._pcrReconcileRegions?.();
graph.setDirtyCanvas(true, true);
return true;
}
// Z-Image patches the DiT's joint attention; SDXL/SD1.5 patch UNet attn2. Both
// sit on the MODEL line and output (MODEL, positive, negative), so the wiring below
// is shared — only the node class differs.
const coupleType = arch === "zimage" ? "PromptChain_ZImageRegionalCouple" : "PromptChain_AttentionCouple";
const couple = LG.createNode(coupleType);
if (!couple) return false;
couple.pos = [(pcNode.pos?.[0] || 0) - 340, (pcNode.pos?.[1] || 0) + 360];
graph.add(couple);
couple.size = [300, 230];
// MODEL line: source -> couple -> sampler (couple inserts after any depth ControlNet
// already feeding the sampler's model, so depth + regional compose).
modelSrc.node.connect(modelSrc.slot, couple, "model");
couple.connect(outIdx(couple, "MODEL"), ksNode, "model");
// CLIP: reuse whatever fed the positive encode; else any CLIPTextEncode's clip
// (respects clip-skip); else any CLIP output.
const clipSrc = traceClip();
if (clipSrc?.node) clipSrc.node.connect(clipSrc.slot, couple, "clip");
// Repair stale/corrupted PromptChain outputs so 'regions' lands at its true slot
// (older builds could leave a duplicated or misnamed output at slot 1).
repairPcOutputs();
// regions ← PromptChain; masks ← the existing Poser scene.
const regOut = outIdx(pcNode, "regions");
if (regOut >= 0) pcNode.connect(regOut, couple, "regions");
// Force-add the MASKS output if the Poser's def is stale (page loaded before the
// backend gained MASKS) — the backend returns MASKS at slot 2 (after IMAGE,
// POSE_JSON), so appending it lands at the right slot. This is the bug that left
// masks orphaned: a stale def meant there was no MASKS output to wire.
let maskOut = outIdx(poseNode, "MASKS");
if (maskOut < 0) { poseNode.addOutput("MASKS", "MASK"); maskOut = outIdx(poseNode, "MASKS"); }
if (maskOut >= 0) poseNode.connect(maskOut, couple, "masks");
// POSE_JSON → couple.pose: carries the figure names so renamed $blocks bind to
// the right mask (skipped on stale couple defs that predate the pose input;
// default mannequinN names still bind positionally without it).
const poseJsonOut = outIdx(poseNode, "POSE_JSON");
const poseInIdx = couple.inputs?.findIndex(i => i.name === "pose");
if (poseJsonOut >= 0 && poseInIdx != null && poseInIdx >= 0) poseNode.connect(poseJsonOut, couple, poseInIdx);
// Reroute conditioning through the couple, KEEPING any ControlNet apply in between:
// feed the couple's global cond into the same consumer that currently feeds the
// sampler (the depth apply's positive/negative), else straight into the sampler.
const reroute = (coupleOut, src, ksInput) => {
const isApply = src.node?.comfyClass === "ControlNetApplyAdvanced";
if (isApply) couple.connect(outIdx(couple, coupleOut), src.node, ksInput);
else couple.connect(outIdx(couple, coupleOut), ksNode, ksInput);
};
reroute("positive", posSrc, "positive");
reroute("negative", negSrc, "negative");
// Seed the prompt now: wiring fires no capture, so ask the Poser to sync its
// $mannequinN{} blocks into the bound PromptChain immediately.
poseNode._pcrReconcileRegions?.();
// If the graph already detail-passes faces, that FaceDetailer would re-render
// every face the couple just painted with ONE global prompt, stripping the
// per-character identity back out — swap it for the regional one. A graph
// with no detailer gets none; face detailing stays its own [add] choice.
// SKIP on Z-Image: the RegionalDetailer runs an SDXL-shaped cfg sampler (no
// AuraFlow model-sampling) that fries DiT faces — same reason it's omitted from
// the Flux/Z-Image regional templates.
if (arch !== "zimage") injectRegionalDetailer(pcNode, true, modelConfig);
graph.setDirtyCanvas(true, true);
return true;
}
// Splice a PromptChain_RegionalDetailer onto the image path after VAEDecode. It
// detects faces itself, matches each to its figure by mask overlap, and details
// it with that region's own conditioning — so it REPLACES any FaceDetailer,
// which is mask-blind (one global prompt for every face). Verified A/B on the
// Ryu/Sagat rig: identical detail quality, identity kept instead of erased.
// This is what [add] > FaceDetailer injects when the graph is regional;
// `replaceOnly` is the Regional-inject path, which only swaps an existing
// FaceDetailer and never adds an unrequested detail pass.
function injectRegionalDetailer(pcNode, replaceOnly = false, modelConfig = null) {
const graph = app.graph;
if (!graph) return false;
const LG = window.LiteGraph || graph.constructor?.LiteGraph;
if (!LG) return false;
const downstream = findDownstreamNodes(pcNode);
if (downstream.some(n => n.comfyClass === "PromptChain_RegionalDetailer")) return true;
const fd = downstream.find(n => n.comfyClass === "FaceDetailer");
if (replaceOnly && !fd) return true;
const vaeDecode = downstream.find(n => n.comfyClass === "VAEDecode");
// Couple + Poser are the REGIONAL inputs (per-figure masks, couple-free base
// model). Both optional: on a non-regional graph the node still works — it
// details every detected face with the prompt's `global` block (PromptChain's
// regions JSON always carries it), exactly like a plain FaceDetailer.
const couple = downstream.find(n => n.comfyClass === "PromptChain_AttentionCouple");
const poseNode = (graph._nodes || []).find(n => n.comfyClass === "PromptChain_PoseStudio");
const ksNode = downstream.find(n => n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced");
if (!vaeDecode || (!couple && !ksNode)) return false;
const traceInput = (node, inputName) => {
const input = node?.inputs?.find(i => i.name === inputName);
if (!input?.link) return null;
const link = getLink(graph, input.link);
return link ? { node: graph.getNodeById(link.origin_id), slot: link.origin_slot } : null;
};
const inIdx = (n, name) => { const i = n.inputs?.findIndex(x => x.name === name); return i == null || i < 0 ? null : i; };
const outIdx = (n, name) => { const i = n.outputs?.findIndex(o => o.name === name); return i == null ? -1 : i; };
// Regional: the couple's own inputs are authoritative — its model input is the
// BASE (couple-free) model, its clip carries the rig's clip-skip. The crop is a
// single region, so couple-free + region cond alone is cleaner.
// Non-regional: source from the sampler instead — its model line carries any
// IPAdapter/PuLID patch, so the face re-pass keeps that style/identity, and the
// clip is whatever encoded the prompt (clip-skip included).
let modelSrc, clipSrc;
if (couple) {
modelSrc = traceInput(couple, "model");
clipSrc = traceInput(couple, "clip");
} else {
modelSrc = traceInput(ksNode, "model");
const enc = findUpstreamByType(ksNode, new Set(["CLIPTextEncode"]));
clipSrc = enc ? traceInput(enc, "clip") : null;
if (!clipSrc) {
const ck = downstream.find(n => n.comfyClass === "CheckpointLoaderSimple" || n.comfyClass === "CheckpointLoader");
if (ck) clipSrc = { node: ck, slot: ck.outputs?.findIndex(o => o.type === "CLIP") ?? 1 };
}
}
const regOut = outIdx(pcNode, "regions");
const maskOut = poseNode ? outIdx(poseNode, "MASKS") : -1;
// An existing FaceDetailer hands over its image consumers, then leaves along
// with any detector/SAM loaders that fed only it.
const fdConsumers = [];
let fdHelpers = [];
if (fd) {
for (const lid of fd.outputs?.[0]?.links || []) {
const link = getLink(graph, lid);
if (link) fdConsumers.push({ id: link.target_id, slot: link.target_slot });
}
fdHelpers = (fd.inputs || [])
.map(i => traceInput(fd, i.name)?.node)
.filter(n => n && (n.comfyClass === "UltralyticsDetectorProvider" || n.comfyClass === "SAMLoader"));
}
let saveImageNode = null;
for (const lid of vaeDecode.outputs?.[0]?.links || []) {
const link = getLink(graph, lid);
const target = link && graph.getNodeById(link.target_id);
if (target?.comfyClass === "SaveImage" || target?.comfyClass === "PreviewImage") { saveImageNode = target; break; }
}
const rd = LG.createNode("PromptChain_RegionalDetailer");
if (!rd) return;
const anchor = fd || saveImageNode || vaeDecode;
rd.pos = [anchor.pos[0] + (fd ? 0 : 331), anchor.pos[1] - (fd ? 0 : 157)];
graph.add(rd);
// Match the rig's sampler dials (the template/preset already set those per
// model, so cfg/sampler track the architecture); denoise 0.6 is the verified
// detail strength. The model's SAVED detailer settings win over both — its
// own RegionalDetailer preset first, else its FaceDetailer preset: curated
// model configs carry face-detail tuning (e.g. PVC figurine denoise 0.25)
// under FaceDetailer, and the dials share names by design.
const ksWidget = (name) => ksNode?.widgets?.find(w => w.name === name)?.value;
const SETTINGS = {
steps: ksWidget("steps"), cfg: ksWidget("cfg"),
sampler_name: ksWidget("sampler_name"), scheduler: ksWidget("scheduler"),
// Fallback for uncurated models only — every curated model carries its own
// FaceDetailer/RegionalDetailer denoise (0.25 photoreal … 0.4 stylized).
// 0.4 is a safe middle; 0.6 over-detailed and shifted faces on photoreal.
denoise: 0.4, guide_size: 384, max_size: 768,
};
const saved = modelConfig?.nodes?.PromptChain_RegionalDetailer
|| modelConfig?.nodes?.FaceDetailer || {};
for (const w of rd.widgets || []) {
if (w.name in saved) { w.value = saved[w.name]; w.callback?.(saved[w.name]); }
else if (w.name in SETTINGS && SETTINGS[w.name] != null) { w.value = SETTINGS[w.name]; w.callback?.(SETTINGS[w.name]); }
}
vaeDecode.connect(0, rd, inIdx(rd, "image") ?? 3);
// Base model, not the couple-patched one: the crop is a single region, so the
// region conditioning alone is cleaner (the couple's full-canvas masks would
// misalign on the crop anyway — the original bug).
modelSrc?.node?.connect(modelSrc.slot, rd, inIdx(rd, "model") ?? 0);
clipSrc?.node?.connect(clipSrc.slot, rd, inIdx(rd, "clip") ?? 1);
const vaeSrc = traceInput(vaeDecode, "vae");
vaeSrc?.node?.connect(vaeSrc.slot, rd, inIdx(rd, "vae") ?? 2);
if (regOut >= 0) pcNode.connect(regOut, rd, inIdx(rd, "regions") ?? 4);
// Masks + pose only exist on a regional graph; non-regional leaves them
// unwired (both optional) and the node falls back to detector + global prompt.
if (poseNode && maskOut >= 0) poseNode.connect(maskOut, rd, inIdx(rd, "masks") ?? 5);
// POSE_JSON carries the Poser's exported head boxes — enables the
// 'mannequin heads' face source (exact head positions, no detector misses).
const poseOut = poseNode ? outIdx(poseNode, "POSE_JSON") : -1;
if (poseNode && poseOut >= 0) {
const poseIn = inIdx(rd, "pose");
if (poseIn != null) poseNode.connect(poseOut, rd, poseIn);
}
// negative stays unwired — the node encodes the regions JSON negative itself.
// PuLID'd graph (injected before the detailer): the sampler-line ApplyPulid
// sits after the couple, so the base model wired above is identity-free —
// give the detailer its own patch, reusing the existing cluster's loaders
// and matching its dials.
const samplerPulid = downstream.find(n => PULID_APPLY_TYPES.has(n.comfyClass));
if (samplerPulid) {
const pulidSources = tracePulidApplySources(graph, samplerPulid);
if (pulidSources) {
const pulidDials = {};
for (const w of samplerPulid.widgets || []) pulidDials[w.name] = w.value;
splicePulidOnDetailerModelLine(graph, LG, rd, pulidSources, pulidDials);
}
}
// Same for a Style Reference (IPAdapter) graph — the face re-pass should
// keep the style, not repaint faces in the bare checkpoint's look.
const samplerStyle = downstream.find(n => n.comfyClass === "IPAdapterAdvanced");
if (samplerStyle) {
const styleSources = {};
for (const name of ["ipadapter", "image"]) {
const src = traceInput(samplerStyle, name);
if (src?.node) styleSources[name] = src;
}
if (styleSources.ipadapter && styleSources.image) {
const styleDials = {};
for (const w of samplerStyle.widgets || []) styleDials[w.name] = w.value;
spliceStyleOnDetailerModelLine(graph, LG, rd, styleSources, styleDials);
}
}
if (fd) {
graph.remove(fd);
for (const h of fdHelpers) {
if (!(h.outputs || []).some(o => o.links?.length)) graph.remove(h);
}
}
if (fdConsumers.length) {
for (const c of fdConsumers) {
const tgt = graph.getNodeById(c.id);
if (tgt) rd.connect(0, tgt, c.slot);
}
} else {
// Add a SECONDARY SaveImage for the detailed output, leaving VAEDecode's
// existing SaveImage to keep saving the base render — matches Impact's
// FaceDetailer add (before/after pair, never replaces the clean save).
const rdSave = LG.createNode("SaveImage");
if (rdSave) {
rdSave.pos = [rd.pos[0] + 450, rd.pos[1]];
graph.add(rdSave);
const prefix = saveImageNode?.widgets?.find(w => w.name === "filename_prefix");
const rdPrefix = rdSave.widgets?.find(w => w.name === "filename_prefix");
if (prefix && rdPrefix) { rdPrefix.value = prefix.value; rdPrefix.callback?.(prefix.value); }
rd.connect(0, rdSave, 0);
}
}
if (fd) {
app.extensionManager?.toast?.add({
severity: "info", summary: "FaceDetailer → Regional Detailer",
detail: "Faces now detail with their own character's prompt (matched by figure mask).",
life: 6000,
});
}
graph.setDirtyCanvas(true, true);
return true;
}
// Flux ControlNet branch (Shakker Union-Pro-2.0). Same KSampler-pivot splice as
// the SDXL inject — PromptChain's Flux template wires a single CLIPTextEncode
// into both KSampler.positive and .negative (cfg=1, no real negative), so the
// trace/splice is identical. Differences: no SetUnionControlNetType (v2 dropped
// it) and the VAE feeding VAEDecode is wired into ControlNetApplyAdvanced (Flux
// ControlNets encode the hint into latent space and need the VAE).
function injectFluxControlNet(pcNode, controlType) {
const graph = app.graph;
if (!graph) return false;
const LG = window.LiteGraph || graph.constructor?.LiteGraph;
if (!LG) return false;
// "depth-pose" = the depth branch sourced from a 3D Poser figure (true geometric
// depth) instead of a LoadImage — wired straight into Apply ControlNet.
const usePose = controlType === "depth-pose";
const baseControl = usePose ? "depth" : controlType;
const cfg = FLUX_CONTROLNET_TYPES[baseControl];
if (!cfg) return false;
const downstream = findDownstreamNodes(pcNode);
const ksNode = downstream.find(n => n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced");
if (!ksNode) return false;
const traceInput = (node, inputName) => {
const input = node.inputs?.find(i => i.name === inputName);
if (!input?.link) return null;
const link = getLink(graph, input.link);
return link ? { node: graph.getNodeById(link.origin_id), slot: link.origin_slot } : null;
};
const posSrc = traceInput(ksNode, "positive");
const negSrc = traceInput(ksNode, "negative");
if (!posSrc?.node || !negSrc?.node) return false;
// VAE source: prefer the one feeding the workflow's VAEDecode; fall back to
// any node exposing a VAE output (VAELoader / checkpoint).
let vaeSrc = null;
const vaeDecode = downstream.find(n => n.comfyClass === "VAEDecode");
if (vaeDecode) vaeSrc = traceInput(vaeDecode, "vae");
if (!vaeSrc?.node) {
for (const n of graph._nodes || []) {
const slot = n.outputs?.findIndex(o => o.type === "VAE") ?? -1;
if (slot >= 0) { vaeSrc = { node: n, slot }; break; }
}
}
// Image source: a 3D Poser figure (depth + 3D pose) whose true-depth render IS
// the control map — or a shared LoadImage reference reused across stacked branches.
let loadImage = null, newLoadImage = false, poseNode = null;
if (usePose) {
poseNode = LG.createNode("PromptChain_PoseStudio");
if (!poseNode) return false;
// True geometric depth, set before its mount seeds the first capture.
poseNode.properties = { ...(poseNode.properties || {}), pcrOutputMode: "depth", pcrTrueDepth: true };
} else {
loadImage = graph._nodes?.find(n => n.comfyClass === "LoadImage" && n.title === "ControlNet Reference");
newLoadImage = !loadImage;
if (!loadImage) loadImage = LG.createNode("LoadImage");
}
const sourceNode = poseNode || loadImage;
// Pose-direct depth needs no preprocessor — the Poser's IMAGE already IS the map.
const preNode = usePose ? null : LG.createNode(resolvePre(cfg.pre));
const loaderNode = LG.createNode("ControlNetLoader");
const applyNode = LG.createNode("ControlNetApplyAdvanced");
const preview = LG.createNode("PreviewImage");
if ((!usePose && !preNode) || !loaderNode || !applyNode) return false;
// Same left-of-node layout as the SDXL cluster, minus the SetUnion node.
const existingApplies = downstream.filter(n => n.comfyClass === "ControlNetApplyAdvanced").length;
const baseX = pcNode.pos?.[0] ?? 0;
const baseY = pcNode.pos?.[1] ?? 0;
const dy = existingApplies * 660;
const preH = preNode?.size?.[1] || 130;
const loaderY = preH + 65;
if (poseNode) {
poseNode.pos = [baseX - 1383, baseY + dy];
graph.add(poseNode);
poseNode.size = [360, 470];
} else if (newLoadImage) {
loadImage.title = "ControlNet Reference";
loadImage.pos = [baseX - 1223, baseY];
graph.add(loadImage);
loadImage.size = [250, 340];
}
if (preNode) preNode.pos = [baseX - 643, baseY + dy];
loaderNode.pos = [baseX - 633, baseY + loaderY + dy];
applyNode.pos = [baseX - 313, baseY + dy];
if (preview) preview.pos = [baseX - 933, baseY + dy];
if (preNode) graph.add(preNode);
graph.add(loaderNode);
graph.add(applyNode);
if (preview) graph.add(preview);
if (preNode) preNode.size = [291, preNode.size?.[1] ?? 130];
loaderNode.size = [270, 83];
applyNode.size = [270, 240];
if (preview) preview.size = [250, 360];
// Flux branches carry a "flux-" prefixed control type so their settings
// sections + recommended markers stay distinct from the SDXL defaults.
const settingsType = `flux-${baseControl}`;
applyNode.title = `ControlNet — ${usePose ? "Depth (pose)" : (CN_LABELS[settingsType] || controlType)}`;
applyNode.properties = { ...(applyNode.properties || {}), pcrControlType: settingsType };
setControlNetModel(loaderNode, "fluxunion");
for (const w of applyNode.widgets || []) {
if (w.name === "strength") { w.value = cfg.strength; w.callback?.(cfg.strength); }
else if (w.name === "start_percent") { w.value = 0.0; w.callback?.(0.0); }
else if (w.name === "end_percent") { w.value = cfg.end; w.callback?.(cfg.end); }
}
for (const w of preNode?.widgets || []) {
if (w.name === "resolution") { w.value = 1024; w.callback?.(1024); }
}
const applyImageIdx = applyNode.inputs?.findIndex(i => i.name === "image") ?? 3;
if (preNode) {
sourceNode.connect(0, preNode, preNode.inputs?.findIndex(i => i.name === "image") ?? 0);
preNode.connect(0, applyNode, applyImageIdx);
if (preview) preNode.connect(0, preview, 0);
} else {
// Pose-direct: the Poser's true-depth IMAGE is the control map itself.
sourceNode.connect(0, applyNode, applyImageIdx);
if (preview) sourceNode.connect(0, preview, 0);
}
loaderNode.connect(0, applyNode, applyNode.inputs?.findIndex(i => i.name === "control_net") ?? 2);
if (vaeSrc?.node) {
const vaeIdx = applyNode.inputs?.findIndex(i => i.name === "vae") ?? -1;
if (vaeIdx >= 0) vaeSrc.node.connect(vaeSrc.slot, applyNode, vaeIdx);
}
posSrc.node.connect(posSrc.slot, applyNode, applyNode.inputs?.findIndex(i => i.name === "positive") ?? 0);
negSrc.node.connect(negSrc.slot, applyNode, applyNode.inputs?.findIndex(i => i.name === "negative") ?? 1);
applyNode.connect(0, ksNode, ksNode.inputs?.findIndex(i => i.name === "positive") ?? 1);
applyNode.connect(1, ksNode, ksNode.inputs?.findIndex(i => i.name === "negative") ?? 2);
graph.setDirtyCanvas(true, true);
return true;
}
// Z-Image ControlNet branch (alibaba-pai Fun-Controlnet-Union). Unlike SDXL/Flux,
// ZImageFunControlnet PATCHES the model (DiffSynth model-patch) and outputs only
// MODEL — so this splices onto the model line (like PuLID), not the conditioning.
// Stacks: reading the sampler's CURRENT model source chains a 2nd branch on.
function injectZImageControlNet(pcNode, controlType, isBase = false) {
const graph = app.graph;
if (!graph) return false;
const LG = window.LiteGraph || graph.constructor?.LiteGraph;
if (!LG) return false;
// "depth-pose" = the depth branch sourced from a 3D Poser figure instead of a
// LoadImage. The Poser emits its own true geometric depth, so it wires straight
// into ZImageFunControlnet (no DepthAnything); its MASKS/POSE outputs are what a
// follow-up Regional inject binds to.
const usePose = controlType === "depth-pose";
const baseControl = usePose ? "depth" : controlType;
const cfg = ZIMAGE_CONTROLNET_TYPES[baseControl];
if (!cfg) return false;
const downstream = findDownstreamNodes(pcNode);
const ksNode = downstream.find(n => n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced");
if (!ksNode) return false;
const traceInput = (node, inputName) => {
const input = node.inputs?.find(i => i.name === inputName);
if (!input?.link) return null;
const link = getLink(graph, input.link);
return link ? { node: graph.getNodeById(link.origin_id), slot: link.origin_slot } : null;
};
const modelSrc = traceInput(ksNode, "model");
if (!modelSrc?.node) return false;
let vaeSrc = null;
const vaeDecode = downstream.find(n => n.comfyClass === "VAEDecode");
if (vaeDecode) vaeSrc = traceInput(vaeDecode, "vae");
if (!vaeSrc?.node) {
for (const n of graph._nodes || []) {
const slot = n.outputs?.findIndex(o => o.type === "VAE") ?? -1;
if (slot >= 0) { vaeSrc = { node: n, slot }; break; }
}
}
let loadImage = null, newLoadImage = false, poseNode = null;
if (usePose) {
poseNode = LG.createNode("PromptChain_PoseStudio");
if (!poseNode) return false;
// Poser emits its own true geometric depth → straight into ZImageFunControlnet,
// no DepthAnything re-estimation. Set before its mount seeds the first capture.
poseNode.properties = { ...(poseNode.properties || {}), pcrOutputMode: "depth", pcrTrueDepth: true };
} else {
loadImage = graph._nodes?.find(n => n.comfyClass === "LoadImage" && n.title === "ControlNet Reference");
newLoadImage = !loadImage;
if (!loadImage) loadImage = LG.createNode("LoadImage");
}
const sourceNode = poseNode || loadImage;
// Pose-direct depth needs no preprocessor — the Poser's IMAGE already IS the map.
const preNode = usePose ? null : LG.createNode(resolvePre(cfg.pre));
const patchLoader = LG.createNode("ModelPatchLoader");
const zcnNode = LG.createNode("ZImageFunControlnet");
const preview = LG.createNode("PreviewImage");
if ((!usePose && !preNode) || !patchLoader || !zcnNode) return false;
const existing = downstream.filter(n => n.comfyClass === "ZImageFunControlnet").length;
const baseX = pcNode.pos?.[0] ?? 0;
const baseY = pcNode.pos?.[1] ?? 0;
const dy = existing * 660;
const preH = preNode?.size?.[1] || 130;
const loaderY = preH + 65;
if (poseNode) {
poseNode.pos = [baseX - 1383, baseY + dy];
graph.add(poseNode);
poseNode.size = [360, 470];
} else if (newLoadImage) {
loadImage.title = "ControlNet Reference";
loadImage.pos = [baseX - 1223, baseY];
graph.add(loadImage);
loadImage.size = [250, 340];
}
if (preNode) preNode.pos = [baseX - 643, baseY + dy];
patchLoader.pos = [baseX - 633, baseY + loaderY + dy];
zcnNode.pos = [baseX - 313, baseY + dy];
if (preview) preview.pos = [baseX - 933, baseY + dy];
if (preNode) graph.add(preNode);
graph.add(patchLoader);
graph.add(zcnNode);
if (preview) graph.add(preview);
if (preNode) preNode.size = [291, preNode.size?.[1] ?? 130];
patchLoader.size = [270, 58];
zcnNode.size = [270, 130];
if (preview) preview.size = [250, 360];
const settingsType = `zimage-${baseControl}`;
zcnNode.title = `ControlNet — ${usePose ? "Depth (pose)" : (CN_LABELS[settingsType] || controlType)}`;
zcnNode.properties = { ...(zcnNode.properties || {}), pcrControlType: settingsType };
// ModelPatchLoader.name combo reads models/model_patches. Pick the patch for
// the model family (Turbo vs base aren't cross-compatible); else pre-seed the
// installer filename (fresh-download combo lag).
const nameW = patchLoader.widgets?.find(x => x.name === "name");
if (nameW) {
const { hit, canonical } = pickZImageModelPatch(patchLoader, isBase);
let val = hit;
if (!val) {
val = canonical;
if (nameW.options?.values && !nameW.options.values.includes(val)) nameW.options.values.push(val);
}
if (val) { nameW.value = val; nameW.callback?.(val); }
}
for (const w of zcnNode.widgets || []) {
if (w.name === "strength") { w.value = cfg.strength; w.callback?.(cfg.strength); }
}
for (const w of preNode?.widgets || []) {
if (w.name === "resolution") { w.value = 1024; w.callback?.(1024); }
}
const zcnImageIdx = zcnNode.inputs?.findIndex(i => i.name === "image") ?? -1;
if (preNode) {
sourceNode.connect(0, preNode, preNode.inputs?.findIndex(i => i.name === "image") ?? 0);
preNode.connect(0, zcnNode, zcnImageIdx);
if (preview) preNode.connect(0, preview, 0);
} else {
// Pose-direct: the Poser's true-depth IMAGE feeds the Z-Image control patch.
sourceNode.connect(0, zcnNode, zcnImageIdx);
if (preview) sourceNode.connect(0, preview, 0);
}
patchLoader.connect(0, zcnNode, zcnNode.inputs?.findIndex(i => i.name === "model_patch") ?? 1);
if (vaeSrc?.node) vaeSrc.node.connect(vaeSrc.slot, zcnNode, zcnNode.inputs?.findIndex(i => i.name === "vae") ?? 2);
// Model line: source → ZImageFunControlnet → sampler.
modelSrc.node.connect(modelSrc.slot, zcnNode, zcnNode.inputs?.findIndex(i => i.name === "model") ?? 0);
zcnNode.connect(0, ksNode, ksNode.inputs?.findIndex(i => i.name === "model") ?? 0);
graph.setDirtyCanvas(true, true);
return true;
}
// ── template capture ──────────────────────────────────────────────
export function captureWorkflowGraph(pcNode) {
const graph = app.graph;
if (!graph) return null;
const pcType = pcNode.comfyClass || "PromptChain";
const capturedIds = new Set();
const captured = [];
const downQueue = [pcNode];
const downVisited = new Set([pcNode.id]);
while (downQueue.length) {
const n = downQueue.shift();
for (const output of n.outputs || []) {
if (!output.links?.length) continue;
for (const lid of output.links) {
const link = getLink(graph, lid);
if (!link) continue;
const target = graph.getNodeById(link.target_id);
if (!target || downVisited.has(target.id)) continue;
downVisited.add(target.id);
if (target.comfyClass === pcType) continue;
capturedIds.add(target.id);
captured.push(target);
downQueue.push(target);
}
}
}
const upQueue = [...captured];
while (upQueue.length) {
const n = upQueue.shift();
for (const input of n.inputs || []) {
if (input.link == null) continue;
const link = getLink(graph, input.link);
if (!link) continue;
const source = graph.getNodeById(link.origin_id);
if (!source || capturedIds.has(source.id) || source.id === pcNode.id) continue;
if (source.comfyClass === pcType) continue;
capturedIds.add(source.id);
captured.push(source);
upQueue.push(source);
}
}
if (!captured.length) return null;
const idToIdx = new Map();
captured.forEach((n, i) => idToIdx.set(n.id, i));
const nodes = captured.map(n => ({
type: n.comfyClass || n.type,
offset: [n.pos[0] - pcNode.pos[0], n.pos[1] - pcNode.pos[1]],
size: [n.size[0], n.size[1]],
widgets_values: n.widgets_values ?? n.widgets?.map(w => w.value) ?? [],
properties: { ...(n.properties || {}) },
}));
const connections = [];
for (const n of captured) {
for (const output of n.outputs || []) {
if (!output.links?.length) continue;
for (const lid of output.links) {
const link = getLink(graph, lid);
if (!link) continue;
if (!capturedIds.has(link.target_id)) continue;
connections.push({
from_node_idx: idToIdx.get(n.id),
from_slot: link.origin_slot,
to_node_idx: idToIdx.get(link.target_id),
to_slot: link.target_slot,
});
}
}
}
const anchorConnections = [];
for (const output of pcNode.outputs || []) {
if (!output.links?.length) continue;
for (const lid of output.links) {
const link = getLink(graph, lid);
if (!link || !capturedIds.has(link.target_id)) continue;
anchorConnections.push({
from_slot: link.origin_slot,
to_node_idx: idToIdx.get(link.target_id),
to_slot: link.target_slot,
});
}
}
return { nodes, connections, anchorConnections };
}
// ── resolve resolution ticks for upscale_by slider ────────────────
export function resolveResolutionTicks(detected) {
const RES_TARGETS = [
{ pixels: 1024, label: "1K" }, { pixels: 2048, label: "2K" },
{ pixels: 3072, label: "3K" }, { pixels: 4096, label: "4K" },
{ pixels: 5120, label: "5K" }, { pixels: 6144, label: "6K" },
{ pixels: 7168, label: "7K" }, { pixels: 8192, label: "8K" },
];
let inputLongest = 0;
const ksEntry = detected.find(d =>
d.type === "KSampler" || d.type === "KSamplerAdvanced" || d.type?.includes?.("Sampler")
);
if (ksEntry) {
const latentInput = ksEntry.node.inputs?.find(i => i.name === "latent_image");
if (latentInput?.link) {
const lnk = getLinkInfo(latentInput.link);
const latentNode = lnk ? app.graph.getNodeById(lnk.origin_id) : null;
if (latentNode) {
const wW = latentNode.widgets?.find(w => w.name === "width");
const wH = latentNode.widgets?.find(w => w.name === "height");
if (wW && wH) inputLongest = Math.max(Number(wW.value) || 0, Number(wH.value) || 0);
}
}
}
if (!inputLongest) {
const latEntry = detected.find(d =>
d.type === "EmptyLatentImage" || d.type === "EmptySD3LatentImage" || d.type === "EmptyFlux2LatentImage"
);
if (latEntry) {
const wW = readWidgetValue(latEntry.node, "width");
const wH = readWidgetValue(latEntry.node, "height");
if (wW && wH) inputLongest = Math.max(Number(wW), Number(wH));
}
}
// Upscale-only graphs (LoadImage -> USDU) carry no latent — the upscaler's
// input IMAGE defines the base resolution. The source node's loaded preview
// (node.imgs) is the client-side ground truth for its pixel dimensions.
if (!inputLongest) {
const usEntry = detected.find(d => d.type === "UltimateSDUpscale");
const imgInput = usEntry?.node.inputs?.find(i => i.name === "image");
if (imgInput?.link) {
const lnk = getLinkInfo(imgInput.link);
const src = lnk ? app.graph.getNodeById(lnk.origin_id) : null;
const img = src?.imgs?.[0];
if (img?.naturalWidth) inputLongest = Math.max(img.naturalWidth, img.naturalHeight);
}
}
if (inputLongest <= 0) return null;
const ticks = RES_TARGETS.map(t => ({ value: t.pixels / inputLongest, label: t.label }));
const scale1K = RES_TARGETS[0].pixels / inputLongest;
const scale8K = RES_TARGETS[RES_TARGETS.length - 1].pixels / inputLongest;
const range = scale8K - scale1K;
const padding = range * 0.08;
return { ticks, minBound: Math.max(0.01, scale1K - padding), maxBound: scale8K + padding };
}
// ── Svelte lazy-load + lifecycle ──────────────────────────────────
const ensureCSS = ensureSvelteCSS;
let svelteModule = null;
const loadModule = createModuleLoader(async () => {
svelteModule = await import("./svelte/promptchain-model-settings.js");
return svelteModule;
});
let currentInstance = null;
let currentContainer = null;
// Module-level state preserved across open/close cycles
let _rangeMode = false;
let _activeTab = "settings";
const _expandedSections = {};
let _switchTimer = null;
export function closeModelModal() {
clearTimeout(_switchTimer);
if (currentInstance) {
svelteModule?.destroyModelSettings(currentInstance);
currentInstance = null;
}
if (currentContainer) {
currentContainer.remove();
currentContainer = null;
}
_rangeMode = false;
document.querySelector(".pcr-model-indicator-open")?.classList.remove("pcr-model-indicator-open");
}
export async function showModelModal(pcNode, modelInfo, anchorEl, { tab = null } = {}) {
if (currentInstance) { closeModelModal(); return; }
if (tab) _activeTab = tab;
ensureCSS();
const detected = detectSupportedNodes(pcNode);
let savedConfig = null;
try {
const res = await fetch(`/promptchain/models/settings/${modelInfo.hash}`);
if (res.ok) savedConfig = await res.json();
} catch {}
// Resolve the Z-Image family (base vs turbo) for ControlNet model routing +
// arch-aware cfg. The saved family is often unset (auto-discovered configs
// don't populate it), so fall back to inferring from the filename — Turbo
// checkpoints carry "turbo", base ones don't. null for non-Z-Image models.
const zimageVariant = (() => {
const arch = savedConfig?.architecture || modelInfo.architecture || "";
if (arch !== "zimage") return null;
const fam = savedConfig?.family || "";
if (fam === "zimage_base" || fam === "zimage_turbo") return fam;
const hay = [modelInfo.filename, modelInfo.model_name, savedConfig?.model_name]
.filter(Boolean).join(" ").toLowerCase();
return /turbo/.test(hay) ? "zimage_turbo" : "zimage_base";
})();
const mod = await loadModule();
currentContainer = document.createElement("div");
document.body.appendChild(currentContainer);
currentInstance = mod.mountModelSettings(currentContainer, {
pcNode,
modelInfo,
anchorEl,
detected,
savedConfig,
activeTab: _activeTab,
rangeMode: _rangeMode,
expandedSections: _expandedSections,
onClose: () => closeModelModal(),
onReopen: (opts = {}) => {
// Reopen with optional mode/tab changes
if (opts.rangeMode !== undefined) _rangeMode = opts.rangeMode;
if (opts.tab) _activeTab = opts.tab;
closeModelModal();
showModelModal(pcNode, modelInfo, anchorEl);
},
onSwitchVersion: (ver) => {
if (!ver.filename) return;
swapNodeCheckpointToFilename(pcNode, ver.filename);
closeModelModal();
const newModelInfo = { ...modelInfo, hash: ver.hash, filename: ver.filename };
_switchTimer = setTimeout(() => showModelModal(pcNode, newModelInfo, anchorEl), 50);
},
onShowDownload: (ver) => {
// Newer-version download from the settings panel. The ckpt-widget
// swap is deferred to onBeforeRestart — it fires the moment the
// user commits to restart, not the moment the file lands. That
// way dismissing the modal before restarting leaves the node
// untouched (file is on disk but not active yet).
if (!ver?.file?.filename) return;
const civitaiResult = {
model_name: savedConfig?.model_name || modelInfo.filename || "",
architecture: savedConfig?.architecture || modelInfo.architecture || "",
family: savedConfig?.family || "",
civitai_model_id: savedConfig?.civitai_model_id,
file: ver.file,
};
closeModelModal();
showDownloadModal(civitaiResult, pcNode, anchorEl, {
onBeforeRestart: (filename) => {
swapNodeCheckpointToFilename(pcNode, filename);
},
});
},
onOpenPicker: () => {
closeModelModal();
window.dispatchEvent(new CustomEvent("pcr-open-model-picker", { detail: { nodeId: pcNode.id } }));
},
onApplyStyleLora: (lora, strength) => applyStyleLora(pcNode, lora, strength),
onInjectNode: async (type) => {
// ControlNet items arrive as "ControlNet:<controlType>" (depth/canny/…);
// the base type drives requirements + the install modal.
const isZImageBaseCN = type.startsWith("ZImageControlNetBase:");
const isZImageCN = type.startsWith("ZImageControlNet:");
const isFluxCN = type.startsWith("FluxControlNet:");
const isCN = type.startsWith("ControlNet:");
const controlType = isZImageBaseCN ? type.slice("ZImageControlNetBase:".length)
: isZImageCN ? type.slice("ZImageControlNet:".length)
: isFluxCN ? type.slice("FluxControlNet:".length)
: isCN ? type.slice("ControlNet:".length) : null;
const baseType = isZImageBaseCN ? "ZImageControlNetBase"
: isZImageCN ? "ZImageControlNet"
: isFluxCN ? "FluxControlNet" : isCN ? "ControlNet" : type;
// The injects read modelConfig.architecture to set internal-sampler cfg/
// steps per arch; resolve it the same way the menu gate does so the value
// is present even when the model has no saved settings yet.
const injectConfig = {
...(savedConfig || {}),
architecture: savedConfig?.architecture || modelInfo.architecture || "",
family: savedConfig?.family || zimageVariant || "",
};
const runInject = () => {
const ok = baseType === "RegionalDetailer" ? injectRegionalDetailer(pcNode, false, injectConfig)
: baseType === "PuLID" ? injectPuLID(pcNode)
: baseType === "PuLIDFlux" ? injectPuLIDFlux(pcNode)
: baseType === "PuLIDFlux2" ? injectPuLIDFlux2(pcNode)
: baseType === "StyleReference" ? injectStyleReference(pcNode)
: baseType === "Upscaler" ? injectUpscaler(pcNode, injectConfig)
: baseType === "FluxControlNet" ? injectFluxControlNet(pcNode, controlType)
: baseType === "ZImageControlNet" ? injectZImageControlNet(pcNode, controlType, false)
: baseType === "ZImageControlNetBase" ? injectZImageControlNet(pcNode, controlType, true)
: baseType === "ControlNet" ? (controlType === "regional" ? injectRegional(pcNode, injectConfig) : injectControlNet(pcNode, controlType))
: false;
if (ok) {
closeModelModal();
showModelModal(pcNode, modelInfo, anchorEl);
} else {
app.extensionManager?.toast?.add({
severity: "warn",
summary: `Couldn't add ${baseType}`,
detail: "No compatible attachment point was found in the current workflow (it needs a sampler / VAE Decode to hook into).",
life: 6000,
});
}
};
// Regional needs NO downloaded model or third-party pack — only our own
// AttentionCouple/RegionalConditioning (always registered) plus a 3D Poser
// in the graph (checked inside the inject). Skip the install gate: its
// baseType is "ControlNet", whose models are the xinsir SDXL nets, so a
// Flux/Z-Image user would otherwise be prompted to fetch SDXL checkpoints
// the regional terminal never loads.
if (controlType === "regional") { runInject(); return; }
// Is the required pack fully installed before we build? Ask the server,
// which knows BOTH whether the proof nodes are registered AND whether the
// model weights are on disk. The client can only see registered node
// types, so a present-nodes / missing-model pack (e.g. a fresh Flux
// ControlNet whose preprocessors are already installed) would otherwise
// inject with a wrong fallback model. After install + restart we
// re-register node defs so LiteGraph knows new types without a reload.
// Some injectables need ComfyUI-core nodes a pack can't provide (Z-Image's
// ModelPatchLoader/ZImageFunControlnet). If those are absent the only fix
// is updating ComfyUI — say so instead of offering a pack install.
const coreNodes = INJECT_REQUIREMENTS[baseType]?.coreNodes;
if (coreNodes) {
const LG = window.LiteGraph;
const missingCore = coreNodes.filter(n => !LG?.registered_node_types?.[n]);
if (missingCore.length) {
closeModelModal();
app.extensionManager?.toast?.add({
severity: "warn",
summary: "Update ComfyUI to enable Z-Image ControlNet",
detail: `Missing built-in nodes: ${missingCore.join(", ")}. Update ComfyUI to a recent version.`,
life: 8000,
});
return;
}
}
if (INJECT_REQUIREMENTS[baseType]) {
let status = null;
try {
const res = await fetch(`/promptchain/nodepacks/status?injectable=${encodeURIComponent(baseType)}`);
if (res.ok) status = await res.json();
} catch (e) {
console.error("[PromptChain] pack status check failed", e);
}
// Server doesn't recognise this pack — almost always a new pack added
// to node_install_api.py with the server not yet restarted. Injecting
// now would load a wrong/fallback model, so stop and say so instead of
// building a broken graph.
if (!status || typeof status.present !== "boolean") {
closeModelModal();
app.extensionManager?.toast?.add({
severity: "warn",
summary: `Restart ComfyUI to enable ${baseType}`,
detail: "This injectable was just added — restart the ComfyUI server so it can install and load.",
life: 7000,
});
return;
}
if (!status.present) {
closeModelModal();
showNodePackInstallModal(baseType, async () => {
try {
const defs = await app.getNodeDefs();
await app.registerNodesFromDefs(defs);
} catch (e) {
console.error("[PromptChain] node def refresh failed", e);
}
runInject();
});
return;
}
}
runInject();
},
onApplyTemplate: (tpl) => {
tpl._presetNodes = savedConfig?.nodes || {};
applyTemplate(pcNode, tpl, modelInfo.filename);
closeModelModal();
},
captureWorkflowGraph: () => captureWorkflowGraph(pcNode),
// Graph interaction callbacks
readWidgetValue: (node, name) => readWidgetValue(node, name),
readWidgetOptions: (node, name) => readWidgetOptions(node, name),
writeWidgetValue: (node, name, value) => writeWidgetValue(node, name, value),
resolveResolutionTicks: () => resolveResolutionTicks(detected),
// Check what injectable nodes are missing
injectableNodes: () => {
const downstream = findDownstreamNodes(pcNode);
const hasPuLID = downstream.some(n =>
n.comfyClass === "ApplyPulidAdvanced" || n.comfyClass === "ApplyPulid"
) || app.graph?._nodes?.some(n =>
(n.comfyClass === "ApplyPulidAdvanced" || n.comfyClass === "ApplyPulid")
);
const hasPuLIDFlux = downstream.some(n => n.comfyClass === "ApplyPulidFlux")
|| app.graph?._nodes?.some(n => n.comfyClass === "ApplyPulidFlux");
const hasPuLIDFlux2 = downstream.some(n => n.comfyClass === "ApplyPuLIDFlux2")
|| app.graph?._nodes?.some(n => n.comfyClass === "ApplyPuLIDFlux2");
const hasUpscaler = detected.some(d => d.type === "UltimateSDUpscale")
|| downstream.some(n => n.comfyClass === "UltimateSDUpscale");
const items = [];
const arch = savedConfig?.architecture || modelInfo.architecture || "";
const family = savedConfig?.family || "";
// FaceDetailer/Upscaler splice into a KSampler chain, so offer them only
// when the workflow actually has a KSampler to hook — graph truth rather
// than an arch guess. This correctly enables them on Flux (Dev/Krea and
// Z-Image templates are KSampler-based; the injects already handle split
// DualCLIP/VAE loaders) while hiding them on KSampler-less pivots like
// Flux 2 (SamplerCustomAdvanced + BasicGuider). Internal sampler cfg/steps
// are set per-architecture inside the injects. PuLID here is the SDXL/SD1.5
// cubiq variant; Flux needs the separate balazik ApplyPulidFlux (Task 2).
const hasKSampler = downstream.some(n =>
n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced"
);
// The Upscaler inject also anchors on Flux 2's SamplerCustomAdvanced +
// guider split; the other one-shots still need a real KSampler.
const hasCustomSampler = downstream.some(n => n.comfyClass === "SamplerCustomAdvanced");
// On a regional graph (attention couple present), FaceDetailer means the
// RegionalDetailer: same one-click detail pass, but each face gets its own
// character's prompt instead of the one global — a plain FaceDetailer
// would strip the identities the couple paints. No Impact pack needed.
// Face detailing is always our RegionalDetailer: own YOLO detector +
// global-prompt fallback = plain FaceDetailer behaviour without the Impact
// pack, and on a regional graph it matches each face to its figure mask.
// Offered until one exists; injecting it also swaps out any Impact
// FaceDetailer already in the graph (the inject removes it).
const hasRegional = downstream.some(n => n.comfyClass === "PromptChain_RegionalDetailer");
if (!hasRegional && hasKSampler) items.push({ label: "FaceDetailer", value: "RegionalDetailer" });
if (!hasPuLID && (arch === "sdxl" || arch === "sd15")) items.push("PuLID");
// Flux PuLID (balazik) is FLUX.1-dev-trained — offer on Dev/Krea, not the
// separate Fill UNET or distilled Schnell. Mirrors the Flux ControlNet gate.
if (!hasPuLIDFlux && arch === "flux" && family !== "flux_fill" && family !== "flux_schnell") items.push({ label: "PuLID", value: "PuLIDFlux" });
// Flux.2 PuLID (iFayens). The native weights are Klein-trained — on Dev
// the dim mismatch swaps in a randomly-initialized projection (noise),
// so offer it on the Klein family only.
if (!hasPuLIDFlux2 && arch === "flux2" && family === "flux2_klein") items.push({ label: "PuLID", value: "PuLIDFlux2" });
// Style Reference = IPAdapter style transfer. SDXL only — the installer
// fetches the vit-h PLUS adapter, which the UnifiedLoader only matches
// against SDXL checkpoints.
const hasStyleRef = downstream.some(n => n.comfyClass === "IPAdapterAdvanced")
|| app.graph?._nodes?.some(n => n.comfyClass === "IPAdapterAdvanced");
if (!hasStyleRef && arch === "sdxl" && hasKSampler) items.push({ label: "Style Reference", value: "StyleReference" });
if (!hasUpscaler && (hasKSampler || hasCustomSampler)) items.push("Upscaler");
// ControlNet (SDXL only for now; Flux/Z-Image use different model families).
// Always offered — unlike the one-shots, branches stack (depth+canny+pose).
if (arch === "sdxl") {
items.push({
label: "ControlNet",
children: [
{ label: "Depth", value: "ControlNet:depth" },
{ label: "Depth + 3D pose", value: "ControlNet:depth-pose" },
{ label: "Regional (Attention Couple)", value: "ControlNet:regional" },
{ label: "Canny", value: "ControlNet:canny" },
{ label: "Pose", value: "ControlNet:pose" },
{ label: "Lineart", value: "ControlNet:lineart" },
{ label: "Soft Edge", value: "ControlNet:softedge" },
{ label: "Scribble", value: "ControlNet:scribble" },
// Tile intentionally omitted: it only does something useful inside
// an upscale re-render (which the Upscaler injectable already tiles),
// not as a standalone control on the base generation. Future home is
// a toggle in the Upscaler inject. CONTROLNET_TYPES.tile kept for that.
],
});
}
// Flux ControlNet (Shakker Union-Pro-2.0). FLUX.1-dev-trained, so offered
// on Dev/Krea only — excluded on flux_fill (separate inpaint UNET) and
// flux_schnell (distilled, degraded), and flux2 is a different arch.
if (arch === "flux" && family !== "flux_fill" && family !== "flux_schnell") {
items.push({
label: "ControlNet",
children: [
{ label: "Depth", value: "FluxControlNet:depth" },
{ label: "Depth + 3D pose", value: "FluxControlNet:depth-pose" },
{ label: "Canny", value: "FluxControlNet:canny" },
{ label: "Pose", value: "FluxControlNet:pose" },
{ label: "Soft Edge", value: "FluxControlNet:softedge" },
{ label: "Gray", value: "FluxControlNet:gray" },
],
});
}
// Krea 2 has no working regional path: it's a Flux.1-dev finetune, and
// Flux/DiT models don't honour ComfyUI's native cond-area masks (the only
// regional engine available to krea2). GPU-tested — "a cat"/"a dog" boxes
// smeared onto the main subject instead of separating. A real krea2
// regional would need a Flux attention-couple node (like ZImageRegionalCouple),
// which doesn't exist; until then Regional is intentionally NOT offered.
// Z-Image ControlNet (DiffSynth model-patch path; core nodes + aux
// preprocessors). The control model is family-specific: Turbo's is
// step-distilled, base's isn't, and they're NOT cross-compatible — so a
// base node routes to the ZImageControlNetBase pack/model, everything else
// (turbo, or family unset) to the Turbo one.
if (arch === "zimage") {
const cnPrefix = zimageVariant === "zimage_base" ? "ZImageControlNetBase" : "ZImageControlNet";
items.push({
label: "ControlNet",
children: [
{ label: "Depth", value: `${cnPrefix}:depth` },
{ label: "Depth + 3D pose", value: `${cnPrefix}:depth-pose` },
{ label: "Regional", value: "ControlNet:regional" },
{ label: "Canny", value: `${cnPrefix}:canny` },
{ label: "Pose", value: `${cnPrefix}:pose` },
{ label: "Soft Edge", value: `${cnPrefix}:softedge` },
{ label: "Scribble", value: `${cnPrefix}:scribble` },
{ label: "Gray", value: `${cnPrefix}:gray` },
],
});
}
return items;
},
onShowCatalogDownload: (entry, initialPrecision) => {
closeModelModal();
showCatalogDownloadModal(entry, pcNode, anchorEl, initialPrecision);
},
// Editor reference for prompts tab
getEditor: () => pcNode._pcrEditor,
});
}
registerModelModal(showModelModal, closeModelModal);