- 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.
2511 lines
124 KiB
JavaScript
2511 lines
124 KiB
JavaScript
// 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;
|
||
}
|