Files
chrmc40 e4b970d2b6 Publish snapshot: image editor suite, Krea 2 templates, sidebar fixes
- Image editor: layers with masks/blend modes, lightweight groups, Camera Raw
  scoped to the selected layer, full-frame i2i, Copy Merged, Photoshop keyboard
  shortcuts, and a large batch of Photoshop-parity bug fixes.
- LoadImage right-click "Open in PromptChain editor" bridge (edit -> lineage
  child re-pointed onto the node).
- Krea 2 Turbo/Realism templates and editor modal integration; remove the
  krea2 Text-to-Image 3D Regional template.
- ControlNet depth + 3D-pose: wire Poser true-depth straight into Apply
  ControlNet on pose paths.
- Sidebar: drag-to-LoadImage now uploads to the input root so the widget value
  is a valid bare filename; "Copy as path" emits native Windows backslashes.
- Contributor/relicensing docs and packaging tweaks.
2026-06-28 11:58:31 -07:00

2511 lines
124 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Viewer "Upscale": rebuild a ready-to-queue upscale graph from a finished
// image's embedded workflow metadata.
//
// Strategy is graft, not re-derive: load the embedded workflow into a new tab,
// let the existing injectUpscaler do the case handling it already knows
// (regional conditioning, ControlNet slot-tracing, split Flux loaders, saved
// per-model USDU settings), then shape the conditioning to the mode the user
// picked in the upscale modal, prune the graph down to the upscale core, and
// feed it a content-addressed input-dir copy of the viewed image. Images
// without usable metadata fall back to a plain ESRGAN pass, which works on
// anything.
//
// Modes (picked in the viewer's upscale modal, defaulted from what the source
// workflow actually used):
// prompt — USDU re-details tiles with the image's prompt only.
// depth — prompt + one depth ControlNet keeping structure pinned. Hint
// source is the workflow's 3D pose render or the image itself.
// regional — per-figure regional conditioning + 3D pose depth, for
// multi-character scenes.
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { getLink } from "./slot-utils.js";
import { offscreenIdBase } from "./offscreen-graph.js";
import {
injectUpscaler,
buildRegionalUpscaleCond,
setControlNetModel,
CONTROLNET_TYPES,
resolvePre,
hasPre,
} from "./model-settings-bridge.js";
const EMPTY_WORKFLOW = { last_node_id: 0, last_link_id: 0, nodes: [], links: [], groups: [], config: {}, extra: {}, version: 0.4 };
function toast(severity, detail) {
app.extensionManager?.toast?.add({ severity, summary: "Upscale", detail, life: 6000 });
}
// Build failures die in the browser console where remote debugging can't see
// them — relay to the server log (best-effort; the route may not exist until
// the server restarts after deploy).
export function reportClientError(where, e, context = null) {
try {
const ctx = context ? ` ${JSON.stringify(context)}` : "";
api.fetchApi("/promptchain/client-error", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
where,
message: `${e?.message || String(e)}${ctx}`,
stack: e?.stack || "",
}),
}).catch(() => {});
} catch { /* reporting must never break the flow it reports on */ }
}
// Diagnostic: log exactly what an upscale Apply submitted, into comfyui.log
// (as "[upscale-run]") so a run's model/denoise/recipe is recoverable without
// reverse-engineering the output PNG. apiPrompt is app.graphToPrompt().output.
export function logUpscaleRun(apiPrompt, engine) {
try {
if (!apiPrompt || typeof apiPrompt !== "object") return;
const vals = Object.values(apiPrompt);
const find = (pred) => vals.find(pred) || null;
const unet = find((n) => n.class_type === "UNETLoader")?.inputs?.unet_name
|| find((n) => n.class_type === "CheckpointLoaderSimple" || n.class_type === "CheckpointLoader")?.inputs?.ckpt_name
|| null;
const us = find((n) => (n.class_type || "").includes("UltimateSDUpscale"))?.inputs
|| find((n) => n.class_type === "KSampler" || n.class_type === "PromptChain_MaskedDetail")?.inputs
|| {};
const climb = find((n) => n.class_type === "UpscaleModelLoader")?.inputs?.model_name || null;
const vae = find((n) => n.class_type === "VAELoader")?.inputs?.vae_name
|| find((n) => n.class_type === "VAEUtils_CustomVAELoader")?.inputs?.vae_name || null;
const tile = us.tile_width != null ? `${us.tile_width}x${us.tile_height}` : null;
const summary = {
engine: engine || "?", model: unet, vae, climb,
denoise: us.denoise, cfg: us.cfg, steps: us.steps,
sampler: us.sampler_name, scheduler: us.scheduler,
tile, upscale_by: us.upscale_by, seam: us.seam_fix_mode, mode: us.mode_type,
};
reportClientError("upscale-run",
{ message: `engine=${engine} model=${unet} vae=${vae} denoise=${us.denoise} cfg=${us.cfg} steps=${us.steps} sampler=${us.sampler_name}/${us.scheduler} tile=${tile} upscale_by=${us.upscale_by} seam=${us.seam_fix_mode} climb=${climb}` },
summary);
} catch { /* never break the run */ }
}
function freshWorkflowId() {
if (globalThis.crypto?.randomUUID) return crypto.randomUUID();
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
}
function traceInput(graph, 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;
}
function inputIdx(node, name, fallback) {
const idx = node.inputs?.findIndex((i) => i.name === name) ?? -1;
return idx >= 0 ? idx : fallback;
}
// ---------------------------------------------------------------------------
// Capability analysis — what the embedded workflow can support, computed from
// the serialized JSON (before any graph is loaded) so the modal can offer and
// default the right modes.
// New depth branches need the DepthAnything preprocessor registered and an
// SDXL union ControlNet in the loader's combo. Probing an off-graph loader
// instance is how the injects read combo options too.
function depthAssetsAvailable() {
try {
const LG = window.LiteGraph;
if (!hasPre("DepthAnythingV2Preprocessor")) return false;
const probe = LG.createNode("ControlNetLoader");
const opts = probe?.widgets?.find((w) => w.name === "control_net_name")?.options?.values || [];
return opts.some((o) => /union/i.test(o));
} catch {
return false;
}
}
// Default destination = the source workflow's own save prefix with _upscale
// appended, so upscales land right beside the gens they came from. Guarded so
// re-upscaling an upscale doesn't stack suffixes.
function deriveUpscalePrefix(base) {
if (!base) return "upscale/upscale";
return base.endsWith("_upscale") ? base : `${base}_upscale`;
}
// Where the source workflow saves: the SaveImage fed by the USDU when one
// exists (its prefix is already a final destination), else the one on the
// detailer/decode output — the same anchor injectUpscaler inherits from.
// Exported for the inpaint graft, which derives its own _inpaint suffix.
export function sourceSavePrefixInfo(workflow) {
const nodes = workflow?.nodes || [];
const links = workflow?.links || [];
const byId = new Map(nodes.map((n) => [n.id, n]));
const saveFedBy = (node) => {
if (!node) return null;
for (const l of links) {
const origin = Array.isArray(l) ? l[1] : l?.origin_id;
const target = Array.isArray(l) ? l[3] : l?.target_id;
if (origin !== node.id) continue;
const t = byId.get(target);
if (t?.type === "SaveImage" && typeof t.widgets_values?.[0] === "string") return t.widgets_values[0];
}
return null;
};
const usdu = nodes.find((n) => n.type === "UltimateSDUpscale");
if (usdu) {
const p = saveFedBy(usdu);
if (p) return { prefix: p, fromUpscaler: true };
}
const src = nodes.find((n) => n.type === "PromptChain_RegionalDetailer")
|| nodes.find((n) => n.type === "FaceDetailer")
|| nodes.find((n) => n.type === "VAEDecode");
return { prefix: saveFedBy(src), fromUpscaler: false };
}
function sourceSavePrefix(workflow) {
const { prefix, fromUpscaler } = sourceSavePrefixInfo(workflow);
if (fromUpscaler && prefix) return prefix;
return deriveUpscalePrefix(prefix);
}
export function analyzeUpscaleCaps(data) {
const nodes = data.workflow?.nodes || [];
const has = (t) => nodes.some((n) => n.type === t);
// The plain-pass engine choice (UltraSharp vs SeedVR2 restore-first) only
// renders when the SeedVR2 pack is actually installed.
const seedvr2Available = !!window.LiteGraph?.registered_node_types?.["SeedVR2VideoUpscaler"];
// High-Detail krea2 renders decode through VAEUtils_VAEDecodeTiled (spacepxl),
// not the stock VAEDecode — still a graftable sampler->decode pipeline, so
// accept the tiled decoders too or those renders fall to plain ESRGAN only.
const graftable = has("UltimateSDUpscale") || (
has("PromptChain_PromptChain")
&& (has("KSampler") || has("KSamplerAdvanced") || has("SamplerCustomAdvanced"))
&& (has("VAEDecode") || has("VAEUtils_VAEDecodeTiled") || has("VAEDecodeTiled"))
);
if (!graftable) {
return {
graftable: false,
modes: { prompt: { ok: false }, depth: { ok: false }, regional: { ok: false } },
sources: { pose: { ok: false }, image: { ok: false } },
conditions: { ipadapter: { ok: false, installable: false }, pulid: { ok: false, installable: false } },
defaultMode: "prompt", defaultDepthSource: "image",
defaultSavePrefix: "upscale/upscale",
seedvr2Available,
};
}
// pose_state is the PoseStudio node's second widget (control_map, pose_state,
// width, height) — non-empty means the frontend can re-render the maps.
const poseNode = nodes.find((n) => n.type === "PromptChain_PoseStudio");
const poseState = typeof poseNode?.widgets_values?.[1] === "string" ? poseNode.widgets_values[1] : "";
const hasPose = poseState.trim().length > 2;
const hasCouple = has("PromptChain_AttentionCouple") || has("PromptChain_ZImageRegionalCouple");
const hasRegionalNodes = hasCouple || has("PromptChain_RegionalConditioning");
// Depth mode builds an SDXL union ControlNet (ensureDepthBranch) — only valid when
// the source already HAS a classic depth apply, OR we can build one on SDXL. Do NOT
// light it up just because the DepthAnything preprocessor is installed (that would
// offer depth for Z-Image/Flux, whose models reject an SDXL ControlNet → "y is None").
const hasDepthCN = nodes.some((n) => n.type === "ControlNetApplyAdvanced" && n.properties?.pcrControlType === "depth");
// Split loaders (UNETLoader) mean Flux/other — the SDXL union depth cluster
// would load the wrong net there, so only keep-existing depth is allowed.
const isSdxl = has("CheckpointLoaderSimple") || has("CheckpointLoader");
// Plain prompt conditioning exists as text encodes (any CLIPTextEncode
// variant) OR an Attention Couple: regional builds have no encode nodes —
// the couple encodes clip+regions itself, and its positive/negative outputs
// are the global prompt (the per-figure logic rides the patched MODEL,
// which the upscaler never takes).
const hasTextEncode = nodes.some((n) => typeof n.type === "string" && n.type.startsWith("CLIPTextEncode")) || hasCouple;
const assets = depthAssetsAvailable();
const canBuildDepth = isSdxl && assets;
const modes = {
prompt: hasTextEncode
? { ok: true }
: { ok: false, reason: "This image's workflow only carries regional conditioning — no plain prompt to upscale with." },
depth: (hasDepthCN || canBuildDepth)
? { ok: true }
: { ok: false, reason: isSdxl
? "Needs the ControlNet preprocessor pack and an SDXL union ControlNet model."
: "Depth injection isn't wired for this model family yet." },
// Regional upscale grafts the SDXL union depth ControlNet (ensureDepthBranch),
// so it's SDXL-only; non-SDXL regional sources fall back to the prompt pass.
regional: (hasRegionalNodes && hasPose && isSdxl)
? { ok: true }
: { ok: false, reason: !isSdxl
? "Regional-aware upscale isn't wired for this model family yet — use the prompt pass."
: hasRegionalNodes
? "No 3D pose in this image's workflow."
: "The image wasn't generated with regional conditioning." },
};
const sources = {
pose: { ok: hasPose },
image: { ok: canBuildDepth || hasDepthCN },
};
const defaultMode =
(modes.regional.ok && hasRegionalNodes) ? "regional"
: (modes.depth.ok && hasDepthCN) ? "depth"
: modes.prompt.ok ? "prompt"
: modes.depth.ok ? "depth" : "regional";
const defaultDepthSource = hasPose ? "pose" : "image";
// Identity/style lock for the re-detail pass (SDXL only — same gate as the
// inpaint conditions; Flux PuLID is a different node family, not wired here).
const reg = window.LiteGraph?.registered_node_types || {};
const condReason = !isSdxl
? "SDXL checkpoints only for now."
: "Install the pack first (model panel → add).";
// ok = usable now (engine supports it AND its pack is registered).
// installable = engine supports it but the pack isn't installed yet, so the
// modal can OFFER it and install on apply (instead of hiding it).
const conditions = {
ipadapter: { ok: isSdxl && !!reg["IPAdapterAdvanced"] && !!reg["IPAdapterUnifiedLoader"], installable: isSdxl, reason: condReason },
pulid: { ok: isSdxl && !!reg["ApplyPulidAdvanced"], installable: isSdxl, reason: condReason },
};
return {
graftable: true, modes, sources, conditions, defaultMode, defaultDepthSource,
defaultSavePrefix: sourceSavePrefix(data.workflow),
seedvr2Available,
};
}
// ---------------------------------------------------------------------------
// Engine picker — the modal can re-detail with a model the user picks instead
// of the image's source model. Per-architecture prompt-push policy: sdxl
// engines prefill the image's own prompt (user spec), qwen_edit engines
// prefill a fixed enhance instruction.
// Tile prompts are PAINT: any texture noun gets synthesized onto every tile
// ("fabric weave" once cross-hatched a whole face; negating "smooth" does the
// same from the other side). The anti-grid terms ride the negative instead.
export const SDXL_TILE_NEUTRAL_POSITIVE = "photograph, photorealistic, sharp focus, high detail, natural skin";
export const SDXL_TILE_NEGATIVE = "fabric texture, canvas texture, cross-hatch, moire, grid pattern, blurry, lowres, jpeg artifacts, watermark";
export const QWEN_ENHANCE_INSTRUCTION = "Enhance this image: increase quality, sharpness and fine detail. Keep the composition, subjects, colors and lighting exactly the same.";
// The reference template runs 2.5MP, but Qwen Edit grows plastic skin past
// ~1920px on the long edge — 2MP keeps common aspect ratios under that line.
export const QWEN_EDIT_STAGE_MP = 2.0;
// FLUX.1 tile regime (GPU-proven on Krea): guidance rides FluxGuidance, cfg
// stays 1.0, NO tile ControlNet — flux1's schedule shift is mild enough that
// low-denoise tiles hold structure on their own (only FLUX.2's shift-as-mu
// breaks tiling).
export const FLUX1_TILE_GUIDANCE = 3.0;
function comboProbe(nodeType, widgetName) {
try {
const probe = window.LiteGraph?.createNode?.(nodeType);
return probe?.widgets?.find((w) => w.name === widgetName)?.options?.values || [];
} catch {
return [];
}
}
// What the non-source engines can actually build on this install — probed off
// registered node defs and loader combos (never /object_info status: unknown
// classes return 200 {}).
export function engineAssetSupport() {
const reg = window.LiteGraph?.registered_node_types || {};
const esrgan = comboProbe("UpscaleModelLoader", "model_name").length > 0;
const tileCn = comboProbe("ControlNetLoader", "control_net_name")
.some((o) => /tile/i.test(o) && /sdxl/i.test(o) && !/flux/i.test(o));
const sdxl = !!reg["UltimateSDUpscaleNoUpscale"] && !!reg["ControlNetApplyAdvanced"] && tileCn && esrgan;
const qwen = !!reg["TextEncodeQwenImageEditPlus"] && !!reg["FluxKontextMultiReferenceLatentMethod"]
&& !!reg["ModelSamplingAuraFlow"] && !!reg["CFGNorm"] && esrgan
&& comboProbe("CLIPLoader", "clip_name").some((o) => /qwen.*2[._]5.*vl/i.test(o))
&& comboProbe("VAELoader", "vae_name").some((o) => /qwen.*image.*vae/i.test(o));
const dualClips = comboProbe("DualCLIPLoader", "clip_name1");
const flux1 = !!reg["UltimateSDUpscaleNoUpscale"] && !!reg["FluxGuidance"] && esrgan
&& dualClips.some((o) => /clip_l/i.test(o))
&& dualClips.some((o) => /t5xxl/i.test(o))
&& comboProbe("VAELoader", "vae_name").some((o) => /(^|[\\/])ae\.(safetensors|sft)$/i.test(o));
const zimage = !!reg["UltimateSDUpscaleNoUpscale"] && !!reg["ModelSamplingAuraFlow"] && esrgan
&& comboProbe("UNETLoader", "unet_name").some((o) => /z.?image/i.test(o))
&& comboProbe("CLIPLoader", "clip_name").some((o) => /qwen.*3[._]4b|lumina2/i.test(o))
&& comboProbe("VAELoader", "vae_name").some((o) => /(^|[\\/])ae\.(safetensors|sft)$/i.test(o));
// Krea 2 (DiT, mirrors zimage but no ModelSamplingAuraFlow — shift is baked
// into the model class): krea2 UNET + qwen3vl_4b text encoder + qwen_image_vae.
const krea2 = !!reg["UltimateSDUpscaleNoUpscale"] && esrgan
&& comboProbe("UNETLoader", "unet_name").some((o) => /krea2/i.test(o))
&& comboProbe("CLIPLoader", "clip_name").some((o) => /qwen3vl.*4b|qwen3.?vl.*4b/i.test(o))
&& comboProbe("VAELoader", "vae_name").some((o) => /qwen.*image.*vae/i.test(o));
return { sdxl, qwen, flux1, zimage, krea2 };
}
// The modal's defaults for a picked engine model. SDXL: the CN-locked tile
// recipe — saved per-model USDU denoise/sampler/seam values are deliberately
// NOT inherited (they were tuned for prompt-driven USDU without a tile CN;
// 0.25 + SDE in this regime stipples fake pores), only shape knobs carry
// over. Qwen: the template recipe; denoise stays 1.0 — the conditioning image
// is the anchor, partial denoise just blends the sub-pixel-shifted re-render
// with the original.
function engineModelDefaults(architecture, config) {
const nodes = config?.nodes || {};
if (architecture === "qwen_edit") {
const ks = nodes.KSampler || {};
return {
denoise: 1.0,
sampler: "euler",
scheduler: "simple",
advanced: { steps: ks.steps ?? 40, cfg: ks.cfg ?? 4 },
};
}
if (architecture === "zimage") {
// Family from the filename: Turbo is step-distilled (cfg 1.3, ~8 steps,
// 0.7 denoise — low denoise truncates its sigma schedule to a tail the
// few-step field can't resolve, so it blotches); Base is non-distilled
// (cfg 3.0, more steps) and re-details cleanly at a low-denoise tail.
const turbo = /turbo|8steps/i.test(config?.filename || "");
const usdu = nodes.UltimateSDUpscale || {};
return {
denoise: turbo ? 0.7 : 0.15,
denoiseMax: turbo ? 0.8 : 0.4,
// Base (non-distilled) wants a stochastic sampler for real skin texture;
// res_multistep (Turbo's few-step sampler) sterilizes base skin.
sampler: turbo ? "res_multistep" : "dpmpp_sde",
scheduler: "beta",
advanced: {
steps: usdu.steps ?? (turbo ? 8 : 20),
cfg: turbo ? 1.3 : 3.0,
tile_width: usdu.tile_width ?? 1024,
tile_height: usdu.tile_height ?? 1024,
mask_blur: 32,
tile_padding: 64,
seam_fix_mode: "None",
},
};
}
if (architecture === "flux") {
const usdu = nodes.UltimateSDUpscale || {};
// cfg is pinned at 1.0 (never inherited): flux1 guidance rides
// FluxGuidance — a saved gen cfg would double sampling time and burn.
return {
denoise: 0.2,
denoiseMax: 0.4,
sampler: "euler",
scheduler: "simple",
advanced: {
steps: usdu.steps ?? 20,
cfg: 1.0,
tile_width: usdu.tile_width ?? 1024,
tile_height: usdu.tile_height ?? 1024,
mask_blur: 32,
tile_padding: 64,
seam_fix_mode: "None",
},
};
}
if (architecture === "krea2") {
// Krea 2 Turbo is step-distilled (cfg 1.0, 8 steps), cfg native (no
// FluxGuidance/ModelSampling). TILE denoise stays LOW: a tile routine needs
// each tile anchored to the source or adjacent tiles regenerate different
// content and won't stitch (visible seams). The catch — at low denoise the
// distilled few-step field under-resolves the tile (some residual voronoi/
// blotch on smooth skin; NOT a VAE artifact, persists through a clean
// wan_2.1_vae decode). Turbo can't fully win either way in a tile pass; the
// non-distilled RAW krea2 is the proper clean+coherent tile upscaler.
// Turbo defaults to euler+simple (the official Krea recipe; suits the smooth
// matte-PVC figurine finish). RAW keeps er_sde (grain-preserving for photoreal).
const usdu = nodes.UltimateSDUpscale || {};
// RAW = the undistilled base checkpoint. Non-distilled, so it resolves a LOW
// denoise tile cleanly (no blotch) while staying anchored to the source (no
// seams) — the proper clean+coherent tiled upscaler. It runs a REAL cfg +
// full steps; Turbo stays the soft-but-coherent 0.45 / cfg 1.0 / 8-step
// compromise. (Same wan_2.1_vae decode + qwen3vl CLIP either way.)
const fn = config?.filename || "";
const raw = /raw/i.test(fn) && !/turbo/i.test(fn);
return {
denoise: raw ? 0.35 : 0.45,
// No tile ControlNet exists for Krea 2 (standalone DiT, not Flux), so each tile
// is anchored only by the img2img floor — push denoise too far and a tile that
// contains the whole subject regenerates it (doubling). Cap to the CN-less safe
// band; Turbo can't go much below 0.45 without blotching, so 0.45/0.5 is its
// narrow usable window. (Stronger re-detail without doubling needs smaller tiles.)
denoiseMax: raw ? 0.45 : 0.5,
sampler: raw ? "er_sde" : "euler",
scheduler: "simple",
advanced: {
steps: usdu.steps ?? (raw ? 30 : 8),
cfg: raw ? 3.5 : 1.0,
tile_width: usdu.tile_width ?? 1024,
tile_height: usdu.tile_height ?? 1024,
mask_blur: 32,
tile_padding: 64,
mode_type: "Chess", // checkerboard order — neighbours redraw before a tile's seam (cleaner than Linear)
seam_fix_mode: raw ? "Half Tile" : "None",
// RAW's Half-Tile seam pass must NOT full-repaint (the node default is 1.0, which
// makes seam bands hallucinate content that won't match the lightly-detailed tiles).
seam_fix_denoise: raw ? 0.3 : undefined,
},
};
}
const usdu = nodes.UltimateSDUpscale || {};
return {
denoise: 0.15,
denoiseMax: 0.4,
sampler: "dpmpp_2m",
scheduler: "karras",
advanced: {
steps: usdu.steps ?? 20,
cfg: usdu.cfg ?? 5,
tile_width: usdu.tile_width ?? 1024,
tile_height: usdu.tile_height ?? 1024,
mask_blur: 32,
tile_padding: 64,
seam_fix_mode: "None",
},
};
}
// Different files of the same model (fp8 vs bf16 vs GGUF quants) share one
// saved display name — the precision token off the filename tells them apart
// in the picker.
function precisionTag(filename) {
const base = String(filename || "").toLowerCase();
const m = base.match(/(fp8[a-z0-9]*(?:_scaled)?|bf16|fp16|fp32|nf4|int8|q[0-9]_[k0](?:_[sml])?|q[0-9])/);
if (m) return m[1];
if (/\.gguf$/.test(base)) return "gguf";
return "";
}
// Engine-model candidates for the pickers (upscale modal + inpaint modal):
// every installed sdxl / flux1 / qwen_edit model, with display names,
// per-model tile defaults, and the model's own gen-time KSampler settings
// folded in.
export async function fetchEngineModels() {
try {
const listRes = await api.fetchApi("/promptchain/models/list");
if (!listRes.ok) return [];
const models = ((await listRes.json())?.models || [])
.filter((m) => m.architecture === "sdxl" || m.architecture === "qwen_edit"
|| m.architecture === "zimage" || m.architecture === "krea2"
// flux = the whole FLUX.1 family; edit/control/util variants don't
// belong in a faithful tile pass (kontext re-renders, schnell is
// step-distilled, fill/canny/depth/redux are tool models).
|| (m.architecture === "flux" && !/kontext|schnell|fill|canny|depth|redux/i.test(m.filename)));
if (!models.length) return [];
let settings = {};
try {
const bulkRes = await api.fetchApi("/promptchain/models/settings/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hashes: models.map((m) => m.hash) }),
});
if (bulkRes.ok) settings = (await bulkRes.json())?.settings || {};
} catch { /* display names + saved tweaks are optional */ }
const list = models.map((m) => {
const config = settings[m.hash] || {};
const ks = config?.nodes?.KSampler || {};
const tag = config.display_name ? precisionTag(m.filename) : "";
return {
hash: m.hash,
filename: m.filename,
architecture: m.architecture,
displayName: config.display_name
? (tag ? `${config.display_name} (${tag})` : config.display_name)
: m.filename,
defaults: engineModelDefaults(m.architecture, { ...config, filename: m.filename }),
gen: {
sampler: ks.sampler_name || null,
scheduler: ks.scheduler || null,
steps: ks.steps ?? null,
cfg: ks.cfg ?? null,
},
};
});
// Still-colliding labels (same name, same precision) fall back to the
// filename — the picker must never show two indistinguishable entries.
const labelCounts = new Map();
for (const e of list) labelCounts.set(e.displayName, (labelCounts.get(e.displayName) || 0) + 1);
for (const e of list) {
if (labelCounts.get(e.displayName) > 1) e.displayName = `${e.displayName} — ${e.filename}`;
}
return list;
} catch {
return [];
}
}
// Saved per-model USDU settings + architecture, resolved from the workflow
// JSON's loader filename — gives the modal the same defaults the build itself
// will use.
export async function fetchSavedUsduSettings(workflow) {
const nodes = workflow?.nodes || [];
const loader = nodes.find((n) => n.type === "CheckpointLoaderSimple" || n.type === "CheckpointLoader")
|| nodes.find((n) => n.type === "UNETLoader" || n.type === "UnetLoaderGGUF");
const file = typeof loader?.widgets_values?.[0] === "string" ? loader.widgets_values[0] : "";
if (!file) return { settings: {}, architecture: "" };
try {
const idRes = await api.fetchApi(`/promptchain/models/identity?file=${encodeURIComponent(file)}`);
if (!idRes.ok) return { settings: {}, architecture: "" };
const identity = await idRes.json();
let config = {};
if (identity?.hash) {
const sRes = await api.fetchApi(`/promptchain/models/settings/${identity.hash}`);
if (sRes.ok) config = (await sRes.json()) || {};
}
return {
settings: config?.nodes?.UltimateSDUpscale || {},
// The model's own recommended gen sampler — the upscale recommendation
// when no USDU-specific override exists.
ksampler: config?.nodes?.KSampler || {},
architecture: config?.architecture || identity?.architecture || "",
family: config?.family || "",
hash: identity?.hash || null,
};
} catch {
return { settings: {}, ksampler: {}, architecture: "", family: "", hash: null };
}
}
// Mirror of the inject's applyArchSamplerDefaults — the recommendation the
// modal highlights must equal what the build would pick on its own.
function recommendedSamplerFor(architecture) {
const arch = architecture || "";
if (arch === "flux" || arch === "flux2") return { sampler: "euler", scheduler: "simple" };
if (arch === "zimage") return { sampler: "res_multistep", scheduler: "beta" };
if (arch === "krea2") return { sampler: "euler", scheduler: "simple" };
return { sampler: "dpmpp_2m_sde", scheduler: "karras" };
}
// Combo options off an off-graph probe node, same trick as the depth-assets
// check — node defs carry the live enum lists.
function usduComboOptions() {
try {
const probe = window.LiteGraph?.createNode?.("UltimateSDUpscale");
const opts = (name) => probe?.widgets?.find((w) => w.name === name)?.options?.values || [];
return {
samplers: opts("sampler_name"),
schedulers: opts("scheduler"),
modes: opts("mode_type"),
seamFixModes: opts("seam_fix_mode"),
};
} catch {
return { samplers: [], schedulers: [], modes: [], seamFixModes: [] };
}
}
// What sampler/scheduler the image was actually made with — if someone
// deviated from the defaults at gen time, the upscale continues in their
// choice. Scanned by value (widgets_values carry no names, and KSampler
// variants order them differently): the first string that's a known sampler /
// scheduler is the one. An existing USDU outranks the gen sampler.
function workflowSamplerChoice(workflow, samplers, schedulers) {
const nodes = workflow?.nodes || [];
const pick = (node) => {
const vals = (node?.widgets_values || []).filter((v) => typeof v === "string");
const s = vals.find((v) => samplers.includes(v)) || null;
const sch = vals.find((v) => schedulers.includes(v)) || null;
return s || sch ? { sampler: s, scheduler: sch } : null;
};
return pick(nodes.find((n) => n.type === "UltimateSDUpscale"))
|| pick(nodes.find((n) => n.type === "KSampler" || n.type === "KSamplerAdvanced"))
|| pick(nodes.find((n) => n.type === "KSamplerSelect"))
|| null;
}
// The effective USDU values the build would land on (inject DEFAULTS + arch
// adjustments + saved per-model settings) — the modal's Advanced section
// shows these so untouched fields equal what actually runs.
function usduBuildDefaults(architecture, saved) {
const d = {
steps: 15, cfg: 5, 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,
};
const arch = architecture || "";
if (arch === "flux" || arch === "flux2") d.cfg = 1.0;
else if (arch === "zimage") d.cfg = 1.3;
// Krea 2 carries cfg natively at 1.0 (no FluxGuidance); euler/simple/0.45
// denoise are applied in buildKrea2TileStage's defaults, not here (this dict
// only holds USDU shape knobs).
else if (arch === "krea2") d.cfg = 1.0;
for (const key of Object.keys(d)) {
if (saved && saved[key] !== undefined) d[key] = saved[key];
}
return d;
}
export async function prepareUpscale(entry) {
const params = entry._browsePath != null
? `scope=${encodeURIComponent(entry._browseScope || "output")}&path=${encodeURIComponent(entry._browsePath)}`
: `hash=${encodeURIComponent(entry.hash)}`;
let data = null;
try {
const res = await api.fetchApi(`/promptchain/image-workflow?${params}`);
if (res.ok) data = await res.json();
} catch (e) {
console.error("[PromptChain] image-workflow fetch failed", e);
}
if (!data?.input_ref) {
toast("error", "Couldn't read the image on the server.");
return null;
}
const caps = analyzeUpscaleCaps(data);
const longest = Math.max(data.width || 0, data.height || 0);
caps.defaultUpscaleBy = longest > 0 ? Math.min(8, Math.max(1, Math.round((4096 / longest) * 100) / 100)) : 2;
// Engine-model list + combo enums populate regardless of graftability — the
// picker's sdxl/qwen engines build standalone graphs that work on any image.
const engineModelsPromise = fetchEngineModels();
const combos = usduComboOptions();
caps.samplerOptions = combos.samplers;
caps.schedulerOptions = combos.schedulers;
caps.modeOptions = combos.modes;
caps.seamFixOptions = combos.seamFixModes;
if (caps.graftable) {
const { settings, ksampler, architecture, family, hash } = await fetchSavedUsduSettings(data.workflow);
const recommended = recommendedSamplerFor(architecture);
// The modal's prompt editor scopes templates/autocomplete to this when
// the source engine is selected (a picked engine model overrides it).
caps.sourceModelInfo = hash ? { hash, architecture, family } : null;
// Krea 2 Turbo source-graft is a TILE pass: denoise stays LOW so each tile
// anchors to the source and they stitch (high denoise = each tile a full
// re-render = seams). The distillation means low-denoise tiles still carry
// some residual blotch on smooth skin — that's a Turbo limitation a tile
// routine can't escape; the non-distilled RAW model is the real clean+
// coherent upscaler.
caps.defaultDenoise = typeof settings.denoise === "number" ? settings.denoise
: (architecture === "zimage" ? (family === "zimage_base" ? 0.15 : 0.7)
: architecture === "krea2" ? 0.45 : 0.3);
// Recommendation chain: a USDU-specific saved value > the MODEL's own
// recommended gen sampler (its config's KSampler section) > generic arch
// default. The model knows itself better than our tiling default does.
caps.recommendedSampler = settings.sampler_name || ksampler.sampler_name || recommended.sampler;
caps.recommendedScheduler = settings.scheduler || ksampler.scheduler || recommended.scheduler;
// The config's in-range alternates get the model panel's blue treatment.
caps.samplerAlternates = ksampler.sampler_name_options || [];
caps.schedulerAlternates = ksampler.scheduler_options || [];
// …but the SELECTED default is what the image was generated with, so a
// deliberate deviation carries through to the upscale — EXCEPT Krea 2, whose
// re-detail recipe is its own (euler is the house default). Inheriting a
// stale gen sampler from an older er_sde render would silently override that
// default; same deliberate skip as the flux2 refine below.
const inherited = workflowSamplerChoice(data.workflow, combos.samplers, combos.schedulers);
caps.defaultSampler = architecture === "krea2"
? caps.recommendedSampler
: (inherited?.sampler || caps.recommendedSampler);
caps.defaultScheduler = inherited?.scheduler || caps.recommendedScheduler;
caps.advancedDefaults = usduBuildDefaults(architecture, settings);
caps.architecture = architecture;
if (architecture === "flux2") {
// Flux2 builds the full-frame reference-anchored refine, not USDU — the
// modal's defaults mirror its recipe: high denoise regenerates texture
// while the ReferenceLatent anchors composition; gen-time sampler
// inheritance is deliberately skipped (the refine recipe is its own).
caps.denoiseMax = 1.0;
caps.defaultDenoise = typeof settings.denoise === "number" ? settings.denoise : 0.6;
caps.recommendedSampler = "euler_ancestral_cfg_pp";
caps.recommendedScheduler = "sgm_uniform";
caps.defaultSampler = caps.recommendedSampler;
caps.defaultScheduler = caps.recommendedScheduler;
if (family !== "flux2_klein") {
// Non-Klein sources refine on pinned Klein base — its official recipe.
if (settings.cfg === undefined) caps.advancedDefaults.cfg = 5;
if (settings.steps === undefined) caps.advancedDefaults.steps = 20;
} else {
const guider = (data.workflow?.nodes || []).find((n) => n.type === "CFGGuider");
const genCfg = Number(guider?.widgets_values?.[0]);
if (settings.cfg === undefined && genCfg > 0) caps.advancedDefaults.cfg = genCfg;
if (settings.steps === undefined) caps.advancedDefaults.steps = 24;
}
}
}
const support = engineAssetSupport();
caps.engineModels = (await engineModelsPromise).filter((m) =>
(m.architecture === "sdxl" && support.sdxl)
|| (m.architecture === "qwen_edit" && support.qwen)
|| (m.architecture === "flux" && support.flux1)
|| (m.architecture === "zimage" && support.zimage)
|| (m.architecture === "krea2" && support.krea2));
caps.enginePromptDefaults = { qwen: QWEN_ENHANCE_INSTRUCTION, sdxlFallback: SDXL_TILE_NEUTRAL_POSITIVE };
// Climb-model picker: the whole installed ESRGAN-family list, with the
// empirically-settled v1 pick as the recommendation. Degradation-trained
// models (RealESRGAN/Nomos) are a free middle tier on rough sources.
caps.upscaleModelOptions = comboProbe("UpscaleModelLoader", "model_name");
caps.recommendedUpscaleModel = defaultUpscaleModelPick(caps.upscaleModelOptions);
// Restore recommendation, by SOURCE: a jpeg or small input likely carries
// degradation a plain GAN climb would smear (the 736px webcam eye-smear);
// clean renders gain nothing from the restore round-trip. A GENERATED render
// (embedded sampler) is clean by construction — a small/square one (1024²) is
// NOT degraded, so don't let the size rule false-trigger SeedVR2 on it; only
// raw imported photos (no generation workflow) get the restore recommendation.
// Matches the prior A/B: UltraSharp beats SeedVR2 on clean renders.
const srcNodes = data.workflow?.nodes || [];
const isRender = srcNodes.some((n) => n.type === "KSampler" || n.type === "KSamplerAdvanced"
|| n.type === "SamplerCustom" || n.type === "SamplerCustomAdvanced");
caps.recommendedRestore = caps.seedvr2Available && !isRender
&& (/\.jpe?g$/i.test(data.filename || "") || Math.max(data.width || 0, data.height || 0) < 1100)
? "seedvr2" : "none";
// DIAGNOSTIC: why the engine picker may show only "Model climb only". If
// engineModels is 0 here, the re-detail engines were filtered out — either the
// asset probe (support) came back false or /models/list returned nothing (e.g.
// model index not finished scanning yet). Read this in the browser console.
console.log("[PromptChain] upscale caps:", JSON.stringify({
graftable: caps.graftable, engineModels: caps.engineModels.length, support,
byArch: caps.engineModels.reduce((a, m) => ((a[m.architecture] = (a[m.architecture] || 0) + 1), a), {}),
}));
return { data, caps };
}
// ---------------------------------------------------------------------------
// Conditioning shaping — runs on the loaded graph before pruning, so anything
// a mode drops from the USDU's conditioning chain falls out of the keep set.
// Walk a USDU conditioning input upstream past ControlNetApplyAdvanced nodes
// (following the same-named input keeps positive/negative slots straight).
// Flux 2 reference chains get the same treatment as the inject: USDU can't
// digest ReferenceLatent conds (ssitu#163), so when one is present, drop to
// the raw text encode beneath it (and beneath any FluxGuidance wrapper);
// reference-free conditioning passes through untouched.
function walkPastApplies(graph, node, inputName) {
let src = traceInput(graph, node, inputName);
while (src?.node?.comfyClass === "ControlNetApplyAdvanced") {
src = traceInput(graph, src.node, inputName);
}
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(graph, probe.node, "conditioning");
}
return sawReference ? probe : src;
}
// Prompt-only: reconnect USDU conditioning to the base text encodes, dropping
// ControlNet applies and regional conditioning from the chain.
function rewireToBaseConditioning(graph, usNode) {
for (const name of ["positive", "negative"]) {
let src = walkPastApplies(graph, usNode, name);
if (src?.node?.comfyClass === "PromptChain_RegionalConditioning") {
// Regional grafts feed USDU from RegionalConditioning; the plain encodes
// still exist on the sampler side of the source workflow — trace there.
const ks = graph._nodes.find((n) => n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced");
const fromSampler = ks && walkPastApplies(graph, ks, name);
if (fromSampler?.node) src = fromSampler;
}
if (src?.node) {
src.node.connect(src.slot, usNode, inputIdx(usNode, name, name === "positive" ? 2 : 3));
}
}
}
// Find a depth ControlNet apply already in the USDU's positive chain, plus its
// preprocessor when the hint goes through DepthAnything.
function findDepthApply(graph, usNode) {
let src = traceInput(graph, usNode, "positive");
while (src?.node?.comfyClass === "ControlNetApplyAdvanced") {
const apply = src.node;
const hintSrc = traceInput(graph, apply, "image");
const pre = (hintSrc?.node?.comfyClass === "DepthAnythingV2Preprocessor"
|| hintSrc?.node?.comfyClass === "PromptChain_DepthAnything") ? hintSrc.node : null;
if (pre || apply.properties?.pcrControlType === "depth") return { apply, pre };
src = traceInput(graph, apply, "positive");
}
return null;
}
// Make sure one depth ControlNet sits between the USDU's current conditioning
// sources and the USDU, hinted from the requested source (3D pose render or
// the upscale input image). An existing depth apply is kept — tuned strength
// survives — and only its hint is re-pointed when the source differs.
function ensureDepthBranch(graph, LG, usNode, depthSource, loadNode) {
const poser = graph._nodes.find((n) => n.comfyClass === "PromptChain_PoseStudio");
const sourceNode = depthSource === "pose" && poser ? poser : loadNode;
const sourceSlot = sourceNode === poser ? (poser.outputs?.findIndex((o) => o.name === "IMAGE") ?? 0) : 0;
// The Poser feeds its IMAGE straight into Apply ControlNet when it ALREADY renders
// true depth — its uploaded control map is the depth map itself, so DepthAnything
// is skipped. A legacy clay-mode Poser keeps the estimator: nothing re-captures a
// depth render at queue time, so pointing a clay render at a depth net would be wrong.
const poseDirect = sourceNode === poser &&
(poser.properties?.pcrOutputMode === "depth" || poser.properties?.pcrTrueDepth === true);
const existing = findDepthApply(graph, usNode);
if (existing) {
if (poseDirect) {
// Hint the apply straight from the Poser; retire any DepthAnything in between.
sourceNode.connect(sourceSlot, existing.apply, inputIdx(existing.apply, "image", 3));
if (existing.pre) existing.pre.mode = 4;
} else if (existing.pre) {
const cur = traceInput(graph, existing.pre, "image");
if (cur?.node !== sourceNode) {
sourceNode.connect(sourceSlot, existing.pre, inputIdx(existing.pre, "image", 0));
}
} else {
// The apply was built pose-direct (no preprocessor) but the new source isn't
// a true-depth render — a clay-mode Poser or an image. Its raw pixels aren't a
// depth map, so splice a DepthAnything estimator back in between source and apply.
const pre = LG.createNode(resolvePre(CONTROLNET_TYPES.depth.pre));
if (pre) {
pre.pos = [existing.apply.pos[0] - 340, existing.apply.pos[1]];
graph.add(pre);
pre.size = [291, pre.size?.[1] ?? 130];
const resW = pre.widgets?.find((w) => w.name === "resolution");
if (resW) { resW.value = 1024; resW.callback?.(1024); }
sourceNode.connect(sourceSlot, pre, inputIdx(pre, "image", 0));
pre.connect(0, existing.apply, inputIdx(existing.apply, "image", 3));
}
}
return true;
}
const cfg = CONTROLNET_TYPES.depth;
const pre = poseDirect ? null : LG.createNode(resolvePre(cfg.pre));
const loader = LG.createNode("ControlNetLoader");
const setUnion = LG.createNode("SetUnionControlNetType");
const applyNode = LG.createNode("ControlNetApplyAdvanced");
if ((!poseDirect && !pre) || !loader || !setUnion || !applyNode) return false;
const ax = usNode.pos[0];
const ay = usNode.pos[1];
if (pre) pre.pos = [ax - 700, ay + 420];
loader.pos = [ax - 700, ay + 640];
setUnion.pos = [ax - 700, ay + 780];
applyNode.pos = [ax - 360, ay + 420];
if (pre) graph.add(pre);
graph.add(loader); graph.add(setUnion); graph.add(applyNode);
applyNode.title = `ControlNet — Depth${sourceNode === poser ? " (pose)" : ""}`;
applyNode.properties = { ...(applyNode.properties || {}), pcrControlType: "depth" };
setControlNetModel(loader, cfg.model);
const unionW = setUnion.widgets?.find((w) => w.name === "type");
if (unionW) { unionW.value = cfg.unionType; unionW.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); }
}
const resW = pre?.widgets?.find((w) => w.name === "resolution");
if (resW) { resW.value = 1024; resW.callback?.(1024); }
const posSrc = traceInput(graph, usNode, "positive");
const negSrc = traceInput(graph, usNode, "negative");
if (!posSrc?.node || !negSrc?.node) return false;
if (pre) {
sourceNode.connect(sourceSlot, pre, inputIdx(pre, "image", 0));
pre.connect(0, applyNode, inputIdx(applyNode, "image", 3));
} else {
// Pose-direct: the Poser's true-depth IMAGE is the control map itself.
sourceNode.connect(sourceSlot, applyNode, inputIdx(applyNode, "image", 3));
}
loader.connect(0, setUnion, 0);
setUnion.connect(0, applyNode, inputIdx(applyNode, "control_net", 2));
posSrc.node.connect(posSrc.slot, applyNode, inputIdx(applyNode, "positive", 0));
negSrc.node.connect(negSrc.slot, applyNode, inputIdx(applyNode, "negative", 1));
applyNode.connect(0, usNode, inputIdx(usNode, "positive", 2));
applyNode.connect(1, usNode, inputIdx(usNode, "negative", 3));
return true;
}
// Regional mode on a workflow that already carried a USDU: make sure its
// conditioning comes from a PromptChain_RegionalConditioning. Workflows built
// by Add > Upscaler already have one wired; a couple without one gets the same
// rc the inject would build.
function ensureRegionalConditioning(graph, LG, usNode) {
const src = walkPastApplies(graph, usNode, "positive");
if (src?.node?.comfyClass === "PromptChain_RegionalConditioning") return true;
const pcNode = graph._nodes.find((n) => n.comfyClass === "PromptChain_PromptChain");
const couple = graph._nodes.find((n) => n.comfyClass === "PromptChain_AttentionCouple");
if (!pcNode || !couple) return false;
const rc = buildRegionalUpscaleCond(graph, LG, pcNode, couple, usNode.pos[0] - 384, usNode.pos[1]);
if (!rc) return false;
rc.connect(0, usNode, inputIdx(usNode, "positive", 2));
rc.connect(1, usNode, inputIdx(usNode, "negative", 3));
return true;
}
// An Edit crop selection rides as data.editRect (document coordinates). The
// pose scene's masks and depth render cover the SOURCE frame, so feeding them
// to a cropped USDU input would paint the whole scene onto the crop — splice
// crops scaled to the poser's own render dims to restore the mask↔pixel
// correspondence. Regions falling outside the rect crop to empty masks and
// contribute nothing (the unmasked global cond still covers every pixel).
function cropSceneToEditRect(graph, LG, usNode, editRect) {
const poser = graph._nodes.find((n) => n.comfyClass === "PromptChain_PoseStudio");
if (!poser || !(editRect?.docW > 0) || !(editRect?.docH > 0)) return;
const poseW = poser.widgets?.find((w) => w.name === "width")?.value || editRect.docW;
const poseH = poser.widgets?.find((w) => w.name === "height")?.value || editRect.docH;
const x = Math.max(0, Math.min(poseW - 1, Math.round((editRect.x / editRect.docW) * poseW)));
const y = Math.max(0, Math.min(poseH - 1, Math.round((editRect.y / editRect.docH) * poseH)));
const w = Math.max(1, Math.min(poseW - x, Math.round((editRect.w / editRect.docW) * poseW)));
const h = Math.max(1, Math.min(poseH - y, Math.round((editRect.h / editRect.docH) * poseH)));
const splice = (target, inputName, cropClass, poserSlot) => {
const src = traceInput(graph, target, inputName);
if (src?.node !== poser || src.slot !== poserSlot) return;
const crop = LG.createNode(cropClass);
if (!crop) return;
crop.pos = [poser.pos[0] + 250, target.pos[1] + 160];
graph.add(crop);
for (const [name, value] of [["x", x], ["y", y], ["width", w], ["height", h]]) {
setNamedWidget(crop, name, value);
}
poser.connect(poserSlot, crop, inputIdx(crop, cropClass === "CropMask" ? "mask" : "image", 0));
crop.connect(0, target, inputIdx(target, inputName, 0));
};
const condSrc = walkPastApplies(graph, usNode, "positive");
if (condSrc?.node?.comfyClass === "PromptChain_RegionalConditioning") {
const maskSlot = poser.outputs?.findIndex((o) => o.name === "MASKS") ?? -1;
if (maskSlot >= 0) splice(condSrc.node, "masks", "CropMask", maskSlot);
}
const depth = findDepthApply(graph, usNode);
if (depth) {
const imgSlot = poser.outputs?.findIndex((o) => o.name === "IMAGE") ?? 0;
splice(depth.pre || depth.apply, "image", "ImageCrop", imgSlot);
}
}
// ---------------------------------------------------------------------------
// Prune + assembly
// Keep the upscale core: the USDU node, everything downstream of it (its
// SaveImage chain), the upstream closure of every input EXCEPT image — the
// image source (decode/detailer chain) is what the LoadImage replaces — and
// any explicitly protected nodes (the LoadImage itself, wired before pruning
// so depth branches can hint from it).
function pruneToUpscaleCore(graph, usNode, protectedIds = []) {
const keep = new Set([usNode.id, ...protectedIds]);
const followDown = (node) => {
for (const out of node.outputs || []) {
for (const lid of out.links || []) {
const link = getLink(graph, lid);
const target = link && graph.getNodeById(link.target_id);
if (target && !keep.has(target.id)) { keep.add(target.id); followDown(target); }
}
}
};
followDown(usNode);
const queue = [];
for (const input of usNode.inputs || []) {
if (input.name === "image" || input.link == null) continue;
const link = getLink(graph, input.link);
const origin = link && graph.getNodeById(link.origin_id);
if (origin) queue.push(origin);
}
while (queue.length) {
const node = queue.shift();
if (keep.has(node.id)) continue;
keep.add(node.id);
for (const input of node.inputs || []) {
if (input.link == null) continue;
const link = getLink(graph, input.link);
const origin = link && graph.getNodeById(link.origin_id);
if (origin) queue.push(origin);
}
}
for (const node of [...graph._nodes]) {
if (!keep.has(node.id)) graph.remove(node);
}
}
// The injects key sampler defaults (cfg/steps) off modelConfig.architecture and
// honor saved per-model UltimateSDUpscale settings — resolve both the same way
// the settings modal does: loader filename → identity → saved settings.
async function fetchModelConfig(graph) {
const loader = graph._nodes.find((n) => n.comfyClass === "CheckpointLoaderSimple" || n.comfyClass === "CheckpointLoader")
|| graph._nodes.find((n) => n.comfyClass === "UNETLoader" || n.comfyClass === "UnetLoaderGGUF");
const file = loader?.widgets?.find((w) => w.name === "ckpt_name" || w.name === "unet_name")?.value;
if (!file) return {};
try {
const idRes = await api.fetchApi(`/promptchain/models/identity?file=${encodeURIComponent(file)}`);
if (!idRes.ok) return {};
const identity = await idRes.json();
let config = {};
if (identity?.hash) {
const sRes = await api.fetchApi(`/promptchain/models/settings/${identity.hash}`);
if (sRes.ok) config = (await sRes.json()) || {};
}
return { ...config, architecture: config.architecture || identity?.architecture || "" };
} catch {
return {};
}
}
// Stamp the modal's destination onto the first SaveImage downstream of the
// USDU (post-prune, that's the only save chain left).
function setSavePrefix(graph, usNode, prefix) {
if (!prefix) return;
const seen = new Set();
const visit = [usNode];
while (visit.length) {
const n = visit.shift();
if (seen.has(n.id)) continue;
seen.add(n.id);
for (const out of n.outputs || []) {
for (const lid of out.links || []) {
const link = getLink(graph, lid);
const target = link && graph.getNodeById(link.target_id);
if (!target) continue;
if (target.comfyClass === "SaveImage") {
const w = target.widgets?.find((x) => x.name === "filename_prefix");
if (w) { w.value = prefix; w.callback?.(prefix); }
return;
}
visit.push(target);
}
}
}
}
// Identity/style lock for the re-detail pass: splice the condition cluster
// between whatever feeds the USDU's model input and the USDU itself, with the
// reference image wired to the SAME LoadImage that feeds the upscale — the
// image locks to itself, so tiles can't drift the character/style. Node
// values mirror the inpaint graft's (verified by _inpaintcheck.mjs).
function injectUpscaleCondition(graph, LG, usNode, loadNode, condition) {
const src = traceInput(graph, usNode, "model");
if (!src?.node || !loadNode) return false;
const modelIdx = inputIdx(usNode, "model", 0);
const setW = (node, name, value) => {
const w = node.widgets?.find((x) => x.name === name);
if (w) { w.value = value; w.callback?.(value); }
};
const ax = usNode.pos[0];
const ay = usNode.pos[1];
if (condition === "ipadapter") {
const loader = LG.createNode("IPAdapterUnifiedLoader");
const apply = LG.createNode("IPAdapterAdvanced");
if (!loader || !apply) return false;
loader.pos = [ax - 700, ay - 480];
apply.pos = [ax - 360, ay - 480];
graph.add(loader); graph.add(apply);
apply.title = "Style Reference — this image";
setW(loader, "preset", "PLUS (high strength)");
setW(apply, "weight", 0.7);
setW(apply, "weight_type", "style transfer");
setW(apply, "combine_embeds", "concat");
setW(apply, "start_at", 0);
setW(apply, "end_at", 0.7);
setW(apply, "embeds_scaling", "V only");
usNode.disconnectInput(modelIdx);
src.node.connect(src.slot, loader, inputIdx(loader, "model", 0));
loader.connect(loader.outputs?.findIndex((o) => o.type === "MODEL") ?? 0, apply, inputIdx(apply, "model", 0));
loader.connect(loader.outputs?.findIndex((o) => o.type === "IPADAPTER") ?? 1, apply, inputIdx(apply, "ipadapter", 1));
loadNode.connect(0, apply, inputIdx(apply, "image", 2));
apply.connect(0, usNode, modelIdx);
return true;
}
if (condition === "pulid") {
const pulidModel = LG.createNode("PulidModelLoader");
const insight = LG.createNode("PulidInsightFaceLoader");
const evaClip = LG.createNode("PulidEvaClipLoader");
const apply = LG.createNode("ApplyPulidAdvanced");
if (!pulidModel || !insight || !evaClip || !apply) return false;
pulidModel.pos = [ax - 700, ay - 700];
insight.pos = [ax - 700, ay - 560];
evaClip.pos = [ax - 700, ay - 440];
apply.pos = [ax - 360, ay - 560];
graph.add(pulidModel); graph.add(insight); graph.add(evaClip); graph.add(apply);
apply.title = "PuLID — face from this image";
const fileW = pulidModel.widgets?.find((x) => x.name === "pulid_file");
const fileOpt = (fileW?.options?.values || []).find((o) => /pulid.*sdxl/i.test(o));
if (fileW && fileOpt) { fileW.value = fileOpt; fileW.callback?.(fileOpt); }
setW(insight, "provider", "CUDA");
setW(apply, "weight", 0.45);
setW(apply, "projection", "ortho_v2");
setW(apply, "fidelity", 4);
setW(apply, "noise", 0);
setW(apply, "start_at", 0);
setW(apply, "end_at", 1);
usNode.disconnectInput(modelIdx);
src.node.connect(src.slot, apply, inputIdx(apply, "model", 0));
pulidModel.connect(0, apply, inputIdx(apply, "pulid", 1));
evaClip.connect(0, apply, inputIdx(apply, "eva_clip", 2));
insight.connect(0, apply, inputIdx(apply, "face_analysis", 3));
loadNode.connect(0, apply, inputIdx(apply, "image", 4));
apply.connect(0, usNode, modelIdx);
return true;
}
return false;
}
// The modal's edited prompt replaces the source PromptChain text — the node
// recompiles it server-side, so positive AND negative (and any // sections)
// flow to the encodes the USDU conditioning already traces to. Cached outputs
// are blanked so a stale compile can't serve; the longest-prompt node is the
// source entry in chained networks (same heuristic the inpaint graft uses).
function overrideGraphPrompt(graph, text) {
const pcs = graph._nodes.filter((n) => n.comfyClass === "PromptChain_PromptChain");
if (!pcs.length) return;
const promptOf = (n) => String(n.widgets?.find((w) => w.name === "prompt")?.value || "");
const pc = pcs.sort((a, b) => promptOf(b).length - promptOf(a).length)[0];
const set = (name, value) => {
const w = pc.widgets?.find((x) => x.name === name);
if (w) { w.value = value; w.callback?.(value); }
};
set("prompt", text);
set("locked", false);
set("cached_output", "");
set("cached_neg_output", "");
set("cached_regions", "");
}
// Edit-handoff runs must not leave files or gallery records — the render only
// exists to come back as a layer, and Edit's own Save is the persistence point.
function swapSaveForPreview(graph, usNode, LG) {
const seen = new Set();
const visit = [usNode];
while (visit.length) {
const n = visit.shift();
if (seen.has(n.id)) continue;
seen.add(n.id);
for (const out of n.outputs || []) {
for (const lid of out.links || []) {
const link = getLink(graph, lid);
const target = link && graph.getNodeById(link.target_id);
if (!target) continue;
if (target.comfyClass === "SaveImage") {
const src = traceInput(graph, target, "images");
const preview = LG.createNode("PreviewImage");
if (!src?.node || !preview) return;
preview.pos = [...target.pos];
graph.add(preview);
src.node.connect(src.slot, preview, 0);
graph.remove(target);
return;
}
visit.push(target);
}
}
}
}
function setImageWidget(loadNode, inputRef) {
const w = loadNode.widgets?.find((x) => x.name === "image");
if (!w) return;
// The combo's values were listed before our server-side copy existed.
if (Array.isArray(w.options?.values) && !w.options.values.includes(inputRef)) {
w.options.values.push(inputRef);
}
w.value = inputRef;
w.callback?.(inputRef);
}
// v1 stays the recommendation: V2 (DAT2) was isolated-tested on a clean
// render and deposits faint micro-speckle on smooth skin where v1 is clean —
// and that speckle fed through a tile CN regularizes into visible cross-hatch.
export function defaultUpscaleModelPick(opts) {
return opts.find((o) => /ultrasharp(?!v2)/i.test(o))
|| opts.find((o) => /ultrasharp/i.test(o))
|| opts.find((o) => /^4x/i.test(o)) || opts[0] || null;
}
function pickUpscaleModel(loaderNode, preferred) {
const w = loaderNode.widgets?.find((x) => x.name === "model_name");
if (!w) return;
const opts = w.options?.values || [];
// Explicit pick (the modal's Climb model select) wins when installed;
// degradation-trained models (RealESRGAN/Nomos) are a legitimate middle
// tier between UltraSharp and a SeedVR2 restore on mildly degraded sources.
const val = (preferred && opts.includes(preferred) ? preferred : null)
|| defaultUpscaleModelPick(opts);
if (val) { w.value = val; w.callback?.(val); }
}
// SeedVR2's restoration stage must stay near its training window — the paper
// caps its own evaluation at 1080p output and >2K is a documented artifact
// regime — so it runs at ≤1440 short edge and UltraSharp climbs the rest.
export const SEEDVR2_STAGE_MAX_SHORT_EDGE = 1440;
// A clean render carries no degradation signature, and the restorer copies its
// condition when there's nothing to invert — a little injected noise is the
// pack's own remedy (README: 0.1–0.3).
export const SEEDVR2_INPUT_NOISE = 0.15;
// Quality pick on a 24 GB card; the sharp variants over-sharpen faces. The
// fp8 guard matters: "7b_fp8_..._mixed_block35_fp16" also contains "fp16",
// and picking it would auto-download a second multi-GB model.
const SEEDVR2_DIT_PATTERNS = [
(o) => /7b/i.test(o) && /fp16/i.test(o) && !/fp8|sharp/i.test(o),
(o) => /7b/i.test(o) && !/sharp/i.test(o),
(o) => /fp16/i.test(o) && !/fp8|sharp/i.test(o),
];
export function pickSeedvr2DitModel(options) {
for (const match of SEEDVR2_DIT_PATTERNS) {
const hit = options.find(match);
if (hit) return hit;
}
return options[0];
}
function setNamedWidget(node, name, value) {
const w = node?.widgets?.find((x) => x.name === name);
if (!w) return;
w.value = value;
w.callback?.(value);
}
// SeedVR2 floor: restore at trained scale, then the classic ESRGAN climb to
// the same 4× target the UltraSharp floor produces, lanczos-fit to exact dims.
function buildSeedvr2FloorGraph(data, savePrefix = "", previewOnly = false, climbModel = null, scale = 0) {
const LG = window.LiteGraph;
const workflowId = freshWorkflowId();
const graph = new LG.LGraph(structuredClone({ ...EMPTY_WORKFLOW, id: workflowId }));
graph.last_node_id = offscreenIdBase(); // render-node ids must stay off the live canvas — see offscreen-graph.js
const load = LG.createNode("LoadImage");
const dit = LG.createNode("SeedVR2LoadDiTModel");
const vae = LG.createNode("SeedVR2LoadVAEModel");
const up = LG.createNode("SeedVR2VideoUpscaler");
const save = LG.createNode(previewOnly ? "PreviewImage" : "SaveImage");
if (!load || !dit || !vae || !up || !save) return null;
const eff = scale > 0 ? scale : 4; // honor the Scale slider; default to 4x when none given
const targetW = (data.width || 0) * eff;
const targetH = (data.height || 0) * eff;
const targetShort = Math.min(targetW, targetH);
const stageShort = targetShort > 0
? Math.min(SEEDVR2_STAGE_MAX_SHORT_EDGE, targetShort)
: 1080;
const needsFinish = targetShort > stageShort;
if (data.filename) load.title = data.filename;
load.pos = [80, 200];
dit.pos = [80, 560];
vae.pos = [80, 900];
up.pos = [520, 240];
graph.add(load); graph.add(dit); graph.add(vae); graph.add(up);
setImageWidget(load, data.input_ref);
const ditModels = dit.widgets?.find((w) => w.name === "model")?.options?.values || [];
if (ditModels.length) setNamedWidget(dit, "model", pickSeedvr2DitModel(ditModels));
setNamedWidget(up, "resolution", stageShort);
setNamedWidget(up, "batch_size", 1); // stills — the 4n+1 video rule allows 1
setNamedWidget(up, "input_noise_scale", SEEDVR2_INPUT_NOISE);
load.connect(0, up, inputIdx(up, "image", 0));
dit.connect(0, up, inputIdx(up, "dit", 1));
vae.connect(0, up, inputIdx(up, "vae", 2));
let imageSource = up;
if (needsFinish) {
const loader = LG.createNode("UpscaleModelLoader");
const apply = LG.createNode("ImageUpscaleWithModel");
const scale = LG.createNode("ImageScale");
if (!loader || !apply || !scale) return null;
loader.pos = [520, 620];
apply.pos = [940, 240];
scale.pos = [1220, 240];
graph.add(loader); graph.add(apply); graph.add(scale);
pickUpscaleModel(loader, climbModel);
up.connect(0, apply, inputIdx(apply, "image", 1));
loader.connect(0, apply, inputIdx(apply, "upscale_model", 0));
setNamedWidget(scale, "upscale_method", "lanczos");
setNamedWidget(scale, "width", targetW);
setNamedWidget(scale, "height", targetH);
apply.connect(0, scale, inputIdx(scale, "image", 0));
imageSource = scale;
}
save.pos = [imageSource.pos[0] + 300, 240];
graph.add(save);
const prefix = save.widgets?.find((w) => w.name === "filename_prefix");
if (prefix) { prefix.value = savePrefix || "upscale/upscale"; prefix.callback?.(prefix.value); }
imageSource.connect(0, save, 0);
return { graph, workflowId };
}
// ---------------------------------------------------------------------------
// Flux2 refine: USDU tiling is a structural dead end on flux2 — no tile
// ControlNet exists, and the default schedule shift (stored 2.02 = mu, an
// EFFECTIVE e^2.02 ≈ 7.5×) turns "denoise 0.3" into a ~70% per-tile repaint,
// which is why grafted USDU output came back softer than its own ESRGAN
// floor. The community-proven recipe is full-frame img2img at ~2K with the
// original image as BOTH the latent and a ReferenceLatent anchor (so high
// denoise regenerates texture without drifting composition), then a plain
// ESRGAN climb to the requested size.
const FLUX2_REFINE_LONG_EDGE = 2048;
function roundTo16(v) {
return Math.max(16, Math.round(v / 16) * 16);
}
function buildFlux2RefineGraph(graph, workflowId, data, options, config) {
const LG = window.LiteGraph;
// Anchors in the source graph — same cases the USDU inject handles.
const customSampler = graph._nodes.find((n) => n.comfyClass === "SamplerCustomAdvanced");
const ksampler = graph._nodes.find((n) => n.comfyClass === "KSampler" || n.comfyClass === "KSamplerAdvanced");
const condAnchorNode = customSampler
? traceInput(graph, customSampler, "guider")?.node
: ksampler;
if (!condAnchorNode) return null;
const isBasicGuider = condAnchorNode.comfyClass === "BasicGuider";
const positive = walkPastApplies(graph, condAnchorNode, isBasicGuider ? "conditioning" : "positive");
const negative = isBasicGuider ? null : walkPastApplies(graph, condAnchorNode, "negative");
if (!positive?.node) return null;
let unet = graph._nodes.find((n) => n.comfyClass === "UNETLoader" || n.comfyClass === "UnetLoaderGGUF");
const clipLoader = graph._nodes.find((n) => n.comfyClass === "CLIPLoader");
const vaeLoader = graph._nodes.find((n) => n.comfyClass === "VAELoader");
if (!unet || !vaeLoader) return null;
// Non-Klein flux2 (Dev fp8/GGUF): full-frame 2K img2img through a streamed
// 32B model is not viable — pin the refine to Klein base, retargeting the
// text encoder with it (Dev=Mistral vs Klein=Qwen3; embeddings differ).
// Both swaps abandon together if either file is missing.
let pinned = false;
if (config?.family && config.family !== "flux2_klein" && clipLoader) {
const pinLoader = LG.createNode("UNETLoader");
const unetW = pinLoader?.widgets?.find((w) => w.name === "unet_name");
const unetOpts = unetW?.options?.values || [];
const kleinFile = unetOpts.find((o) => /klein.*base.*9b|klein.*9b.*base/i.test(o))
|| unetOpts.find((o) => /klein.*9b/i.test(o)) || unetOpts.find((o) => /klein/i.test(o));
const clipW = clipLoader.widgets?.find((w) => w.name === "clip_name");
const qwenFile = (clipW?.options?.values || []).find((o) => /qwen[_-]?3[_-]?8b/i.test(o));
if (pinLoader && kleinFile && qwenFile) {
unetW.value = kleinFile; unetW.callback?.(kleinFile);
clipW.value = qwenFile; clipW.callback?.(qwenFile);
pinLoader.pos = [unet.pos[0], unet.pos[1] - 160];
graph.add(pinLoader);
unet = pinLoader;
pinned = true;
} else if (pinLoader) {
graph.remove(pinLoader);
}
}
const ax = (customSampler || condAnchorNode).pos[0];
const ay = (customSampler || condAnchorNode).pos[1];
const place = (node, dx, dy) => { node.pos = [ax + dx, ay + dy]; graph.add(node); };
const load = LG.createNode("LoadImage");
const refineScale = LG.createNode("ImageScale");
const encodeRefine = LG.createNode("VAEEncode");
const refPos = LG.createNode("ReferenceLatent");
const refNeg = negative?.node ? LG.createNode("ReferenceLatent") : null;
const zeroNeg = negative?.node ? null : LG.createNode("ConditioningZeroOut");
const guider = LG.createNode("CFGGuider");
const scheduler = LG.createNode("BasicScheduler");
const noise = LG.createNode("RandomNoise");
const sampler = LG.createNode("SamplerCustomAdvanced");
const decode = LG.createNode("VAEDecode");
const save = LG.createNode(options.previewOnly ? "PreviewImage" : "SaveImage");
// Recipe sampler: EulerAncestral CFG++. s_noise stays at 1.0 — extra
// injected noise was A/B'd and chews surfaces into hallucinated texture.
let samplerSelect = null;
if (!options.sampler || options.sampler === "euler_ancestral_cfg_pp") {
samplerSelect = LG.createNode("SamplerEulerAncestralCFGPP");
if (samplerSelect) {
setNamedWidget(samplerSelect, "eta", 1.0);
setNamedWidget(samplerSelect, "s_noise", 1.0);
}
}
if (!samplerSelect) {
samplerSelect = LG.createNode("KSamplerSelect");
if (samplerSelect) setNamedWidget(samplerSelect, "sampler_name", options.sampler || "euler_ancestral");
}
if (!load || !refineScale || !encodeRefine || !refPos || (!refNeg && !zeroNeg)
|| !guider || !scheduler || !noise || !sampler || !decode || !save || !samplerSelect) return null;
if (data.filename) load.title = data.filename;
place(load, -1500, 0);
place(refineScale, -1160, 0);
place(encodeRefine, -880, 0);
place(refPos, -620, -160);
if (refNeg) place(refNeg, -620, 20);
if (zeroNeg) place(zeroNeg, -620, 20);
place(guider, -340, -120);
place(scheduler, -340, 120);
place(samplerSelect, -340, 300);
place(noise, -340, 440);
place(sampler, 0, 0);
place(decode, 280, 0);
setImageWidget(load, data.input_ref);
// Refine at the model's comfortable resolution; the GAN handles size.
const w = data.width || 0;
const h = data.height || 0;
const refineFactor = w && h ? FLUX2_REFINE_LONG_EDGE / Math.max(w, h) : 2;
const refineW = roundTo16(w ? w * refineFactor : FLUX2_REFINE_LONG_EDGE);
const refineH = roundTo16(h ? h * refineFactor : FLUX2_REFINE_LONG_EDGE);
setNamedWidget(refineScale, "upscale_method", "lanczos");
setNamedWidget(refineScale, "width", refineW);
setNamedWidget(refineScale, "height", refineH);
const genCfg = Number(condAnchorNode.comfyClass === "CFGGuider" ? condAnchorNode.widgets?.find((x) => x.name === "cfg")?.value : 0);
setNamedWidget(guider, "cfg", options.advanced?.cfg ?? (pinned ? 5 : (genCfg > 0 ? genCfg : 5)));
setNamedWidget(scheduler, "scheduler", options.scheduler || "sgm_uniform");
setNamedWidget(scheduler, "steps", options.advanced?.steps ?? (pinned ? 20 : 24));
setNamedWidget(scheduler, "denoise", typeof options.denoise === "number" ? options.denoise : 0.6);
setNamedWidget(noise, "noise_seed", Math.floor(Math.random() * 2 ** 32));
load.connect(0, refineScale, inputIdx(refineScale, "image", 0));
refineScale.connect(0, encodeRefine, inputIdx(encodeRefine, "pixels", 0));
vaeLoader.connect(0, encodeRefine, inputIdx(encodeRefine, "vae", 1));
// The reference is the SAME latent the sampler starts from — A/B'd against
// a native-res reference encode: the same-latent anchor is what keeps
// fabric/skin/background honest at refine denoise; the native-res variant
// mottled at denoise 0.5 while this stayed clean at 0.6.
positive.node.connect(positive.slot, refPos, inputIdx(refPos, "conditioning", 0));
encodeRefine.connect(0, refPos, inputIdx(refPos, "latent", 1));
if (refNeg) {
negative.node.connect(negative.slot, refNeg, inputIdx(refNeg, "conditioning", 0));
encodeRefine.connect(0, refNeg, inputIdx(refNeg, "latent", 1));
} else {
positive.node.connect(positive.slot, zeroNeg, 0);
}
unet.connect(0, guider, inputIdx(guider, "model", 0));
refPos.connect(0, guider, inputIdx(guider, "positive", 1));
(refNeg || zeroNeg).connect(0, guider, inputIdx(guider, "negative", 2));
unet.connect(0, scheduler, inputIdx(scheduler, "model", 0));
noise.connect(0, sampler, inputIdx(sampler, "noise", 0));
guider.connect(0, sampler, inputIdx(sampler, "guider", 1));
samplerSelect.connect(0, sampler, inputIdx(sampler, "sampler", 2));
scheduler.connect(0, sampler, inputIdx(sampler, "sigmas", 3));
encodeRefine.connect(0, sampler, inputIdx(sampler, "latent_image", 4));
sampler.connect(0, decode, inputIdx(decode, "samples", 0));
vaeLoader.connect(0, decode, inputIdx(decode, "vae", 1));
// ESRGAN climb to the requested size; skipped when the refine already
// covers it. Lanczos-fit to exact dims either way.
const longest = Math.max(w, h);
const chosenScale = typeof options.upscaleBy === "number" && options.upscaleBy >= 1
? options.upscaleBy
: longest > 0 ? Math.min(8, Math.max(1, Math.round((4096 / longest) * 100) / 100)) : 2;
const targetW = Math.round(w * chosenScale) || refineW;
const targetH = Math.round(h * chosenScale) || refineH;
// The prune keeps the sampler's upstream closure and its downstream chain,
// but NOT side-inputs of downstream nodes — protect those explicitly.
const protectedIds = [load.id];
let imageSource = decode;
if (Math.max(targetW, targetH) > Math.max(refineW, refineH)) {
const upLoader = LG.createNode("UpscaleModelLoader");
const upApply = LG.createNode("ImageUpscaleWithModel");
if (!upLoader || !upApply) return null;
place(upLoader, 280, 200);
place(upApply, 560, 0);
pickUpscaleModel(upLoader);
decode.connect(0, upApply, inputIdx(upApply, "image", 1));
upLoader.connect(0, upApply, inputIdx(upApply, "upscale_model", 0));
protectedIds.push(upLoader.id);
imageSource = upApply;
}
const fit = LG.createNode("ImageScale");
if (!fit) return null;
place(fit, 840, 0);
setNamedWidget(fit, "upscale_method", "lanczos");
setNamedWidget(fit, "width", targetW);
setNamedWidget(fit, "height", targetH);
imageSource.connect(0, fit, inputIdx(fit, "image", 0));
// 4K finishing pass: the refine's detail ceiling is its 2K canvas — the
// last 2× is GAN interpolation, which reads soft at 100%. The field's
// converged answer is one low-denoise tiled diffusion pass AT final
// resolution, structure-locked by a tile ControlNet. FLUX.2 has no tile
// CN, so this stage runs on SDXL (mature xinsir tile stack): texture is
// re-synthesized in real 4K pixels while the CN pins every tile to the
// refine output. The composite afterwards keeps the background faithful
// regardless of what the tiles do.
let detailImage = fit;
const reg = LG.registered_node_types || {};
// Opt-IN: three tuning rounds (0.25+SDE = fake pores, 0.15 = softened eyes
// + residual weave) showed the SDXL prior imposes its own surface character
// on smooth Klein skin at any denoise. Available for textured content.
if (options.finish4k === true) {
const stage = buildSdxlTileStage(graph, LG,
(node, dx, dy) => place(node, 840 + dx, -480 + dy),
{ imageNode: fit });
if (stage) {
protectedIds.push(...stage.sideInputIds);
detailImage = stage.usdu;
}
}
// Background-preserving composite: the refine measurably amplifies the
// generator's sub-acuity mottle in defocused regions (and can hallucinate
// objects there) — background pixels come from the ESRGAN climb of the
// SOURCE instead: a faithful enlargement, nothing repainted, nothing
// smoothed (user spec: an upscale that upscales). The mask is DEPTH-driven
// (Otsu split on DepthAnythingV2): frequency-based defocus metrics were
// calibrated on real outputs and rejected — the mottle out-energies smooth
// in-focus surfaces.
let finalImage = detailImage;
if (options.preserveDefocus !== false
&& reg["PromptChain_DefocusMask"] && hasPre("DepthAnythingV2Preprocessor")) {
const srcLoader = LG.createNode("UpscaleModelLoader");
const srcUp = LG.createNode("ImageUpscaleWithModel");
const srcFit = LG.createNode("ImageScale");
const depth = LG.createNode(resolvePre("DepthAnythingV2Preprocessor"));
const dmask = LG.createNode("PromptChain_DefocusMask");
const maskImg = LG.createNode("MaskToImage");
const maskScale = LG.createNode("ImageScale");
const maskBack = LG.createNode("ImageToMask");
const comp = LG.createNode("ImageCompositeMasked");
if (srcLoader && srcUp && srcFit && depth && dmask && maskImg && maskScale && maskBack && comp) {
place(srcLoader, 840, 220);
place(srcUp, 1100, 220);
place(srcFit, 1360, 220);
place(depth, 840, 460);
place(dmask, 1100, 460);
place(maskImg, 1340, 460);
place(maskScale, 1580, 460);
place(maskBack, 1820, 460);
place(comp, 1380, 0);
pickUpscaleModel(srcLoader);
setNamedWidget(srcFit, "upscale_method", "lanczos");
setNamedWidget(srcFit, "width", targetW);
setNamedWidget(srcFit, "height", targetH);
setNamedWidget(depth, "resolution", 1024);
// feather at depth-map res (~1024) ≈ 24px → ~96px after the upscale
setNamedWidget(dmask, "feather", 24);
setNamedWidget(maskScale, "upscale_method", "bilinear");
setNamedWidget(maskScale, "width", targetW);
setNamedWidget(maskScale, "height", targetH);
setNamedWidget(maskBack, "channel", "red");
load.connect(0, srcUp, inputIdx(srcUp, "image", 1));
srcLoader.connect(0, srcUp, inputIdx(srcUp, "upscale_model", 0));
srcUp.connect(0, srcFit, inputIdx(srcFit, "image", 0));
load.connect(0, depth, inputIdx(depth, "image", 0));
depth.connect(0, dmask, inputIdx(dmask, "image", 0));
dmask.connect(0, maskImg, inputIdx(maskImg, "mask", 0));
maskImg.connect(0, maskScale, inputIdx(maskScale, "image", 0));
maskScale.connect(0, maskBack, inputIdx(maskBack, "image", 0));
detailImage.connect(0, comp, inputIdx(comp, "destination", 0));
srcFit.connect(0, comp, inputIdx(comp, "source", 1));
maskBack.connect(0, comp, inputIdx(comp, "mask", 4));
setNamedWidget(comp, "resize_source", false);
protectedIds.push(srcLoader.id, srcUp.id, srcFit.id, depth.id, dmask.id,
maskImg.id, maskScale.id, maskBack.id);
finalImage = comp;
}
}
place(save, finalImage.pos[0] + 280, 0);
finalImage.connect(0, save, 0);
pruneToUpscaleCore(graph, sampler, protectedIds);
if (!options.previewOnly) {
const prefixW = save.widgets?.find((x) => x.name === "filename_prefix");
const prefix = (options.savePrefix || "").trim() || "upscale/upscale";
if (prefixW) { prefixW.value = prefix; prefixW.callback?.(prefix); }
}
// A cleared prompt (empty string) is an explicit choice — override with it so
// the upscale drops the source render's character conditioning instead of
// silently re-detailing every tile with it (the "Ryu's face on a cropped arm"
// bug). Only skip when the modal passed no prompt at all (undefined).
if (typeof options.prompt === "string") {
overrideGraphPrompt(graph, options.prompt);
}
return { graph, workflowId };
}
// Universal floor: pure ESRGAN pass, no conditioning, no checkpoint — works on
// images with no usable metadata (external files, non-PromptChain workflows).
// engine "seedvr2" puts a restoration stage in front; it falls back to the
// plain pair if the cluster can't build (pack uninstalled mid-session).
function buildEsrganFloorGraph(data, savePrefix = "", previewOnly = false, engine = "ultrasharp", climbModel = null, scale = 0) {
if (engine === "seedvr2") {
const built = buildSeedvr2FloorGraph(data, savePrefix, previewOnly, climbModel, scale);
if (built) return built;
}
const LG = window.LiteGraph;
const workflowId = freshWorkflowId();
const graph = new LG.LGraph(structuredClone({ ...EMPTY_WORKFLOW, id: workflowId }));
graph.last_node_id = offscreenIdBase(); // render-node ids must stay off the live canvas — see offscreen-graph.js
const load = LG.createNode("LoadImage");
const loader = LG.createNode("UpscaleModelLoader");
const apply = LG.createNode("ImageUpscaleWithModel");
const save = LG.createNode(previewOnly ? "PreviewImage" : "SaveImage");
if (!load || !loader || !apply || !save) {
toast("error", "Couldn't create the upscale nodes.");
reportClientError("buildEsrganFloorGraph", new Error("createNode returned null"), {
load: !!load, loader: !!loader, apply: !!apply, save: !!save,
});
return null;
}
if (data.filename) load.title = data.filename;
load.pos = [80, 200];
loader.pos = [80, 540];
apply.pos = [500, 240];
graph.add(load); graph.add(loader); graph.add(apply);
setImageWidget(load, data.input_ref);
pickUpscaleModel(loader, climbModel);
load.connect(0, apply, inputIdx(apply, "image", 1));
loader.connect(0, apply, inputIdx(apply, "upscale_model", 0));
// The model upscales by its own fixed factor (e.g. 4x); a lanczos fit lets the
// Scale slider actually target a size (ESRGAN climbs to its factor, then fits
// up/down to source*scale). No scale given → keep the raw model-factor output.
let out = apply;
const w = data.width || 0, h = data.height || 0;
if (scale > 0 && w && h) {
const fit = LG.createNode("ImageScale");
if (fit) {
fit.pos = [780, 240];
graph.add(fit);
setNamedWidget(fit, "upscale_method", "lanczos");
setNamedWidget(fit, "width", Math.round(w * scale));
setNamedWidget(fit, "height", Math.round(h * scale));
apply.connect(0, fit, inputIdx(fit, "image", 0));
out = fit;
}
}
save.pos = [out.pos[0] + 280, 240];
graph.add(save);
const prefix = save.widgets?.find((w) => w.name === "filename_prefix");
if (prefix) { prefix.value = savePrefix || "upscale/upscale"; prefix.callback?.(prefix.value); }
out.connect(0, save, 0);
return { graph, workflowId };
}
// ---------------------------------------------------------------------------
// SDXL tiled re-detail cluster: checkpoint + xinsir tile ControlNet with a
// BLURRED hint + USDU-NoUpscale at final resolution. Shared by the flux2
// finish4k stage and the standalone SDXL-checkpoint engine — the tile rules
// encoded here were paid for in GPU time (see inline comments).
// `place(node, dx, dy)` positions relative to the caller's anchor.
function buildSdxlTileStage(graph, LG, place, opts) {
const reg = LG.registered_node_types || {};
if (!reg["UltimateSDUpscaleNoUpscale"] || !reg["ControlNetApplyAdvanced"]) return null;
const ckpt = LG.createNode("CheckpointLoaderSimple");
const ckptW = ckpt?.widgets?.find((x) => x.name === "ckpt_name");
const ckptOpts = ckptW?.options?.values || [];
// Explicit pick (the engine modal) wins; otherwise the photoreal chain the
// finish pass was tuned on.
let ckptFile = opts.ckptName || null;
if (ckptFile && Array.isArray(ckptOpts) && !ckptOpts.includes(ckptFile)) ckptOpts.push(ckptFile);
if (!ckptFile) {
ckptFile = ckptOpts.find((o) => /juggernaut/i.test(o))
|| ckptOpts.find((o) => /realvis/i.test(o))
|| ckptOpts.find((o) => /epicrealism/i.test(o))
|| ckptOpts.find((o) => /jibmix|skinsupreme/i.test(o));
}
const cnLoader = LG.createNode("ControlNetLoader");
const cnW = cnLoader?.widgets?.find((x) => x.name === "control_net_name");
const cnFile = (cnW?.options?.values || [])
.find((o) => /tile/i.test(o) && /sdxl/i.test(o) && !/flux/i.test(o));
const posEnc = LG.createNode("CLIPTextEncode");
const negEnc = LG.createNode("CLIPTextEncode");
const cnApply = LG.createNode("ControlNetApplyAdvanced");
const hintBlur = LG.createNode("ImageBlur");
const usdu = LG.createNode("UltimateSDUpscaleNoUpscale");
if (!(ckpt && ckptFile && cnLoader && cnFile && posEnc && negEnc && cnApply && hintBlur && usdu)) return null;
place(ckpt, 0, 0);
place(posEnc, 300, -80);
place(negEnc, 300, 100);
place(cnLoader, 300, 280);
place(cnApply, 580, 0);
place(usdu, 860, 280);
ckptW.value = ckptFile; ckptW.callback?.(ckptFile);
cnW.value = cnFile; cnW.callback?.(cnFile);
// Style-only conditioning by default — scene description per tile invites
// hallucination, and TEXTURE NOUNS get painted onto every tile ("fabric
// weave" in this prompt once cross-hatched a whole face). Negating
// "smooth" has the same effect from the other side, so the anti-grid terms
// ride the negative; the tile CN carries the content.
setNamedWidget(posEnc, "text", opts.positiveText || SDXL_TILE_NEUTRAL_POSITIVE);
setNamedWidget(negEnc, "text", opts.negativeText || SDXL_TILE_NEGATIVE);
ckpt.connect(1, posEnc, inputIdx(posEnc, "clip", 0));
ckpt.connect(1, negEnc, inputIdx(negEnc, "clip", 0));
posEnc.connect(0, cnApply, inputIdx(cnApply, "positive", 0));
negEnc.connect(0, cnApply, inputIdx(cnApply, "negative", 1));
cnLoader.connect(0, cnApply, inputIdx(cnApply, "control_net", 2));
// Tile CNs are trained on LOW-FREQUENCY hints — fed a sharp hint they
// enforce its micro-pattern and the sampler paints it crisp (this
// cross-hatched a face). Canonical preprocessing: blur the hint.
place(hintBlur, 580, -200);
setNamedWidget(hintBlur, "blur_radius", 10);
setNamedWidget(hintBlur, "sigma", 4.0);
opts.imageNode.connect(opts.imageSlot || 0, hintBlur, inputIdx(hintBlur, "image", 0));
hintBlur.connect(0, cnApply, inputIdx(cnApply, "image", 3));
setNamedWidget(cnApply, "strength", 0.9);
setNamedWidget(cnApply, "start_percent", 0.0);
setNamedWidget(cnApply, "end_percent", 1.0);
opts.imageNode.connect(opts.imageSlot || 0, usdu, inputIdx(usdu, "upscaled_image", 0));
ckpt.connect(0, usdu, inputIdx(usdu, "model", 1));
cnApply.connect(0, usdu, inputIdx(usdu, "positive", 2));
cnApply.connect(1, usdu, inputIdx(usdu, "negative", 3));
ckpt.connect(2, usdu, inputIdx(usdu, "vae", 4));
// Canonical photoreal-USDU values: 0.25 + SDE on Juggernaut stippled heavy
// fake pores onto smooth skin; 0.15 with a deterministic sampler adds
// texture without repainting the surface character.
setNamedWidget(usdu, "seed", Math.floor(Math.random() * 2 ** 32));
setNamedWidget(usdu, "steps", opts.steps ?? 20);
setNamedWidget(usdu, "cfg", opts.cfg ?? 5);
setNamedWidget(usdu, "sampler_name", opts.sampler || "dpmpp_2m");
setNamedWidget(usdu, "scheduler", opts.scheduler || "karras");
setNamedWidget(usdu, "denoise", typeof opts.denoise === "number" ? opts.denoise : 0.15);
setNamedWidget(usdu, "tile_width", opts.tileWidth ?? 1024);
setNamedWidget(usdu, "tile_height", opts.tileHeight ?? 1024);
setNamedWidget(usdu, "mask_blur", opts.maskBlur ?? 32);
setNamedWidget(usdu, "tile_padding", opts.tilePadding ?? 64);
setNamedWidget(usdu, "seam_fix_mode", opts.seamFixMode || "None");
if (opts.modeType) setNamedWidget(usdu, "mode_type", opts.modeType);
if (typeof opts.seamFixDenoise === "number") setNamedWidget(usdu, "seam_fix_denoise", opts.seamFixDenoise);
setNamedWidget(usdu, "force_uniform_tiles", true);
setNamedWidget(usdu, "tiled_decode", false);
// The ids a pruning caller must protect (side-inputs of the kept chain).
return { usdu, sideInputIds: [ckpt.id, posEnc.id, negEnc.id, cnLoader.id] };
}
// FLUX.1 tile cluster (GPU-proven on Krea): UNET + dual-clip + flux VAE feed
// the USDU directly — no tile ControlNet and no hint blur. Guidance rides
// FluxGuidance on the positive; cfg stays 1.0 so the empty negative is just
// the input the USDU requires.
function buildFlux1TileStage(graph, LG, place, opts) {
const reg = LG.registered_node_types || {};
if (!reg["UltimateSDUpscaleNoUpscale"] || !reg["FluxGuidance"]) return null;
const unet = LG.createNode("UNETLoader");
const unetW = unet?.widgets?.find((x) => x.name === "unet_name");
const unetOpts = unetW?.options?.values || [];
let unetFile = opts.unetName || null;
if (unetFile && Array.isArray(unetOpts) && !unetOpts.includes(unetFile)) unetOpts.push(unetFile);
if (!unetFile) {
// /krea(?!2)/ matches the FLUX.1 "flux1-krea-dev" UNET but must NOT grab
// the separate "krea2_turbo_*" DiT (its own engine, different VAE/CLIP).
unetFile = unetOpts.find((o) => /krea(?!2)/i.test(o))
|| unetOpts.find((o) => /flux1/i.test(o) && !/kontext|schnell|fill|canny|depth|redux/i.test(o));
}
const clip = LG.createNode("DualCLIPLoader");
const clipOpts = clip?.widgets?.find((x) => x.name === "clip_name1")?.options?.values || [];
const clipL = clipOpts.find((o) => /(^|[\\/])clip_l\./i.test(o)) || clipOpts.find((o) => /clip_l/i.test(o));
const t5 = clipOpts.find((o) => /t5xxl.*fp16/i.test(o)) || clipOpts.find((o) => /t5xxl/i.test(o));
const vae = LG.createNode("VAELoader");
const vaeFile = (vae?.widgets?.find((x) => x.name === "vae_name")?.options?.values || [])
.find((o) => /(^|[\\/])ae\.(safetensors|sft)$/i.test(o));
const posEnc = LG.createNode("CLIPTextEncode");
const negEnc = LG.createNode("CLIPTextEncode");
const guidance = LG.createNode("FluxGuidance");
const usdu = LG.createNode("UltimateSDUpscaleNoUpscale");
if (!(unet && unetFile && clip && clipL && t5 && vae && vaeFile && posEnc && negEnc && guidance && usdu)) return null;
place(unet, 0, 0);
place(clip, 0, 180);
place(vae, 0, 360);
place(posEnc, 300, -80);
place(negEnc, 300, 100);
place(guidance, 580, -80);
place(usdu, 860, 280);
setNamedWidget(unet, "unet_name", unetFile);
setNamedWidget(unet, "weight_dtype", "default");
setNamedWidget(clip, "clip_name1", clipL);
setNamedWidget(clip, "clip_name2", t5);
setNamedWidget(clip, "type", "flux");
setNamedWidget(vae, "vae_name", vaeFile);
setNamedWidget(posEnc, "text", opts.positiveText || SDXL_TILE_NEUTRAL_POSITIVE);
setNamedWidget(negEnc, "text", "");
setNamedWidget(guidance, "guidance", FLUX1_TILE_GUIDANCE);
clip.connect(0, posEnc, inputIdx(posEnc, "clip", 0));
clip.connect(0, negEnc, inputIdx(negEnc, "clip", 0));
posEnc.connect(0, guidance, inputIdx(guidance, "conditioning", 0));
opts.imageNode.connect(opts.imageSlot || 0, usdu, inputIdx(usdu, "upscaled_image", 0));
unet.connect(0, usdu, inputIdx(usdu, "model", 1));
guidance.connect(0, usdu, inputIdx(usdu, "positive", 2));
negEnc.connect(0, usdu, inputIdx(usdu, "negative", 3));
vae.connect(0, usdu, inputIdx(usdu, "vae", 4));
setNamedWidget(usdu, "seed", Math.floor(Math.random() * 2 ** 32));
setNamedWidget(usdu, "steps", opts.steps ?? 20);
setNamedWidget(usdu, "cfg", opts.cfg ?? 1.0);
setNamedWidget(usdu, "sampler_name", opts.sampler || "euler");
setNamedWidget(usdu, "scheduler", opts.scheduler || "simple");
setNamedWidget(usdu, "denoise", typeof opts.denoise === "number" ? opts.denoise : 0.2);
setNamedWidget(usdu, "tile_width", opts.tileWidth ?? 1024);
setNamedWidget(usdu, "tile_height", opts.tileHeight ?? 1024);
setNamedWidget(usdu, "mask_blur", opts.maskBlur ?? 32);
setNamedWidget(usdu, "tile_padding", opts.tilePadding ?? 64);
setNamedWidget(usdu, "seam_fix_mode", opts.seamFixMode || "None");
if (opts.modeType) setNamedWidget(usdu, "mode_type", opts.modeType);
if (typeof opts.seamFixDenoise === "number") setNamedWidget(usdu, "seam_fix_denoise", opts.seamFixDenoise);
setNamedWidget(usdu, "force_uniform_tiles", true);
setNamedWidget(usdu, "tiled_decode", false);
return { usdu, sideInputIds: [unet.id, clip.id, vae.id, posEnc.id, negEnc.id, guidance.id] };
}
// Z-Image tile cluster (DiT, mirrors flux1): UNET -> ModelSamplingAuraFlow(3)
// feeds the USDU model directly; CLIP (lumina2) drives a neutral positive +
// empty negative; shared ae VAE. No tile ControlNet and no FluxGuidance —
// Z-Image carries cfg natively. Turbo vs Base differ only in the cfg/steps/
// denoise the modal hands in (per-family defaults from engineModelDefaults).
function buildZImageTileStage(graph, LG, place, opts) {
const reg = LG.registered_node_types || {};
if (!reg["UltimateSDUpscaleNoUpscale"] || !reg["ModelSamplingAuraFlow"]) return null;
const unet = LG.createNode("UNETLoader");
const unetW = unet?.widgets?.find((x) => x.name === "unet_name");
const unetOpts = unetW?.options?.values || [];
let unetFile = opts.unetName || null;
if (unetFile && Array.isArray(unetOpts) && !unetOpts.includes(unetFile)) unetOpts.push(unetFile);
if (!unetFile) {
unetFile = unetOpts.find((o) => /z.?image.*turbo/i.test(o))
|| unetOpts.find((o) => /z.?image/i.test(o));
}
const clip = LG.createNode("CLIPLoader");
const clipFile = (clip?.widgets?.find((x) => x.name === "clip_name")?.options?.values || [])
.find((o) => /qwen.*3[._]4b|lumina2/i.test(o));
const vae = LG.createNode("VAELoader");
const vaeFile = (vae?.widgets?.find((x) => x.name === "vae_name")?.options?.values || [])
.find((o) => /(^|[\\/])ae\.(safetensors|sft)$/i.test(o));
const aura = LG.createNode("ModelSamplingAuraFlow");
const posEnc = LG.createNode("CLIPTextEncode");
const negEnc = LG.createNode("CLIPTextEncode");
const usdu = LG.createNode("UltimateSDUpscaleNoUpscale");
if (!(unet && unetFile && clip && clipFile && vae && vaeFile && aura && posEnc && negEnc && usdu)) return null;
place(unet, 0, 0);
place(clip, 0, 180);
place(vae, 0, 360);
place(aura, 300, 0);
place(posEnc, 580, -80);
place(negEnc, 580, 100);
place(usdu, 860, 280);
setNamedWidget(unet, "unet_name", unetFile);
setNamedWidget(unet, "weight_dtype", "default");
setNamedWidget(clip, "clip_name", clipFile);
setNamedWidget(clip, "type", "lumina2");
setNamedWidget(vae, "vae_name", vaeFile);
setNamedWidget(aura, "shift", 3);
setNamedWidget(posEnc, "text", opts.positiveText || SDXL_TILE_NEUTRAL_POSITIVE);
setNamedWidget(negEnc, "text", "");
unet.connect(0, aura, inputIdx(aura, "model", 0));
clip.connect(0, posEnc, inputIdx(posEnc, "clip", 0));
clip.connect(0, negEnc, inputIdx(negEnc, "clip", 0));
opts.imageNode.connect(opts.imageSlot || 0, usdu, inputIdx(usdu, "upscaled_image", 0));
aura.connect(0, usdu, inputIdx(usdu, "model", 1));
posEnc.connect(0, usdu, inputIdx(usdu, "positive", 2));
negEnc.connect(0, usdu, inputIdx(usdu, "negative", 3));
vae.connect(0, usdu, inputIdx(usdu, "vae", 4));
setNamedWidget(usdu, "seed", Math.floor(Math.random() * 2 ** 32));
setNamedWidget(usdu, "steps", opts.steps ?? 8);
setNamedWidget(usdu, "cfg", opts.cfg ?? 1.3);
setNamedWidget(usdu, "sampler_name", opts.sampler || "res_multistep");
setNamedWidget(usdu, "scheduler", opts.scheduler || "beta");
setNamedWidget(usdu, "denoise", typeof opts.denoise === "number" ? opts.denoise : 0.7);
setNamedWidget(usdu, "tile_width", opts.tileWidth ?? 1024);
setNamedWidget(usdu, "tile_height", opts.tileHeight ?? 1024);
setNamedWidget(usdu, "mask_blur", opts.maskBlur ?? 32);
setNamedWidget(usdu, "tile_padding", opts.tilePadding ?? 64);
setNamedWidget(usdu, "seam_fix_mode", opts.seamFixMode || "None");
if (opts.modeType) setNamedWidget(usdu, "mode_type", opts.modeType);
if (typeof opts.seamFixDenoise === "number") setNamedWidget(usdu, "seam_fix_denoise", opts.seamFixDenoise);
setNamedWidget(usdu, "force_uniform_tiles", true);
setNamedWidget(usdu, "tiled_decode", false);
return { usdu, sideInputIds: [unet.id, clip.id, vae.id, aura.id, posEnc.id, negEnc.id] };
}
// Krea 2 tile cluster (DiT, closest to Z-Image): UNET feeds the USDU model
// DIRECTLY — Krea 2's schedule shift is baked into the model class, so there's
// NO ModelSamplingAuraFlow. CLIP (qwen3vl_4b, type "krea2") drives a neutral
// positive + empty negative; krea2 carries cfg natively (cfg 1.0), so NO
// FluxGuidance and NO tile ControlNet. Turbo-distilled: euler/simple/8 steps.
function buildKrea2TileStage(graph, LG, place, opts) {
const reg = LG.registered_node_types || {};
if (!reg["UltimateSDUpscaleNoUpscale"]) return null;
const unet = LG.createNode("UNETLoader");
const unetW = unet?.widgets?.find((x) => x.name === "unet_name");
const unetOpts = unetW?.options?.values || [];
let unetFile = opts.unetName || null;
if (unetFile && Array.isArray(unetOpts) && !unetOpts.includes(unetFile)) unetOpts.push(unetFile);
if (!unetFile) unetFile = unetOpts.find((o) => /krea2/i.test(o));
const clip = LG.createNode("CLIPLoader");
// Realism mode (krea2_turbo): abliterated Qwen3-VL encoder. The VAE stays
// qwen_image_vae below — a tile decoded through wan_2.1 would seam against the
// qwen-decoded surround (see the VAE note).
const clipNames = clip?.widgets?.find((x) => x.name === "clip_name")?.options?.values || [];
const clipFile = opts.realism
? clipNames.find((o) => /huihui.*qwen3.?vl.*4b.*abliterated|qwen3.?vl.*4b.*abliterated/i.test(o))
: clipNames.find((o) => /qwen3vl.*4b|qwen3.?vl.*4b/i.test(o));
const vae = LG.createNode("VAELoader");
// Use Krea 2's native VAE (qwen_image_vae) for the tile encode AND decode. A
// re-detailed section is composited back into pixels that were generated and
// decoded with qwen_image_vae; decoding the section through any other VAE
// (e.g. wan_2.1_vae) shifts its colour/texture rendition and leaves an
// unblendable seam — and encoding through it lands the tile in a latent the
// Krea 2 UNET reads slightly wrong. A ground-truth plain i2i on the same crop
// is clean with this VAE. Fall back to a Wan 2.1 VAE only if it isn't present.
const vaeOpts = vae?.widgets?.find((x) => x.name === "vae_name")?.options?.values || [];
const vaeFile = vaeOpts.find((o) => /qwen.*image.*vae/i.test(o))
|| vaeOpts.find((o) => /wan.*2[._]?1.*vae/i.test(o) && !/upscale|2x/i.test(o));
const loraNames = (opts.realism && LG.createNode("LoraLoaderModelOnly")?.widgets?.find((x) => x.name === "lora_name")?.options?.values) || [];
const projLora = opts.realism && loraNames.find((o) => /krea2_turbo_projector_scale/i.test(o));
const realLora = opts.realism && loraNames.find((o) => /krea2-realism/i.test(o));
const posEnc = LG.createNode("CLIPTextEncode");
const negEnc = LG.createNode("CLIPTextEncode");
const usdu = LG.createNode("UltimateSDUpscaleNoUpscale");
if (!(unet && unetFile && clip && clipFile && vae && vaeFile && posEnc && negEnc && usdu)) return null;
if (opts.realism && (!projLora || !realLora)) { console.warn("[PromptChain][upscale] Realism mode needs Krea2-realism-V1 + krea2_turbo_projector_scale LoRAs installed"); return null; }
place(unet, 0, 0);
place(clip, 0, 180);
place(vae, 0, 360);
place(posEnc, 300, -80);
place(negEnc, 300, 100);
place(usdu, 860, 280);
setNamedWidget(unet, "unet_name", unetFile);
setNamedWidget(unet, "weight_dtype", "default");
setNamedWidget(clip, "clip_name", clipFile);
setNamedWidget(clip, "type", "krea2");
setNamedWidget(vae, "vae_name", vaeFile);
setNamedWidget(posEnc, "text", opts.positiveText || SDXL_TILE_NEUTRAL_POSITIVE);
setNamedWidget(negEnc, "text", "");
clip.connect(0, posEnc, inputIdx(posEnc, "clip", 0));
clip.connect(0, negEnc, inputIdx(negEnc, "clip", 0));
opts.imageNode.connect(opts.imageSlot || 0, usdu, inputIdx(usdu, "upscaled_image", 0));
let modelNode = unet;
const realismLoras = [];
if (opts.realism && projLora && realLora) {
const lora1 = LG.createNode("LoraLoaderModelOnly");
const lora2 = LG.createNode("LoraLoaderModelOnly");
place(lora1, 0, 540);
place(lora2, 300, 540);
setNamedWidget(lora1, "lora_name", projLora);
setNamedWidget(lora1, "strength_model", 0.5);
setNamedWidget(lora2, "lora_name", realLora);
setNamedWidget(lora2, "strength_model", 0.6);
modelNode.connect(0, lora1, inputIdx(lora1, "model", 0));
lora1.connect(0, lora2, inputIdx(lora2, "model", 0));
modelNode = lora2;
realismLoras.push(lora1.id, lora2.id);
}
modelNode.connect(0, usdu, inputIdx(usdu, "model", 1));
posEnc.connect(0, usdu, inputIdx(usdu, "positive", 2));
negEnc.connect(0, usdu, inputIdx(usdu, "negative", 3));
vae.connect(0, usdu, inputIdx(usdu, "vae", 4));
setNamedWidget(usdu, "seed", Math.floor(Math.random() * 2 ** 32));
setNamedWidget(usdu, "steps", opts.steps ?? 8);
setNamedWidget(usdu, "cfg", opts.cfg ?? 1.0);
setNamedWidget(usdu, "sampler_name", opts.sampler || "er_sde");
setNamedWidget(usdu, "scheduler", opts.scheduler || "simple");
setNamedWidget(usdu, "denoise", typeof opts.denoise === "number" ? opts.denoise : 0.45);
setNamedWidget(usdu, "tile_width", opts.tileWidth ?? 1024);
setNamedWidget(usdu, "tile_height", opts.tileHeight ?? 1024);
setNamedWidget(usdu, "mask_blur", opts.maskBlur ?? 32);
setNamedWidget(usdu, "tile_padding", opts.tilePadding ?? 64);
setNamedWidget(usdu, "seam_fix_mode", opts.seamFixMode || "None");
if (opts.modeType) setNamedWidget(usdu, "mode_type", opts.modeType);
if (typeof opts.seamFixDenoise === "number") setNamedWidget(usdu, "seam_fix_denoise", opts.seamFixDenoise);
setNamedWidget(usdu, "force_uniform_tiles", true);
setNamedWidget(usdu, "tiled_decode", false);
return { usdu, sideInputIds: [unet.id, clip.id, vae.id, posEnc.id, negEnc.id, ...realismLoras] };
}
// The modal's tuning knobs in tile-stage shape — shared by every tile engine.
function tileStageOpts(options) {
return {
denoise: options.denoise,
sampler: options.sampler,
scheduler: options.scheduler,
steps: options.advanced?.steps,
cfg: options.advanced?.cfg,
tileWidth: options.advanced?.tile_width,
tileHeight: options.advanced?.tile_height,
maskBlur: options.advanced?.mask_blur,
tilePadding: options.advanced?.tile_padding,
seamFixMode: options.advanced?.seam_fix_mode,
modeType: options.advanced?.mode_type,
seamFixDenoise: options.advanced?.seam_fix_denoise,
};
}
// Standalone tile-engine core: the picker's "re-detail with a picked model"
// path. An optional SeedVR2 restore, then UltraSharp, climb the source to the
// target; the engine-specific stage re-details every tile at final
// resolution. Fresh graph, no pruning — works on any image, metadata or not,
// and flux2 content never touches flux2 USDU tiling (the engine model has an
// unshifted/mild schedule).
function buildTiledEngineGraph(data, options = {}, buildStage) {
const LG = window.LiteGraph;
const workflowId = freshWorkflowId();
const graph = new LG.LGraph(structuredClone({ ...EMPTY_WORKFLOW, id: workflowId }));
graph.last_node_id = offscreenIdBase(); // render-node ids must stay off the live canvas — see offscreen-graph.js
const placeAt = (node, x, y) => { node.pos = [x, y]; graph.add(node); };
const save = LG.createNode(options.previewOnly ? "PreviewImage" : "SaveImage");
if (!save) return null;
const w = data.width || 0;
const h = data.height || 0;
const longest = Math.max(w, h);
const chosenScale = typeof options.upscaleBy === "number" && options.upscaleBy >= 1
? options.upscaleBy
: longest > 0 ? Math.min(8, Math.max(1, Math.round((4096 / longest) * 100) / 100)) : 2;
const targetW = Math.round(w * chosenScale) || 2048;
const targetH = Math.round(h * chosenScale) || 2048;
// `fit` is the image (already at final/target dims) the engine stage tiles.
// Normally we climb the source here (optional SeedVR2 restore → UltraSharp →
// exact-fit lanczos). On a cache hit the enlarge already ran in a prior
// pre-pass, so load that saved base directly and skip the whole climb.
let fit;
if (options.preEnlargedRef) {
fit = LG.createNode("LoadImage");
if (!fit) return null;
fit.title = "enlarged base (cached)";
placeAt(fit, 740, 200);
setImageWidget(fit, options.preEnlargedRef);
} else {
const load = LG.createNode("LoadImage");
if (!load) return null;
if (data.filename) load.title = data.filename;
placeAt(load, 80, 200);
setImageWidget(load, data.input_ref);
let imageSource = load;
let needsModelClimb = chosenScale > 1.02;
// Optional SeedVR2 restoration in front of the climb: degraded sources
// (webcam blur, jpeg) smear through a plain GAN climb — A/B'd on a 736px
// jpeg, the eye region smeared via UltraSharp but came back clean when
// SeedVR2 rebuilt structure at its trained scale first. The tile CN then
// pins restored structure instead of pinning the smear.
if (options.climbStage === "seedvr2" && LG.registered_node_types?.["SeedVR2VideoUpscaler"]) {
const dit = LG.createNode("SeedVR2LoadDiTModel");
const seedvrVae = LG.createNode("SeedVR2LoadVAEModel");
const restore = LG.createNode("SeedVR2VideoUpscaler");
if (dit && seedvrVae && restore) {
placeAt(dit, 80, 460);
placeAt(seedvrVae, 80, 660);
placeAt(restore, 400, 200);
const ditModels = dit.widgets?.find((x) => x.name === "model")?.options?.values || [];
if (ditModels.length) setNamedWidget(dit, "model", pickSeedvr2DitModel(ditModels));
const targetShort = Math.min(targetW, targetH);
const stageShort = Math.min(SEEDVR2_STAGE_MAX_SHORT_EDGE, targetShort);
setNamedWidget(restore, "resolution", stageShort);
setNamedWidget(restore, "batch_size", 1);
setNamedWidget(restore, "input_noise_scale", SEEDVR2_INPUT_NOISE);
setNamedWidget(restore, "seed", 42); // match buildEnlargePrompt so the cached base == this fused base
load.connect(0, restore, inputIdx(restore, "image", 0));
dit.connect(0, restore, inputIdx(restore, "dit", 1));
seedvrVae.connect(0, restore, inputIdx(restore, "vae", 2));
imageSource = restore;
needsModelClimb = targetShort > stageShort;
}
}
if (needsModelClimb) {
const upLoader = LG.createNode("UpscaleModelLoader");
const upApply = LG.createNode("ImageUpscaleWithModel");
if (!upLoader || !upApply) return null;
placeAt(upLoader, 80, 880);
placeAt(upApply, 460, 460);
pickUpscaleModel(upLoader, options.climbModel);
imageSource.connect(0, upApply, inputIdx(upApply, "image", 1));
upLoader.connect(0, upApply, inputIdx(upApply, "upscale_model", 0));
imageSource = upApply;
}
// The tile pass runs AT final resolution — exact-fit first, then re-detail.
fit = LG.createNode("ImageScale");
if (!fit) return null;
placeAt(fit, 740, 200);
setNamedWidget(fit, "upscale_method", "lanczos");
setNamedWidget(fit, "width", targetW);
setNamedWidget(fit, "height", targetH);
imageSource.connect(0, fit, inputIdx(fit, "image", 0));
}
const stage = buildStage(graph, LG,
(node, dx, dy) => placeAt(node, 1040 + dx, 480 + dy), fit);
if (!stage) return null;
placeAt(save, stage.usdu.pos[0] + 320, stage.usdu.pos[1]);
stage.usdu.connect(0, save, 0);
if (!options.previewOnly) {
const prefixW = save.widgets?.find((x) => x.name === "filename_prefix");
const prefix = (options.savePrefix || "").trim() || "upscale/upscale";
if (prefixW) { prefixW.value = prefix; prefixW.callback?.(prefix); }
}
return { graph, workflowId };
}
// "Re-detail with Juggernaut": picked SDXL checkpoint, structure-locked by
// the tile CN, with the user's prompt.
function buildSdxlCkptEngineGraph(data, options = {}) {
return buildTiledEngineGraph(data, options, (graph, LG, place, fit) =>
buildSdxlTileStage(graph, LG, place, {
imageNode: fit,
ckptName: options.engineModel?.filename || null,
positiveText: (options.prompt || "").trim() || SDXL_TILE_NEUTRAL_POSITIVE,
negativeText: SDXL_TILE_NEGATIVE,
...tileStageOpts(options),
}));
}
// "Re-detail with Krea": picked FLUX.1 UNET in the cfg-1 FluxGuidance regime,
// no structure lock needed.
function buildFlux1EngineGraph(data, options = {}) {
return buildTiledEngineGraph(data, options, (graph, LG, place, fit) =>
buildFlux1TileStage(graph, LG, place, {
imageNode: fit,
unetName: options.engineModel?.filename || null,
positiveText: (options.prompt || "").trim() || SDXL_TILE_NEUTRAL_POSITIVE,
...tileStageOpts(options),
}));
}
// "Re-detail with Z-Image Base/Turbo": picked Z-Image UNET in the
// res_multistep/beta regime. Base (cfg 3, 0.15 denoise) holds structure
// cleanly at low denoise; Turbo (cfg 1.3, 0.7) repaints harder. No tile CN.
function buildZImageEngineGraph(data, options = {}) {
return buildTiledEngineGraph(data, options, (graph, LG, place, fit) =>
buildZImageTileStage(graph, LG, place, {
imageNode: fit,
unetName: options.engineModel?.filename || null,
positiveText: (options.prompt || "").trim() || SDXL_TILE_NEUTRAL_POSITIVE,
...tileStageOpts(options),
}));
}
// "Re-detail with Krea 2": picked Krea 2 Turbo UNET in the cfg-1/euler/simple
// 8-step regime, model wired straight to the USDU (no ModelSampling, no
// FluxGuidance, no tile CN). 0.45 denoise re-details while holding structure.
function buildKrea2EngineGraph(data, options = {}) {
return buildTiledEngineGraph(data, options, (graph, LG, place, fit) =>
buildKrea2TileStage(graph, LG, place, {
imageNode: fit,
unetName: options.engineModel?.filename || null,
positiveText: (options.prompt || "").trim() || SDXL_TILE_NEUTRAL_POSITIVE,
realism: !!options.realism,
...tileStageOpts(options),
}));
}
// Standalone Qwen-Image-Edit engine: instruction-driven whole-frame re-render
// at ~2MP (the model's working size — bigger grows plastic skin, and the
// stock encoder downsamples its conditioning copy regardless), then the
// UltraSharp climb + exact fit to the requested target. Pixel fidelity is
// NOT guaranteed — Qwen Edit has a known sub-pixel shift; the modal says so.
function buildQwenEditEngineGraph(data, options = {}) {
const LG = window.LiteGraph;
const reg = LG.registered_node_types || {};
if (!reg["TextEncodeQwenImageEditPlus"] || !reg["FluxKontextMultiReferenceLatentMethod"]
|| !reg["ModelSamplingAuraFlow"] || !reg["CFGNorm"]) return null;
const workflowId = freshWorkflowId();
const graph = new LG.LGraph(structuredClone({ ...EMPTY_WORKFLOW, id: workflowId }));
graph.last_node_id = offscreenIdBase(); // render-node ids must stay off the live canvas — see offscreen-graph.js
const placeAt = (node, x, y) => { node.pos = [x, y]; graph.add(node); };
const load = LG.createNode("LoadImage");
const stageScale = LG.createNode("ImageScaleToTotalPixels");
const unet = LG.createNode("UNETLoader");
const clip = LG.createNode("CLIPLoader");
const vae = LG.createNode("VAELoader");
const aura = LG.createNode("ModelSamplingAuraFlow");
const norm = LG.createNode("CFGNorm");
const posEnc = LG.createNode("TextEncodeQwenImageEditPlus");
const negEnc = LG.createNode("TextEncodeQwenImageEditPlus");
const refPos = LG.createNode("FluxKontextMultiReferenceLatentMethod");
const refNeg = LG.createNode("FluxKontextMultiReferenceLatentMethod");
const encode = LG.createNode("VAEEncode");
const sampler = LG.createNode("KSampler");
const decode = LG.createNode("VAEDecode");
const save = LG.createNode(options.previewOnly ? "PreviewImage" : "SaveImage");
if (!load || !stageScale || !unet || !clip || !vae || !aura || !norm || !posEnc || !negEnc
|| !refPos || !refNeg || !encode || !sampler || !decode || !save) return null;
const unetW = unet.widgets?.find((x) => x.name === "unet_name");
const unetFile = options.engineModel?.filename
|| (unetW?.options?.values || []).find((o) => /qwen.*image.*edit/i.test(o));
const clipW = clip.widgets?.find((x) => x.name === "clip_name");
const clipFile = (clipW?.options?.values || []).find((o) => /qwen.*2[._]5.*vl/i.test(o));
const vaeW = vae.widgets?.find((x) => x.name === "vae_name");
const vaeFile = (vaeW?.options?.values || []).find((o) => /qwen.*image.*vae/i.test(o));
if (!unetW || !unetFile || !clipW || !clipFile || !vaeW || !vaeFile) return null;
if (Array.isArray(unetW.options?.values) && !unetW.options.values.includes(unetFile)) {
unetW.options.values.push(unetFile);
}
unetW.value = unetFile; unetW.callback?.(unetFile);
clipW.value = clipFile; clipW.callback?.(clipFile);
setNamedWidget(clip, "type", "qwen_image");
vaeW.value = vaeFile; vaeW.callback?.(vaeFile);
if (data.filename) load.title = data.filename;
placeAt(load, 80, 200);
placeAt(stageScale, 420, 200);
placeAt(unet, 80, 700);
placeAt(clip, 80, 860);
placeAt(vae, 80, 1020);
placeAt(aura, 420, 700);
placeAt(norm, 420, 860);
placeAt(posEnc, 760, 420);
placeAt(negEnc, 760, 680);
placeAt(refPos, 1080, 420);
placeAt(refNeg, 1080, 680);
placeAt(encode, 760, 200);
placeAt(sampler, 1400, 420);
placeAt(decode, 1720, 200);
setImageWidget(load, data.input_ref);
setNamedWidget(stageScale, "upscale_method", "lanczos");
setNamedWidget(stageScale, "megapixels", QWEN_EDIT_STAGE_MP);
setNamedWidget(aura, "shift", 3);
setNamedWidget(norm, "strength", 1);
setNamedWidget(posEnc, "prompt", (options.prompt || "").trim() || QWEN_ENHANCE_INSTRUCTION);
setNamedWidget(negEnc, "prompt", "");
for (const ref of [refPos, refNeg]) {
const methodW = ref.widgets?.[0];
if (methodW) { methodW.value = "index_timestep_zero"; methodW.callback?.(methodW.value); }
}
setNamedWidget(sampler, "seed", Math.floor(Math.random() * 2 ** 32));
setNamedWidget(sampler, "steps", options.advanced?.steps ?? 40);
setNamedWidget(sampler, "cfg", options.advanced?.cfg ?? 4);
setNamedWidget(sampler, "sampler_name", options.sampler || "euler");
setNamedWidget(sampler, "scheduler", options.scheduler || "simple");
// Denoise stays 1.0: the conditioning image anchors the content; partial
// denoise just blends the sub-pixel-shifted re-render with the original.
setNamedWidget(sampler, "denoise", 1.0);
load.connect(0, stageScale, inputIdx(stageScale, "image", 0));
clip.connect(0, posEnc, inputIdx(posEnc, "clip", 0));
clip.connect(0, negEnc, inputIdx(negEnc, "clip", 0));
vae.connect(0, posEnc, inputIdx(posEnc, "vae", 1));
vae.connect(0, negEnc, inputIdx(negEnc, "vae", 1));
stageScale.connect(0, posEnc, inputIdx(posEnc, "image1", 2));
stageScale.connect(0, negEnc, inputIdx(negEnc, "image1", 2));
stageScale.connect(0, encode, inputIdx(encode, "pixels", 0));
vae.connect(0, encode, inputIdx(encode, "vae", 1));
posEnc.connect(0, refPos, inputIdx(refPos, "conditioning", 0));
negEnc.connect(0, refNeg, inputIdx(refNeg, "conditioning", 0));
unet.connect(0, aura, inputIdx(aura, "model", 0));
aura.connect(0, norm, inputIdx(norm, "model", 0));
norm.connect(0, sampler, inputIdx(sampler, "model", 0));
refPos.connect(0, sampler, inputIdx(sampler, "positive", 1));
refNeg.connect(0, sampler, inputIdx(sampler, "negative", 2));
encode.connect(0, sampler, inputIdx(sampler, "latent_image", 3));
sampler.connect(0, decode, inputIdx(decode, "samples", 0));
vae.connect(0, decode, inputIdx(decode, "vae", 1));
const w = data.width || 0;
const h = data.height || 0;
const longest = Math.max(w, h);
const chosenScale = typeof options.upscaleBy === "number" && options.upscaleBy >= 1
? options.upscaleBy
: longest > 0 ? Math.min(8, Math.max(1, Math.round((4096 / longest) * 100) / 100)) : 2;
const targetW = Math.round(w * chosenScale) || 2048;
const targetH = Math.round(h * chosenScale) || 2048;
let imageSource = decode;
if (targetW * targetH > QWEN_EDIT_STAGE_MP * 1e6 * 1.05) {
const upLoader = LG.createNode("UpscaleModelLoader");
const upApply = LG.createNode("ImageUpscaleWithModel");
if (!upLoader || !upApply) return null;
placeAt(upLoader, 1720, 420);
placeAt(upApply, 2040, 200);
pickUpscaleModel(upLoader, options.climbModel);
decode.connect(0, upApply, inputIdx(upApply, "image", 1));
upLoader.connect(0, upApply, inputIdx(upApply, "upscale_model", 0));
imageSource = upApply;
}
// Exact-fit either way — the qwen stage rescales by total pixels, so its
// dims never equal the requested target on their own.
const fit = LG.createNode("ImageScale");
if (!fit) return null;
placeAt(fit, imageSource.pos[0] + 320, 200);
setNamedWidget(fit, "upscale_method", "lanczos");
setNamedWidget(fit, "width", targetW);
setNamedWidget(fit, "height", targetH);
imageSource.connect(0, fit, inputIdx(fit, "image", 0));
placeAt(save, fit.pos[0] + 300, 200);
fit.connect(0, save, 0);
if (!options.previewOnly) {
const prefixW = save.widgets?.find((x) => x.name === "filename_prefix");
const prefix = (options.savePrefix || "").trim() || "upscale/upscale";
if (prefixW) { prefixW.value = prefix; prefixW.callback?.(prefix); }
}
return { graph, workflowId };
}
// Build the complete upscale graft on an OFF-SCREEN LGraph — the user's tabs
// are NEVER involved. The old temp-tab dance once replaced the user's open
// workflow with the graft (frontend tab-reuse semantics shifted underneath
// it), so isolation is now structural: this graph has no canvas, no tab, no
// path, and is discarded after serialization. Returns { graph, workflowId }.
export async function buildUpscaleGraph(prepared, options = {}) {
const { data, caps } = prepared;
try {
const savePrefix = (options.savePrefix || "").trim();
const engine = options.engine || "source";
// Picker engines build standalone graphs — any source image works,
// graftable or not.
if (engine === "sdxl-ckpt" || engine === "qwen-edit" || engine === "flux1-unet"
|| engine === "zimage-unet" || engine === "krea2-unet") {
const built = engine === "sdxl-ckpt"
? buildSdxlCkptEngineGraph(data, { ...options, savePrefix })
: engine === "flux1-unet"
? buildFlux1EngineGraph(data, { ...options, savePrefix })
: engine === "zimage-unet"
? buildZImageEngineGraph(data, { ...options, savePrefix })
: engine === "krea2-unet"
? buildKrea2EngineGraph(data, { ...options, savePrefix })
: buildQwenEditEngineGraph(data, { ...options, savePrefix });
if (built) return built;
toast("warn", "Couldn't build that upscale engine — using a plain model upscale instead.");
return buildEsrganFloorGraph(data, savePrefix, options.previewOnly, "ultrasharp", options.climbModel);
}
// Plain engines are explicit main-engine choices now; non-graftable
// images keep them as their only option.
if (!caps.graftable || engine === "ultrasharp" || engine === "seedvr2") {
const longest = Math.max(data.width || 0, data.height || 0);
const scale = typeof options.upscaleBy === "number" && options.upscaleBy >= 1
? options.upscaleBy
: longest > 0 ? Math.min(8, Math.max(1, Math.round((4096 / longest) * 100) / 100)) : 2;
return buildEsrganFloorGraph(data, savePrefix, options.previewOnly, engine, options.climbModel, scale);
}
const mode = options.mode || caps.defaultMode || "prompt";
const depthSource = options.depthSource || caps.defaultDepthSource || "pose";
// The embedded JSON still carries the SOURCE workflow's id — keep the
// graft a separate workflow, or its outputs get recorded into the source
// workflow's timeline (left/right must stay locked to its own outputs).
// JSON round-trip: prepared lives in Svelte $state by the time the modal
// confirms, which deep-proxies it — and litegraph's _configureBase
// structuredClones workflow.extra, which THROWS DataCloneError on
// proxies. The workflow originated as server JSON, so this is lossless.
const workflowId = freshWorkflowId();
const workflowJson = JSON.parse(JSON.stringify({ ...data.workflow, id: workflowId }));
const LG = window.LiteGraph;
const graph = new LG.LGraph();
graph.configure(workflowJson);
let usNode = graph._nodes.find((n) => n.comfyClass === "UltimateSDUpscale");
if (!usNode) {
const pcNode = graph._nodes.find((n) => n.comfyClass === "PromptChain_PromptChain");
const config = await fetchModelConfig(graph);
// Flux2 never goes through USDU — tiling it is structurally broken
// (see buildFlux2RefineGraph). Full-frame reference-anchored refine.
if ((config?.architecture || caps.architecture) === "flux2") {
const refined = buildFlux2RefineGraph(graph, workflowId, data, { ...options, savePrefix }, config);
if (refined) return refined;
toast("warn", "Couldn't build the Flux 2 refine — using a plain model upscale instead.");
return buildEsrganFloorGraph(data, savePrefix, options.previewOnly, options.engine, options.climbModel);
}
if (!pcNode || !injectUpscaler(pcNode, config, { regional: mode === "regional", pinFlux2TileModel: true })) {
console.warn(`[PromptChain][upscale] source graft -> PLAIN fallback: pcNode=${pcNode?.id ?? "NONE"} arch=${config?.architecture || caps.architecture} graphTypes=[${graph._nodes.map((n) => n.comfyClass).join(",")}]`);
toast("warn", "No attachment point in this image's workflow — using a plain model upscale instead.");
return buildEsrganFloorGraph(data, savePrefix, options.previewOnly, options.engine, options.climbModel);
}
usNode = graph._nodes.find((n) => n.comfyClass === "UltimateSDUpscale");
} else if (mode === "regional") {
ensureRegionalConditioning(graph, LG, usNode);
}
// Krea 2 renders carry the Qwen-Image VAE, whose decoder bakes a periodic
// grid/scale artifact into every tile USDU re-decodes. The Wan 2.1 VAE
// shares the SAME frozen encoder (tiles encode to an identical Krea2-
// compatible latent) but its decoder has no grid artifact — repoint the
// grafted decode to it so the source-model upscale comes out clean, same as
// the standalone Krea2 tile engine. Excludes the 2x variant (12-ch/2x output
// would break tiling); only fires for a Krea 2 source on the Qwen VAE.
const isKrea2Source = caps.architecture === "krea2"
|| graph._nodes.some((n) => n.comfyClass === "CLIPLoader"
&& n.widgets?.find((w) => w.name === "type")?.value === "krea2");
if (isKrea2Source) {
for (const vl of graph._nodes.filter((n) => n.comfyClass === "VAELoader")) {
const vaeW = vl.widgets?.find((w) => w.name === "vae_name");
if (!vaeW || !/qwen.*image.*vae/i.test(vaeW.value || "")) continue;
const wan = (vaeW.options?.values || [])
.find((o) => /wan.*2[._]?1.*vae/i.test(o) && !/upscale|2x/i.test(o));
if (wan) { vaeW.value = wan; vaeW.callback?.(wan); }
}
}
if (mode === "regional" && usNode) {
// The installed USDU port's crop_mask only remaps the FIRST crop region
// of a batch — batched tiles would smear every region mask across the
// whole batch. One tile per sample keeps the per-tile mask crops exact.
setNamedWidget(usNode, "batch_size", 1);
}
// The LoadImage goes in before shaping/pruning: depth-from-image hints off
// the same node that feeds the USDU, and the prune protects it explicitly
// (the image input is otherwise excluded from the keep closure).
const imageIdx = inputIdx(usNode, "image", 0);
usNode.disconnectInput(imageIdx);
const loadNode = LG.createNode("LoadImage");
if (loadNode) {
// The input copy's content-hash name is opaque — title the node with the
// source filename so it's obvious which image this workflow upscales.
if (data.filename) loadNode.title = data.filename;
loadNode.pos = [usNode.pos[0] - 380, usNode.pos[1]];
graph.add(loadNode);
setImageWidget(loadNode, data.input_ref);
loadNode.connect(0, usNode, imageIdx);
}
if (mode === "prompt") {
rewireToBaseConditioning(graph, usNode);
} else if (loadNode) {
// depth mode hints from the chosen source; regional always pins to the
// 3D pose render (its regions come from the same scene).
ensureDepthBranch(graph, LG, usNode, mode === "regional" ? "pose" : depthSource, loadNode);
}
// After the cond/depth wiring settles: an Edit crop selection needs the
// pose masks + depth render cropped to its rect. No-op unless the poser
// actually feeds this graft (prompt mode rewires past it).
if (data.editRect) {
cropSceneToEditRect(graph, LG, usNode, data.editRect);
}
if (loadNode && options.condition && options.condition !== "none") {
injectUpscaleCondition(graph, LG, usNode, loadNode, options.condition);
}
pruneToUpscaleCore(graph, usNode, loadNode ? [loadNode.id] : []);
if (options.previewOnly) swapSaveForPreview(graph, usNode, LG);
else setSavePrefix(graph, usNode, savePrefix);
if (typeof options.prompt === "string" && options.prompt.trim()) {
overrideGraphPrompt(graph, options.prompt);
}
// Scale from the ACTUAL image dimensions, not the workflow's latent — an
// already-upscaled input would otherwise re-multiply toward 16K tiles.
// Modal sliders (background mode) override both knobs when provided.
const longest = Math.max(data.width || 0, data.height || 0);
const scaleW = usNode.widgets?.find((x) => x.name === "upscale_by");
const chosenScale = typeof options.upscaleBy === "number" && options.upscaleBy >= 1
? options.upscaleBy
: longest > 0 ? Math.min(8, Math.max(1, Math.round((4096 / longest) * 100) / 100)) : null;
if (scaleW && chosenScale != null) {
scaleW.value = chosenScale;
scaleW.callback?.(chosenScale);
}
const denoiseW = usNode.widgets?.find((x) => x.name === "denoise");
if (denoiseW && typeof options.denoise === "number") {
denoiseW.value = options.denoise;
denoiseW.callback?.(options.denoise);
}
for (const [optKey, widgetName] of [["sampler", "sampler_name"], ["scheduler", "scheduler"]]) {
const choice = options[optKey];
const w = usNode.widgets?.find((x) => x.name === widgetName);
if (w && typeof choice === "string" && choice) {
w.value = choice;
w.callback?.(choice);
}
}
// Advanced overrides carry only the fields the user actually touched in
// the modal — untouched ones keep whatever the build/source already set.
if (options.advanced && typeof options.advanced === "object") {
const ALLOWED = new Set([
"steps", "cfg", "tile_width", "tile_height", "mask_blur",
"tile_padding", "mode_type", "seam_fix_mode", "seam_fix_denoise",
]);
for (const [name, value] of Object.entries(options.advanced)) {
if (!ALLOWED.has(name) || value == null) continue;
const w = usNode.widgets?.find((x) => x.name === name);
if (w) { w.value = value; w.callback?.(value); }
}
}
return { graph, workflowId };
} catch (e) {
// The prepare step can't see build failures — surface them, or the modal's
// confirm just silently does nothing.
console.error("[PromptChain] upscale graph build failed", e);
toast("error", `Couldn't build the upscale workflow: ${e.message}`);
reportClientError("buildUpscaleGraph", e, {
mode: options.mode || prepared.caps?.defaultMode || "prompt",
condition: options.condition || "none",
previewOnly: !!options.previewOnly,
filename: prepared.data?.filename || "",
input_ref: prepared.data?.input_ref || "",
});
return null;
}
}
// "Create Workflow": the one DELIBERATE tab — built off-screen first, then
// displayed. The user explicitly asked for a tab here; Apply and the Edit
// handoff never come through this path.
export async function buildUpscaleWorkflow(prepared, options = {}) {
const built = await buildUpscaleGraph(prepared, options);
if (!built) return false;
const tabName = `upscale ${prepared.data.filename || ""}`.trim();
await app.loadGraphData(built.graph.serialize(), true, true, tabName);
return true;
}