Ticking Realism on a Krea 2 Turbo i2i edit now drops the author's ClownsharKSampler_Beta recipe defaults (sampler/steps/cfg/denoise) into the dials, while still honoring any value the user has guided away from - the same preset pattern as the 'engine' picker. Unticking restores the engine defaults. Realism still always swaps in the abliterated encoder, wan VAE, and the projector+realism LoRAs regardless of sampler choice.
559 lines
28 KiB
JavaScript
559 lines
28 KiB
JavaScript
// Editor "i2i": full-frame image-to-image. The Edit modal hands its flattened
|
|
// composite (already snapped to a /16 canvas by openI2iHandoff, so the VAE's
|
|
// own center-crop is a no-op and the result composites back aligned) to a clean
|
|
// LoadImage → VAEEncode → KSampler(denoise) → VAEDecode → PreviewImage
|
|
// graph — no mask, no tiling, no crop. The render runs off-screen (PreviewImage
|
|
// → temp dir, the user's tabs untouched) and returns to Edit as a new layer.
|
|
//
|
|
// This is the no-mask sibling of the inpaint subsystem: it reuses prepareInpaint
|
|
// (wrapped) for caps + the engine list, the same content-addressed upload, the
|
|
// same loader clusters per engine, and the same background-tracker lifecycle.
|
|
// The one net-new piece is the clean graph TAIL — the inpaint builders all end
|
|
// in PromptChain_MaskedDetail (crop-and-detail), which would downscale a full
|
|
// frame to its max_size; i2i samples the whole frame instead.
|
|
|
|
import { app } from "../../../scripts/app.js";
|
|
import { api } from "../../../scripts/api.js";
|
|
import { externallyTrackedPrompts, armExternalQueue, disarmExternalQueue } from "./history.js";
|
|
import { prepareInpaint, sourcePromptText, createTracker, freshWorkflowId } from "./inpaint-background.js";
|
|
import { offscreenIdBase } from "./offscreen-graph.js";
|
|
|
|
function toast(severity, detail) {
|
|
app.extensionManager?.toast?.add({ severity, summary: "i2i", detail, life: 7000 });
|
|
}
|
|
|
|
const numOr = (v, fallback) => (typeof v === "number" && Number.isFinite(v) ? v : fallback);
|
|
|
|
// Per-engine i2i recipe (denoise/steps/cfg/sampler/scheduler), read off the live
|
|
// data/templates/*-i2i.json this session. The modal seeds its dials from this;
|
|
// the builders only fall back to it when an option is missing. Keyed by an
|
|
// architecture family; krea2/zimage split raw-vs-turbo by filename.
|
|
export const I2I_RECIPE_DEFAULTS = {
|
|
sdxl: { denoise: 0.55, steps: 35, cfg: 4.0, sampler: "dpmpp_2m_sde", scheduler: "karras" },
|
|
flux1: { denoise: 0.55, steps: 25, cfg: 1.0, sampler: "euler", scheduler: "simple", guidance: 3.5 },
|
|
flux2: { denoise: 0.55, steps: 25, cfg: 1.0, sampler: "euler", scheduler: "simple", guidance: 3.5 },
|
|
krea2_raw: { denoise: 0.35, steps: 40, cfg: 3.5, sampler: "er_sde", scheduler: "beta" },
|
|
krea2_turbo: { denoise: 0.35, steps: 8, cfg: 1.0, sampler: "euler", scheduler: "simple" },
|
|
zimage_base: { denoise: 0.55, steps: 50, cfg: 4.0, sampler: "dpmpp_sde", scheduler: "beta" },
|
|
zimage_turbo: { denoise: 0.55, steps: 9, cfg: 1.3, sampler: "res_multistep", scheduler: "beta" },
|
|
generic: { denoise: 0.50, steps: 30, cfg: 4.0, sampler: "euler", scheduler: "normal" },
|
|
};
|
|
|
|
// The single source of truth for i2i defaults: resolve the recipe for an
|
|
// architecture/filename, layering the model's own saved gen recipe where it's
|
|
// meaningful. Returns a FRESH object (never a shared I2I_RECIPE_DEFAULTS ref).
|
|
// `gen` = the model config's t2i KSampler settings (from fetchEngineModels).
|
|
// SDXL honors its stored steps/cfg/sampler/scheduler; distilled DiTs
|
|
// (flux/krea2/zimage) have arch-fixed recipes a stored t2i gen shouldn't
|
|
// override, except flux's step count. The modal reads this via caps — it has no
|
|
// table of its own (no drift).
|
|
export function i2iRecipe(architecture, filename = "", gen = null) {
|
|
const a = String(architecture || "").toLowerCase();
|
|
const turbo = /turbo/i.test(filename);
|
|
const raw = /raw/i.test(filename) && !turbo;
|
|
let base;
|
|
if (a === "krea2") base = raw ? I2I_RECIPE_DEFAULTS.krea2_raw : I2I_RECIPE_DEFAULTS.krea2_turbo;
|
|
else if (a === "sdxl") base = I2I_RECIPE_DEFAULTS.sdxl;
|
|
else if (a === "flux" || a === "flux1") base = I2I_RECIPE_DEFAULTS.flux1;
|
|
else if (a === "flux2") base = I2I_RECIPE_DEFAULTS.flux2;
|
|
else if (a === "zimage" || a === "z-image") base = turbo ? I2I_RECIPE_DEFAULTS.zimage_turbo : I2I_RECIPE_DEFAULTS.zimage_base;
|
|
else base = I2I_RECIPE_DEFAULTS.generic;
|
|
const r = { ...base };
|
|
if (gen) {
|
|
if (a === "sdxl") {
|
|
if (Number.isFinite(gen.steps)) r.steps = gen.steps;
|
|
if (Number.isFinite(gen.cfg)) r.cfg = gen.cfg;
|
|
if (gen.sampler) r.sampler = gen.sampler;
|
|
if (gen.scheduler) r.scheduler = gen.scheduler;
|
|
} else if ((a === "flux" || a === "flux1") && Number.isFinite(gen.steps)) {
|
|
r.steps = gen.steps;
|
|
}
|
|
}
|
|
return r;
|
|
}
|
|
|
|
// Caps for the i2i modal: the inpaint caps minus everything mask-spatial. Full
|
|
// frame has no region to resolve, so regional/depth modes and the conditions
|
|
// panel (which patch the UNET around a mask) don't apply.
|
|
export async function prepareI2I(entry) {
|
|
const prepared = await prepareInpaint(entry);
|
|
if (!prepared) return null;
|
|
prepared.caps.regionalAvailable = false;
|
|
prepared.caps.depthAvailable = false;
|
|
// P0 engine roster: Source + SDXL / FLUX.1 / Krea 2 (true denoise i2i). Qwen
|
|
// Edit is an instruction edit at denoise 1.0 — a different feature, deferred.
|
|
// Stamp each engine with its resolved i2i recipe (the modal reads .i2i — no
|
|
// recipe table lives in the component).
|
|
prepared.caps.engineModels = (prepared.caps.engineModels || [])
|
|
.filter((m) => m.architecture !== "qwen_edit")
|
|
.map((m) => ({ ...m, i2i: i2iRecipe(m.architecture, m.filename, m.gen) }));
|
|
// The Source engine's recipe (only its denoise is used — steps/cfg/sampler are
|
|
// inherited from the image's own embedded render). Drives the default denoise,
|
|
// so a krea2 source opens at krea2's i2i denoise.
|
|
prepared.caps.sourceI2iRecipe = i2iRecipe(prepared.caps.sourceModelInfo?.architecture || "", "", null);
|
|
prepared.caps.defaultDenoise = prepared.caps.sourceI2iRecipe.denoise ?? 0.55;
|
|
return prepared;
|
|
}
|
|
|
|
// ── graph builders ────────────────────────────────────────────────────────
|
|
|
|
function probeCombo(type, widget) {
|
|
try {
|
|
const node = window.LiteGraph?.createNode?.(type);
|
|
return node?.widgets?.find((w) => w.name === widget)?.options?.values || [];
|
|
} catch { return []; }
|
|
}
|
|
|
|
const PC_NODE = () => ({
|
|
prompt: "", mode: "combine", switch_index: 1,
|
|
locked: false, disabled: false, cached_output: "", cached_neg_output: "",
|
|
iterate_index: 0, iterate_cycle: 1, collapsed: false,
|
|
wildcard_modes: "", cached_regions: "",
|
|
});
|
|
|
|
function makeAdder(graph) {
|
|
// Render-node ids start far above every live-canvas id so ComfyUI's id-keyed
|
|
// output painter never lands this background render on a visible node (see
|
|
// offscreen-graph.js). Mirrors the inpaint builders.
|
|
let nextId = offscreenIdBase();
|
|
return (class_type, inputs) => {
|
|
const id = String(nextId++);
|
|
graph[id] = { class_type, inputs };
|
|
return id;
|
|
};
|
|
}
|
|
|
|
// Shared clean tail: encode the loaded image, partially denoise it, decode,
|
|
// preview. `modelRef`/`clipRef`/`vaeRef` are [id, slot] links into `graph`.
|
|
function attachI2ITail(graph, add, { inputRef, modelRef, clipRef, vaeRef, options, guidance, steps, cfg, sampler, scheduler, clown }) {
|
|
const loadId = add("LoadImage", { image: inputRef });
|
|
const encId = add("VAEEncode", { pixels: [loadId, 0], vae: vaeRef });
|
|
const pc = PC_NODE(); pc.prompt = options.prompt || "";
|
|
const pcId = add("PromptChain_PromptChain", pc);
|
|
let posRef = [add("CLIPTextEncode", { text: [pcId, 1], clip: clipRef }), 0];
|
|
const negRef = [add("CLIPTextEncode", { text: [pcId, 2], clip: clipRef }), 0];
|
|
if (typeof guidance === "number") {
|
|
posRef = [add("FluxGuidance", { conditioning: posRef, guidance }), 0];
|
|
}
|
|
const denoise = typeof options.denoise === "number" ? options.denoise : 0.55;
|
|
// Realism full-frame i2i uses the author's ClownsharKSampler_Beta (RES4LYF) —
|
|
// the same node the realism t2i/i2i templates use. Full-frame (not a composited
|
|
// patch), so a sampler NODE is allowed here. cfg pinned 1 like Turbo.
|
|
const ksId = clown
|
|
? add("ClownsharKSampler_Beta", {
|
|
model: modelRef, positive: posRef, negative: negRef, latent_image: [encId, 0],
|
|
eta: 0.5, sampler_name: "exponential/ddim", scheduler: "beta57",
|
|
steps: steps ?? 8, steps_to_run: -1, denoise, cfg: cfg ?? 1.0,
|
|
seed: Math.floor(Math.random() * 0xffffffffff), sampler_mode: "standard", bongmath: false,
|
|
})
|
|
: add("KSampler", {
|
|
model: modelRef, positive: posRef, negative: negRef, latent_image: [encId, 0],
|
|
seed: Math.floor(Math.random() * 0xffffffffff),
|
|
steps, cfg, sampler_name: sampler, scheduler, denoise,
|
|
});
|
|
const decId = add("VAEDecode", { samples: [ksId, 0], vae: vaeRef });
|
|
const previewId = add("PreviewImage", { images: [decId, 0] });
|
|
return { ksId, previewId };
|
|
}
|
|
|
|
// SDXL picker engine: a fresh checkpoint i2i — no embedded render data needed,
|
|
// so plain photos / imported images work too. Mirrors buildInpaintEnginePrompt
|
|
// upstream (checkpoint + two encodes), clean tail.
|
|
export function buildI2IEngineSdxl(data, options) {
|
|
const filename = options.engineModel?.filename;
|
|
if (!filename) throw new Error("no engine model picked");
|
|
const graph = {};
|
|
const add = makeAdder(graph);
|
|
const ckptId = add("CheckpointLoaderSimple", { ckpt_name: filename });
|
|
const gen = options.engineGen || {};
|
|
const { ksId } = attachI2ITail(graph, add, {
|
|
inputRef: data.input_ref, modelRef: [ckptId, 0], clipRef: [ckptId, 1], vaeRef: [ckptId, 2], options,
|
|
steps: numOr(options.steps, numOr(gen.steps, 35)),
|
|
cfg: numOr(options.cfg, numOr(gen.cfg, 4)),
|
|
sampler: options.sampler || gen.sampler || "dpmpp_2m_sde",
|
|
scheduler: options.scheduler || gen.scheduler || "karras",
|
|
});
|
|
return { graph, detailId: ksId };
|
|
}
|
|
|
|
// FLUX.1 picker engine: guidance rides FluxGuidance on the positive, KSampler
|
|
// cfg pinned 1.0 (a real cfg would double sampling). Mirrors buildFlux1InpaintPrompt.
|
|
export function buildI2IEngineFlux1(data, options) {
|
|
const filename = options.engineModel?.filename;
|
|
if (!filename) throw new Error("no engine model picked");
|
|
const clipOpts = probeCombo("DualCLIPLoader", "clip_name1");
|
|
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 vaeFile = probeCombo("VAELoader", "vae_name").find((o) => /(^|[\\/])ae\.(safetensors|sft)$/i.test(o));
|
|
if (!clipL || !t5 || !vaeFile) throw new Error("the FLUX text encoders / VAE aren't installed");
|
|
const graph = {};
|
|
const add = makeAdder(graph);
|
|
const unetId = add("UNETLoader", { unet_name: filename, weight_dtype: "default" });
|
|
const clipId = add("DualCLIPLoader", { clip_name1: clipL, clip_name2: t5, type: "flux", device: "default" });
|
|
const vaeId = add("VAELoader", { vae_name: vaeFile });
|
|
const gen = options.engineGen || {};
|
|
const { ksId } = attachI2ITail(graph, add, {
|
|
inputRef: data.input_ref, modelRef: [unetId, 0], clipRef: [clipId, 0], vaeRef: [vaeId, 0], options,
|
|
guidance: I2I_RECIPE_DEFAULTS.flux1.guidance,
|
|
steps: numOr(options.steps, numOr(gen.steps, 25)),
|
|
cfg: 1.0,
|
|
sampler: options.sampler || gen.sampler || "euler",
|
|
scheduler: options.scheduler || gen.scheduler || "simple",
|
|
});
|
|
return { graph, detailId: ksId };
|
|
}
|
|
|
|
// Krea 2 picker engine: a DiT that wires like FLUX.1 minus the guidance regime
|
|
// (cfg-native, no FluxGuidance). RAW needs the resolution shift + full steps +
|
|
// real cfg; Turbo is the pinned 8-step/cfg-1 recipe. Mirrors buildKrea2InpaintPrompt.
|
|
export function buildI2IEngineKrea2(data, options) {
|
|
const filename = options.engineModel?.filename;
|
|
if (!filename) throw new Error("no engine model picked");
|
|
// Realism mode (krea2_turbo): abliterated Qwen3-VL encoder + wan_2.1 VAE +
|
|
// projector/realism LoRAs + the author's ClownsharKSampler_Beta tail. i2i is
|
|
// full-frame (a whole new layer, not a composited patch), so wan VAE is safe.
|
|
const realism = !!options.realism;
|
|
const clipOpts = probeCombo("CLIPLoader", "clip_name");
|
|
const loraOpts = probeCombo("LoraLoaderModelOnly", "lora_name");
|
|
const clipFile = realism
|
|
? clipOpts.find((o) => /huihui.*qwen3.?vl.*4b.*abliterated|qwen3.?vl.*4b.*abliterated/i.test(o))
|
|
: (clipOpts.find((o) => /qwen3vl.*4b/i.test(o)) || clipOpts.find((o) => /qwen3.?vl.*4b/i.test(o)));
|
|
const vaeFile = realism
|
|
? probeCombo("VAELoader", "vae_name").find((o) => /wan.*2[._]?1.*vae/i.test(o) && !/upscale|2x/i.test(o))
|
|
: probeCombo("VAELoader", "vae_name").find((o) => /qwen.*image.*vae/i.test(o));
|
|
const projLora = realism && loraOpts.find((o) => /krea2_turbo_projector_scale/i.test(o));
|
|
const realLora = realism && loraOpts.find((o) => /krea2-realism/i.test(o));
|
|
if (!clipFile || !vaeFile) throw new Error(realism
|
|
? "Realism mode needs the abliterated Qwen3-VL encoder + wan_2.1 VAE installed"
|
|
: "the Krea 2 text encoder / VAE aren't installed");
|
|
if (realism && (!projLora || !realLora)) throw new Error("Realism mode needs the Krea2-realism-V1 + krea2_turbo_projector_scale LoRAs installed");
|
|
const graph = {};
|
|
const add = makeAdder(graph);
|
|
const unetId = add("UNETLoader", { unet_name: filename, weight_dtype: "default" });
|
|
const clipId = add("CLIPLoader", { clip_name: clipFile, type: "krea2", device: "default" });
|
|
const vaeId = add("VAELoader", { vae_name: vaeFile });
|
|
const raw = /raw/i.test(filename) && !/turbo/i.test(filename);
|
|
let modelRef = [unetId, 0];
|
|
if (raw) {
|
|
// RAW over-shifts on the baked 1.15 mu — replace it with a resolution-aware
|
|
// shift (the same node the raw t2i/i2i templates insert). i2i has no
|
|
// MaskedDetail crop, so the sampled latent IS the input size — feed those
|
|
// real dims, not a stale 1024², or the mu schedule is wrong for non-square /
|
|
// non-1024 inputs.
|
|
const msfId = add("ModelSamplingFlux", {
|
|
model: [unetId, 0], max_shift: 0.90625, base_shift: 0.5,
|
|
width: data.width || 1024, height: data.height || 1024,
|
|
});
|
|
modelRef = [msfId, 0];
|
|
}
|
|
if (realism) {
|
|
const proj = add("LoraLoaderModelOnly", { model: modelRef, lora_name: projLora, strength_model: 0.5 });
|
|
const real = add("LoraLoaderModelOnly", { model: [proj, 0], lora_name: realLora, strength_model: 0.6 });
|
|
modelRef = [real, 0];
|
|
}
|
|
const { ksId } = attachI2ITail(graph, add, {
|
|
inputRef: data.input_ref, modelRef, clipRef: [clipId, 0], vaeRef: [vaeId, 0], options,
|
|
// Realism defaults to the author's Clown sampler, but the user can pick a
|
|
// normal sampler in the modal to override it — then run a plain KSampler.
|
|
clown: realism && (!options.sampler || /clownshar/i.test(options.sampler)),
|
|
steps: numOr(options.steps, raw ? 40 : 8),
|
|
cfg: numOr(options.cfg, raw ? 3.5 : 1.0),
|
|
sampler: options.sampler || (raw ? "er_sde" : "euler"),
|
|
scheduler: options.scheduler || (raw ? "beta" : "simple"),
|
|
});
|
|
return { graph, detailId: ksId };
|
|
}
|
|
|
|
// Architecture of the image's own render graph — picks the conditioning regime
|
|
// for the source graft (which model stream / guidance the fresh encodes feed).
|
|
// Mirrors inpaint-background's inpaintArch.
|
|
function sourceArch(entries) {
|
|
const has = (...cls) => entries.some((n) => cls.includes(n?.class_type));
|
|
if (has("CheckpointLoaderSimple", "CheckpointLoader")) return "sdxl";
|
|
const clipType = String(entries.find((n) => n?.class_type === "CLIPLoader")?.inputs?.type || "").toLowerCase();
|
|
if (clipType === "flux2") return "flux2";
|
|
if (clipType === "krea2") return "krea2";
|
|
if (clipType === "lumina2") return "zimage";
|
|
if (has("DualCLIPLoader") || clipType === "flux" || has("UNETLoader", "UnetLoaderGGUF")) return "flux1";
|
|
return "other";
|
|
}
|
|
|
|
// Source graft: re-diffuse the image with ITS OWN model. Traces the model / clip
|
|
// / vae out of the embedded API prompt (the truest "this image, more diffused")
|
|
// and attaches the clean i2i tail, applying the arch's conditioning regime.
|
|
// Mirrors buildInpaintPrompt's tracing; swaps the MaskedDetail tail for the
|
|
// plain encode/sample/decode. Throws (engine picker is the fallback) if the
|
|
// graph can't be traced.
|
|
export function buildI2ISource(apiPrompt, data, options) {
|
|
const graph = {};
|
|
for (const [id, entry] of Object.entries(apiPrompt || {})) {
|
|
if (!entry || typeof entry.class_type !== "string") continue;
|
|
graph[id] = { class_type: entry.class_type, inputs: JSON.parse(JSON.stringify(entry.inputs || {})) };
|
|
}
|
|
const idByClass = (...cls) => Object.keys(graph).find((id) => cls.includes(graph[id].class_type));
|
|
const isLink = (v) => Array.isArray(v) && v.length === 2 && !!graph[String(v[0])];
|
|
|
|
const SAMPLER_CLASSES = ["KSampler", "KSamplerAdvanced", "PromptChain_MaskedDetail", "UltimateSDUpscale"];
|
|
const anchorId = Object.keys(graph).find((id) => SAMPLER_CLASSES.includes(graph[id].class_type));
|
|
if (!anchorId) throw new Error("no sampler in this image's render data");
|
|
const src = graph[anchorId];
|
|
|
|
// Trace to the unpatched model (keep LoRA loaders / ModelSampling shims; drop
|
|
// only the AttentionCouple, which bakes the source gen's regional prompts).
|
|
let modelRef = src.inputs.model;
|
|
while (isLink(modelRef) && graph[String(modelRef[0])].class_type === "PromptChain_AttentionCouple") {
|
|
modelRef = graph[String(modelRef[0])].inputs.model;
|
|
}
|
|
if (!isLink(modelRef)) throw new Error("the sampler has no model wired");
|
|
|
|
const ckptId = idByClass("CheckpointLoaderSimple", "CheckpointLoader");
|
|
const encNodeId = idByClass("CLIPTextEncode");
|
|
const regionalId = idByClass("PromptChain_AttentionCouple", "PromptChain_RegionalConditioning");
|
|
const clipLoaderId = idByClass("CLIPLoader");
|
|
const clipRef = (encNodeId && isLink(graph[encNodeId].inputs.clip) && graph[encNodeId].inputs.clip)
|
|
|| (regionalId && isLink(graph[regionalId].inputs.clip) && graph[regionalId].inputs.clip)
|
|
|| (clipLoaderId && [clipLoaderId, 0])
|
|
|| (ckptId && [ckptId, 1]);
|
|
const decId = idByClass("VAEDecode", "VAEUtils_VAEDecodeTiled", "VAEDecodeTiled");
|
|
let vaeRef = (decId && isLink(graph[decId].inputs.vae) && graph[decId].inputs.vae)
|
|
|| (isLink(src.inputs.vae) && src.inputs.vae)
|
|
|| (ckptId && [ckptId, 2]);
|
|
if (!clipRef) throw new Error("couldn't trace the clip/vae lines");
|
|
|
|
const add = makeAdder(graph);
|
|
|
|
// A decode-only / 2x custom VAE loader can't ENCODE — i2i needs both. Graft a
|
|
// stock VAELoader on the shared latent if the trace failed or hit that loader.
|
|
const tracedVae = isLink(vaeRef) ? graph[String(vaeRef[0])] : null;
|
|
if (!vaeRef || (tracedVae && tracedVae.class_type === "VAEUtils_CustomVAELoader")) {
|
|
const vaeName = probeCombo("VAELoader", "vae_name").find((o) => /qwen.*image.*vae/i.test(o))
|
|
|| probeCombo("VAELoader", "vae_name").find((o) => /wan.*2[._]?1.*vae/i.test(o) && !/upscale|2x/i.test(o));
|
|
if (!vaeName) throw new Error("couldn't trace the clip/vae lines");
|
|
vaeRef = [add("VAELoader", { vae_name: vaeName }), 0];
|
|
}
|
|
|
|
const arch = sourceArch(Object.values(graph));
|
|
const fluxLike = arch === "flux1" || arch === "flux2";
|
|
const { ksId, previewId } = attachI2ITail(graph, add, {
|
|
inputRef: data.input_ref, modelRef, clipRef, vaeRef, options,
|
|
guidance: fluxLike ? I2I_RECIPE_DEFAULTS.flux1.guidance : undefined,
|
|
steps: numOr(options.steps, numOr(src.inputs.steps, 30)),
|
|
// flux pins cfg 1.0 (guidance carries it); other arches inherit the dial.
|
|
cfg: fluxLike ? 1.0 : numOr(options.cfg, numOr(src.inputs.cfg, 5)),
|
|
sampler: options.sampler || (typeof src.inputs.sampler_name === "string" ? src.inputs.sampler_name : "euler"),
|
|
scheduler: options.scheduler || (typeof src.inputs.scheduler === "string" ? src.inputs.scheduler : "normal"),
|
|
});
|
|
|
|
// Prune to the new PreviewImage's upstream closure — the source gen (its
|
|
// sampler, pose, couple, old encodes, saves, and ITS own PreviewImage/saves)
|
|
// all falls away. Walk from the terminal preview so VAEDecode/KSampler/loaders
|
|
// are all retained.
|
|
const keep = new Set();
|
|
const stack = [previewId];
|
|
while (stack.length) {
|
|
const id = stack.pop();
|
|
if (!id || keep.has(id)) continue;
|
|
keep.add(id);
|
|
for (const v of Object.values(graph[id].inputs || {})) {
|
|
if (isLink(v)) stack.push(String(v[0]));
|
|
}
|
|
}
|
|
for (const id of Object.keys(graph)) if (!keep.has(id)) delete graph[id];
|
|
|
|
return { graph, detailId: ksId };
|
|
}
|
|
|
|
function buildI2IGraph(data, options) {
|
|
if (options.engine === "sdxl-ckpt" && options.engineModel) return buildI2IEngineSdxl(data, options);
|
|
if (options.engine === "flux1-unet" && options.engineModel) return buildI2IEngineFlux1(data, options);
|
|
if (options.engine === "krea2-unet" && options.engineModel) return buildI2IEngineKrea2(data, options);
|
|
return buildI2ISource(data.prompt, data, options);
|
|
}
|
|
|
|
function collectI2IMeta(graph, detailId, data, options) {
|
|
const byClass = (...cls) => Object.values(graph).find((n) => cls.includes(n.class_type));
|
|
const loader = byClass("CheckpointLoaderSimple", "CheckpointLoader") || byClass("UNETLoader");
|
|
const lit = (v) => (Array.isArray(v) || v === undefined ? null : v);
|
|
const ks = graph[detailId].inputs;
|
|
return {
|
|
parent_filename: data.input_ref,
|
|
model: lit(loader?.inputs?.ckpt_name) || lit(loader?.inputs?.unet_name) || null,
|
|
seed: lit(ks.seed), steps: lit(ks.steps), cfg: lit(ks.cfg),
|
|
sampler: lit(ks.sampler_name), scheduler: lit(ks.scheduler),
|
|
denoise: options.denoise ?? null, prompt: options.prompt || "", negative: "",
|
|
};
|
|
}
|
|
|
|
// Background i2i render. Mirrors runInpaint's lifecycle, with two differences:
|
|
// no mask upload, and listeners are registered BEFORE the queue with a pending
|
|
// buffer that replays once prompt_id is known — a full-frame i2i can be served
|
|
// from cache (~0s), and an execution_success arriving before queuePrompt's
|
|
// response sets prompt_id would otherwise be dropped and the promise would hang
|
|
// (the bug just fixed in enlargeImageInBackground). runInpaint registers after
|
|
// the queue and lacks this guard; i2i must not inherit that race.
|
|
export function runI2IInBackground(prepared, options = {}) {
|
|
const tracker = createTracker();
|
|
const run = { cancelled: false, promptId: null, executionStarted: false, cleanup: null };
|
|
tracker.cancel = async () => {
|
|
if (run.cancelled) return;
|
|
run.cancelled = true;
|
|
tracker.emit({ phase: "cancelled" });
|
|
try {
|
|
if (run.promptId) {
|
|
if (run.executionStarted) await api.interrupt();
|
|
else {
|
|
await api.fetchApi("/queue", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ delete: [run.promptId] }),
|
|
});
|
|
run.cleanup?.();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn("[PromptChain] i2i cancel failed", e);
|
|
}
|
|
};
|
|
|
|
(async () => {
|
|
const { data } = prepared;
|
|
tracker.emit({ phase: "building" });
|
|
let built;
|
|
try {
|
|
built = buildI2IGraph(data, options);
|
|
} catch (e) {
|
|
console.error("[PromptChain][i2i] build failed:", e);
|
|
tracker.emit({ phase: "error", message: `build failed: ${e.message}` });
|
|
toast("error", `i2i build failed: ${e.message}`);
|
|
return;
|
|
}
|
|
if (run.cancelled) return;
|
|
|
|
tracker.emit({ phase: "queueing" });
|
|
const workflowId = freshWorkflowId();
|
|
const workflowExtra = data.workflow ? { ...data.workflow, id: workflowId } : undefined;
|
|
|
|
let promptId = null;
|
|
let idKnown = false;
|
|
let meta = null;
|
|
let lastTemp = null;
|
|
let previewUrl = null;
|
|
const pending = [];
|
|
const setPreview = (blob) => {
|
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
previewUrl = blob ? URL.createObjectURL(blob) : null;
|
|
return previewUrl;
|
|
};
|
|
const cleanup = () => {
|
|
api.removeEventListener("execution_start", onStart);
|
|
api.removeEventListener("progress", onProgress);
|
|
api.removeEventListener("b_preview", onPreview);
|
|
api.removeEventListener("executed", onExecuted);
|
|
api.removeEventListener("execution_success", onSuccess);
|
|
api.removeEventListener("execution_error", onError);
|
|
api.removeEventListener("execution_interrupted", onError);
|
|
if (promptId) externallyTrackedPrompts.delete(promptId);
|
|
setPreview(null);
|
|
};
|
|
run.cleanup = cleanup;
|
|
|
|
const takeStart = (d) => { if (d?.prompt_id === promptId) run.executionStarted = true; };
|
|
const takeProgress = (d) => {
|
|
if (d?.prompt_id !== promptId || run.cancelled) return;
|
|
run.executionStarted = true;
|
|
tracker.emit({ phase: "running", value: d.value, max: d.max, previewUrl });
|
|
};
|
|
const takeExecuted = (d) => {
|
|
if (d?.prompt_id !== promptId || !d?.output?.images?.length) return;
|
|
lastTemp = d.output.images[d.output.images.length - 1];
|
|
};
|
|
const takeSuccess = (d) => {
|
|
if (d?.prompt_id !== promptId) return;
|
|
cleanup();
|
|
if (run.cancelled) return;
|
|
if (!lastTemp) { tracker.emit({ phase: "error", message: "run produced no image" }); return; }
|
|
tracker.emit({ phase: "done", temp: lastTemp, meta, workflowId });
|
|
};
|
|
const takeError = (d) => {
|
|
if (d?.prompt_id !== promptId) return;
|
|
cleanup();
|
|
if (run.cancelled) return;
|
|
const message = d?.exception_message || "execution failed";
|
|
tracker.emit({ phase: "error", message });
|
|
toast("error", `i2i failed: ${message}`);
|
|
};
|
|
|
|
// b_preview carries no prompt_id and self-gates on run.executionStarted, so
|
|
// it's safe to handle live. The prompt_id-matching events buffer until the id
|
|
// is known, then replay.
|
|
const onStart = ({ detail }) => idKnown ? takeStart(detail) : pending.push(["start", detail]);
|
|
const onProgress = ({ detail }) => idKnown ? takeProgress(detail) : pending.push(["progress", detail]);
|
|
const onExecuted = ({ detail }) => idKnown ? takeExecuted(detail) : pending.push(["executed", detail]);
|
|
const onSuccess = ({ detail }) => idKnown ? takeSuccess(detail) : pending.push(["success", detail]);
|
|
const onError = ({ detail }) => idKnown ? takeError(detail) : pending.push(["error", detail]);
|
|
const onPreview = ({ detail }) => {
|
|
if (run.cancelled || !run.executionStarted || !(detail instanceof Blob)) return;
|
|
const url = setPreview(detail);
|
|
const s = tracker.state;
|
|
if (s.phase === "running") tracker.emit({ ...s, previewUrl: url });
|
|
};
|
|
api.addEventListener("execution_start", onStart);
|
|
api.addEventListener("progress", onProgress);
|
|
api.addEventListener("b_preview", onPreview);
|
|
api.addEventListener("executed", onExecuted);
|
|
api.addEventListener("execution_success", onSuccess);
|
|
api.addEventListener("execution_error", onError);
|
|
api.addEventListener("execution_interrupted", onError);
|
|
|
|
armExternalQueue();
|
|
try {
|
|
const previewMethod = app.extensionManager?.setting?.get?.("Comfy.Execution.PreviewMethod")
|
|
?? app.ui?.settings?.getSettingValue?.("Comfy.Execution.PreviewMethod")
|
|
?? "default";
|
|
const res = await api.queuePrompt(0, { output: built.graph, workflow: workflowExtra }, { previewMethod });
|
|
promptId = res?.prompt_id || null;
|
|
} catch (e) {
|
|
disarmExternalQueue();
|
|
cleanup();
|
|
const message = e?.response?.error?.message || e?.message || "queue rejected the prompt";
|
|
tracker.emit({ phase: "error", message });
|
|
toast("error", `i2i failed to queue: ${message}`);
|
|
return;
|
|
}
|
|
if (!promptId) {
|
|
disarmExternalQueue();
|
|
cleanup();
|
|
tracker.emit({ phase: "error", message: "queue returned no prompt id" });
|
|
return;
|
|
}
|
|
externallyTrackedPrompts.add(promptId);
|
|
disarmExternalQueue();
|
|
run.promptId = promptId;
|
|
meta = collectI2IMeta(built.graph, built.detailId, data, options);
|
|
if (run.cancelled) {
|
|
try {
|
|
await api.fetchApi("/queue", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ delete: [promptId] }),
|
|
});
|
|
} catch {}
|
|
cleanup();
|
|
return;
|
|
}
|
|
tracker.emit({ phase: "running" });
|
|
|
|
// The id is known — drain anything that arrived first (a cached run can have
|
|
// already fired success). executed before success so lastTemp is set.
|
|
idKnown = true;
|
|
for (const [k, d] of pending) if (k === "start") takeStart(d);
|
|
for (const [k, d] of pending) if (k === "progress") takeProgress(d);
|
|
for (const [k, d] of pending) if (k === "executed") takeExecuted(d);
|
|
for (const [k, d] of pending) { if (k === "success") takeSuccess(d); else if (k === "error") takeError(d); }
|
|
pending.length = 0;
|
|
})().catch((e) => {
|
|
console.error("[PromptChain] i2i run failed", e);
|
|
tracker.emit({ phase: "error", message: e?.message || "unexpected failure" });
|
|
toast("error", `i2i failed: ${e?.message || e}`);
|
|
});
|
|
return tracker;
|
|
}
|