Files
chrmc40 deb1ed1eb3 Krea 2 Realism (i2i modal): make the Realism checkbox behave as a preset
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.
2026-06-27 17:35:59 -07:00

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;
}