- 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.
3163 lines
146 KiB
JavaScript
3163 lines
146 KiB
JavaScript
// 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);
|