Files

607 lines
30 KiB
JavaScript

// Viewer "Upscale → Apply": build the same grafted upscale graph the
// "Create Workflow" path produces, but on an OFF-SCREEN LGraph, queue it, and
// track execution — the user's workflow tabs are NEVER touched. (The old
// temp-tab build once replaced the user's open workflow with the graft.)
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { getLink } from "./slot-utils.js";
import {
buildUpscaleGraph,
logUpscaleRun,
pickSeedvr2DitModel,
SEEDVR2_STAGE_MAX_SHORT_EDGE,
SEEDVR2_INPUT_NOISE,
} from "./upscale-from-image.js";
import { recordGeneration, externallyTrackedPrompts, armExternalQueue, disarmExternalQueue } from "./history.js";
function toast(severity, detail, life = 8000) {
app.extensionManager?.toast?.add({ severity, summary: "Upscale", detail, life });
}
// Minimal observable the viewer modal subscribes to for live progress.
function createTracker() {
const listeners = new Set();
const tracker = {
state: { phase: "building" },
subscribe(fn) {
listeners.add(fn);
fn(tracker.state);
return () => listeners.delete(fn);
},
emit(state) {
tracker.state = state;
for (const fn of listeners) {
try { fn(state); } catch {}
}
},
};
return tracker;
}
// How many sampled tiles this run will execute, mirroring the USDU script's
// own job counting (ultimate-upscale.py): main pass rows*cols, plus the seam
// pass per seam_fix_mode. Lets the modal show OVERALL progress — the ws
// progress events are per-tile sampler bars that reset every tile.
function computeTilePlan(graph, data, options = {}) {
const usNode = graph._nodes.find((n) => n.comfyClass === "UltimateSDUpscale" || n.comfyClass === "UltimateSDUpscaleNoUpscale");
if (!usNode) return { steps: 0, totalTiles: 0 };
const w = (name) => usNode.widgets?.find((x) => x.name === name)?.value;
const scale = Number(w("upscale_by")) || 2;
const tileW = Number(w("tile_width")) || 1024;
const tileH = Number(w("tile_height")) || 1024;
let outW = (data.width || 0) * scale;
let outH = (data.height || 0) * scale;
if (usNode.comfyClass === "UltimateSDUpscaleNoUpscale") {
if (options.preEnlargedW && options.preEnlargedH) {
// Cache hit: upscaled_image is a LoadImage of the cached base (no width/
// height widgets to read), so take the resolved target dims from the cache.
outW = options.preEnlargedW; outH = options.preEnlargedH;
} else {
// NoUpscale tiles whatever arrives on upscaled_image — the final dims
// live on the exact-fit ImageScale feeding it.
const input = usNode.inputs?.find((i) => i.name === "upscaled_image");
const link = input?.link != null ? getLink(graph, input.link) : null;
const origin = link && graph.getNodeById(link.origin_id);
outW = Number(origin?.widgets?.find((x) => x.name === "width")?.value) || (data.width || 0);
outH = Number(origin?.widgets?.find((x) => x.name === "height")?.value) || (data.height || 0);
}
}
const rows = Math.ceil(outH / tileH) || 1;
const cols = Math.ceil(outW / tileW) || 1;
let tiles = rows * cols;
const mode = String(w("seam_fix_mode") || "None");
if (mode === "Band Pass") tiles += rows + cols - 2;
else if (mode === "Half Tile") tiles += rows * (cols - 1) + (rows - 1) * cols;
else if (mode === "Half Tile + Intersections") tiles += rows * (cols - 1) + (rows - 1) * cols + (rows - 1) * (cols - 1);
const loader = graph._nodes.find((n) => n.comfyClass === "UpscaleModelLoader");
const upscaleModel = String(loader?.widgets?.find((x) => x.name === "model_name")?.value || "")
.replace(/\.(pth|safetensors)$/i, "");
return { steps: Number(w("steps")) || 0, totalTiles: Math.max(1, tiles), upscaleModel };
}
// Generation metadata for the history record, read off the live temp graph
// before it closes. Matches what main.js records for foreground runs.
function collectRecordMeta(graph, data) {
const widget = (node, name) => node?.widgets?.find((w) => w.name === name)?.value;
const usNode = graph._nodes.find((n) => n.comfyClass === "UltimateSDUpscale" || n.comfyClass === "UltimateSDUpscaleNoUpscale");
// Picker-engine graphs sample through a plain KSampler (qwen) instead.
const sNode = usNode || graph._nodes.find((n) => n.comfyClass === "KSampler");
const loader = graph._nodes.find((n) => n.comfyClass === "CheckpointLoaderSimple" || n.comfyClass === "CheckpointLoader")
|| graph._nodes.find((n) => n.comfyClass === "UNETLoader");
const pcNode = graph._nodes.find((n) => n.comfyClass === "PromptChain_PromptChain");
let prompt = pcNode?.properties?.pcrCompiledOutput || "";
let negative = pcNode?.properties?.pcrCompiledNegOutput || "";
if (!pcNode && sNode) {
// Engine graphs carry raw text encodes — walk the conditioning chain
// (CN apply / reference-latent hops) back to the node with the text.
const textOf = (n) => {
const t = n?.widgets?.find((x) => x.name === "text" || x.name === "prompt")?.value;
return typeof t === "string" ? t : null;
};
const trace = (inputName) => {
let node = sNode;
let name = inputName;
for (let hop = 0; hop < 4 && node; hop++) {
const input = node.inputs?.find((x) => x.name === name);
const link = input?.link != null ? getLink(graph, input.link) : null;
node = link && graph.getNodeById(link.origin_id);
const text = textOf(node);
if (text != null) return text;
if (node?.inputs?.some((x) => x.name === "conditioning")) name = "conditioning";
}
return "";
};
prompt = trace("positive");
negative = trace("negative");
}
return {
parent_filename: data.input_ref,
model: widget(loader, "ckpt_name") || widget(loader, "unet_name") || null,
seed: widget(sNode, "seed") ?? null,
steps: widget(sNode, "steps") ?? null,
cfg: widget(sNode, "cfg") ?? null,
sampler: widget(sNode, "sampler_name") || null,
scheduler: widget(sNode, "scheduler") || null,
denoise: widget(sNode, "denoise") ?? null,
prompt,
negative,
};
}
function watchExecution({ promptId, workflowId, meta, plan, startedAt, tracker, run, enlargedUrl }) {
let lastImage = null;
// Tile bookkeeping: each sampled tile runs its own progress bar (1..steps),
// so a value reset = next tile. Bars with a different max are the model
// (ESRGAN) pass, reported separately. Sibling-bar maxes can coincidentally
// equal steps only if the source is tiny — acceptable.
let tileIdx = 0;
let lastValue = Infinity;
let sampling = false;
let samplingStartedAt = 0;
let previewUrl = null;
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);
externallyTrackedPrompts.delete(promptId);
setPreview(null);
};
if (run) run.cleanup = cleanup; // cancel of a still-pending prompt gets no ws event
const onStart = ({ detail }) => {
if (detail?.prompt_id === promptId && run) run.executionStarted = true;
};
const onProgress = ({ detail }) => {
if (detail?.prompt_id !== promptId || run?.cancelled) return; // a late step event must not re-emit "running" after Stop
if (run) run.executionStarted = true;
if (plan.steps > 0 && detail.max === plan.steps) {
if (!sampling) samplingStartedAt = Date.now();
sampling = true;
if (detail.value <= lastValue) tileIdx = Math.min(tileIdx + 1, plan.totalTiles);
lastValue = detail.value;
// ETA from observed throughput: elapsed sampling time scaled by the
// remaining fraction. Self-correcting; suppressed until enough of the
// run has happened to be meaningful.
const fraction = ((tileIdx - 1) + detail.value / detail.max) / plan.totalTiles;
let etaSec = null;
if (fraction > 0.02) {
etaSec = Math.round(((Date.now() - samplingStartedAt) / 1000) * (1 - fraction) / fraction);
}
tracker.emit({
phase: "running", value: detail.value, max: detail.max,
tile: tileIdx, totalTiles: plan.totalTiles, etaSec, previewUrl,
});
} else {
// model-upscale pass (or any non-sampler bar)
tracker.emit({ phase: "running", prep: true, model: plan.upscaleModel, value: detail.value, max: detail.max, previewUrl });
}
};
const onPreview = ({ detail }) => {
// b_preview carries no prompt_id, but the server executes one prompt at a
// time — previews arriving while OUR tiles sample are ours.
if (run?.cancelled || !sampling || !(detail instanceof Blob)) return;
const url = setPreview(detail);
const s = tracker.state;
if (s.phase === "running") tracker.emit({ ...s, previewUrl: url });
};
const onExecuted = ({ detail }) => {
if (detail?.prompt_id !== promptId || !detail?.output?.images?.length) return;
// Edit-handoff runs render to PreviewImage (temp) — keep whatever arrives;
// only output-type images get a history record below.
lastImage = detail.output.images[detail.output.images.length - 1];
};
const onSuccess = async ({ detail }) => {
if (detail?.prompt_id !== promptId) return;
if (run?.cancelled) { cleanup(); return; } // finished after Stop — don't record or resurrect "done"
let entry = null;
if (lastImage && workflowId && (lastImage.type || "output") === "output") {
entry = await recordGeneration(workflowId, lastImage.filename, lastImage.subfolder || "", lastImage.type || "output", meta);
}
cleanup();
const elapsedSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
tracker.emit({
phase: "done",
resultHash: entry?.hash || null,
filename: lastImage?.filename || null,
subfolder: lastImage?.subfolder || "",
type: lastImage?.type || "output",
// The finished render, for the modal's done screen — the live preview
// blob is gone (revoked in cleanup) and the pre-run preview is the OLD
// image, which read as "it showed me the wrong result".
resultUrl: lastImage
? api.apiURL(`/view?filename=${encodeURIComponent(lastImage.filename)}&subfolder=${encodeURIComponent(lastImage.subfolder || "")}&type=${lastImage.type || "output"}&rand=${Math.random()}`)
: null,
// The enlarged base the tiles re-detailed — the compare's "Before" (so you see
// the tile pass's contribution over the sharpened base, not vs the raw source).
enlargedUrl: enlargedUrl || null,
elapsedSec,
});
toast("success", `Upscale finished in ${elapsedSec}s${lastImage ? `: ${lastImage.filename}` : ""}.`);
};
const onError = ({ detail }) => {
if (detail?.prompt_id !== promptId) return;
cleanup();
if (run?.cancelled) return; // user-initiated interrupt — already announced
const message = detail?.exception_message || "execution failed";
tracker.emit({ phase: "error", message });
toast("error", `Upscale failed: ${message}`);
};
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);
}
function comboValues(nodeType, widgetName) {
const probe = window.LiteGraph?.createNode?.(nodeType);
return probe?.widgets?.find((w) => w.name === widgetName)?.options?.values || [];
}
// API-format enlarge prompt: ESRGAN up → lanczos fit to exact dims. With
// engine "seedvr2" a restoration stage runs first at the model's trained
// scale (≤1440 short edge, paper caps eval at 1080p) with a little injected
// noise (clean renders carry no degradation for it to invert), and the ESRGAN
// climb only happens when the target is still further out. Exported for the
// headless build-only harness.
export function buildEnlargePrompt(inputRef, targetW, targetH, engine = "ultrasharp", climbModel = null) {
const models = comboValues("UpscaleModelLoader", "model_name");
// Mirror upscale-from-image.js defaultUpscaleModelPick: exclude UltraSharpV2 (its
// micro-speckle becomes tile-CN cross-hatch) so the cached base matches the fused build.
const modelName = (climbModel && models.includes(climbModel) ? climbModel : null)
|| models.find((o) => /ultrasharp(?!v2)/i.test(o)) || models.find((o) => /ultrasharp/i.test(o))
|| models.find((o) => /^4x/i.test(o)) || models[0];
if (!modelName) throw new Error("no upscale model installed");
if (engine === "seedvr2") {
const ditModels = comboValues("SeedVR2LoadDiTModel", "model");
const vaeModels = comboValues("SeedVR2LoadVAEModel", "model");
if (ditModels.length && vaeModels.length) {
const ditName = pickSeedvr2DitModel(ditModels);
const vaeName = vaeModels.find((o) => /fp16/i.test(o)) || vaeModels[0];
const targetShort = Math.min(targetW, targetH);
const stageShort = Math.min(SEEDVR2_STAGE_MAX_SHORT_EDGE, targetShort);
const output = {
1: { class_type: "LoadImage", inputs: { image: inputRef } },
2: { class_type: "SeedVR2LoadDiTModel", inputs: { model: ditName, device: "cuda:0" } },
3: { class_type: "SeedVR2LoadVAEModel", inputs: { model: vaeName, device: "cuda:0" } },
4: {
class_type: "SeedVR2VideoUpscaler",
inputs: {
image: ["1", 0], dit: ["2", 0], vae: ["3", 0],
seed: 42, resolution: stageShort, max_resolution: 0,
batch_size: 1, uniform_batch_size: false, color_correction: "lab",
input_noise_scale: SEEDVR2_INPUT_NOISE,
},
},
};
let imageRef = ["4", 0];
if (targetShort > stageShort) {
output[5] = { class_type: "UpscaleModelLoader", inputs: { model_name: modelName } };
output[6] = { class_type: "ImageUpscaleWithModel", inputs: { upscale_model: ["5", 0], image: imageRef } };
imageRef = ["6", 0];
}
output[7] = { class_type: "ImageScale", inputs: { image: imageRef, upscale_method: "lanczos", width: targetW, height: targetH, crop: "disabled" } };
output[8] = { class_type: "PreviewImage", inputs: { images: ["7", 0] } };
return output;
}
}
return {
1: { class_type: "LoadImage", inputs: { image: inputRef } },
2: { class_type: "UpscaleModelLoader", inputs: { model_name: modelName } },
3: { class_type: "ImageUpscaleWithModel", inputs: { upscale_model: ["2", 0], image: ["1", 0] } },
4: { class_type: "ImageScale", inputs: { image: ["3", 0], upscale_method: "lanczos", width: targetW, height: targetH, crop: "disabled" } },
5: { class_type: "PreviewImage", inputs: { images: ["4", 0] } },
};
}
// Pure-model enlargement of an input-dir image to exact target dims, rendered
// to temp. Used by the Edit grow-canvas path to produce the new Background
// while the region render diffuses, and for the under-layer pass. Plain JSON
// prompt — no workflow tab involved.
export function enlargeImageInBackground(inputRef, targetW, targetH, engine = "ultrasharp", climbModel = null) {
let output;
try {
output = buildEnlargePrompt(inputRef, targetW, targetH, engine, climbModel);
} catch (e) {
return Promise.reject(e);
}
const workflowId = globalThis.crypto?.randomUUID ? crypto.randomUUID() : `${Date.now().toString(16)}-bg`;
return new Promise((resolve, reject) => {
let promptId = null;
let settled = false;
let lastImage = null;
// A cached enlarge executes in ~0s, so its execution_success can reach the
// listener BEFORE queuePrompt's response resolves our prompt_id. Matching a
// real event against a still-null promptId silently drops it and the promise
// hangs forever — which strands the Edit "Add to Edit" handback awaiting it.
// Buffer every event until the id is known, then replay against it.
const pending = [];
const cleanup = () => {
api.removeEventListener("executed", onExecuted);
api.removeEventListener("execution_success", onSuccess);
api.removeEventListener("execution_error", onError);
api.removeEventListener("execution_interrupted", onError);
if (promptId) externallyTrackedPrompts.delete(promptId);
};
const settle = (fn, arg) => { if (settled) return; settled = true; cleanup(); fn(arg); };
const takeExecuted = (detail) => {
if (detail?.prompt_id !== promptId || !detail?.output?.images?.length) return;
lastImage = detail.output.images[detail.output.images.length - 1];
};
const takeSuccess = (detail) => {
if (detail?.prompt_id !== promptId || settled) return;
if (lastImage) { settle(resolve, lastImage); return; }
// A fully-cached run can emit execution_success without re-emitting its
// executed (no image over the wire) — recover the output from history.
recoverEnlargeFromHistory(promptId).then((img) =>
img ? settle(resolve, img) : settle(reject, new Error("background enlarge produced no image")));
};
const takeError = (detail) => {
if (detail?.prompt_id !== promptId) return;
settle(reject, new Error(detail?.exception_message || "background enlarge failed"));
};
const onExecuted = ({ detail }) => promptId ? takeExecuted(detail) : pending.push(["executed", detail]);
const onSuccess = ({ detail }) => promptId ? takeSuccess(detail) : pending.push(["success", detail]);
const onError = ({ detail }) => promptId ? takeError(detail) : pending.push(["error", detail]);
api.addEventListener("executed", onExecuted);
api.addEventListener("execution_success", onSuccess);
api.addEventListener("execution_error", onError);
api.addEventListener("execution_interrupted", onError);
armExternalQueue();
api.queuePrompt(0, { output, workflow: { id: workflowId, nodes: [], links: [] } })
.then((res) => {
promptId = res?.prompt_id || null;
if (promptId) externallyTrackedPrompts.add(promptId);
disarmExternalQueue();
if (!promptId) { settle(reject, new Error("queue returned no prompt id")); return; }
// Replay buffered events now the id is known — executed first so lastImage
// is set before any buffered success reads it.
for (const [kind, detail] of pending) if (kind === "executed") takeExecuted(detail);
for (const [kind, detail] of pending) {
if (kind === "success") takeSuccess(detail);
else if (kind === "error") takeError(detail);
}
pending.length = 0;
})
.catch((e) => {
disarmExternalQueue();
settle(reject, new Error(e?.response?.error?.message || e?.message || "queue rejected the enlarge prompt"));
});
});
}
// Last output image of a finished prompt, read straight from history — the net
// for a cached enlarge whose execution_success carries no image.
async function recoverEnlargeFromHistory(promptId) {
try {
const res = await api.fetchApi(`/history/${promptId}`);
const outputs = (await res.json())?.[promptId]?.outputs || {};
let last = null;
for (const nodeId of Object.keys(outputs)) {
const imgs = outputs[nodeId]?.images;
if (imgs?.length) last = imgs[imgs.length - 1];
}
return last;
} catch {
return null;
}
}
// ── enlarged-base cache (NoUpscale tile engines) ───────────────────────────
// The UltraSharp/SeedVR2 enlarge ("pre-process") is deterministic, so a retry
// that only changes tile dials reuses the saved base instead of re-running it.
// Keyed on the RESOLVED enlarge identity (input + engine + climb model + target
// dims) — tile dials are excluded by construction. Only the four NoUpscale tile
// engines are eligible; fused/diffusion engines (source/qwen/flux2/plain) build
// the enlarge inseparably and stay on the normal fused path. The whole pre-pass
// is best-effort: any failure falls through to the fused build (never breaks).
const TILE_ENGINES = new Set(["sdxl-ckpt", "flux1-unet", "zimage-unet", "krea2-unet"]);
const _enlargeCache = new Map(); // key -> input-dir ref ("subfolder/name.png")
function resolveEnlargeTarget(options, data) {
const w = data.width || 0, h = data.height || 0;
const longest = Math.max(w, h);
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 { targetW: Math.round(w * scale) || 2048, targetH: Math.round(h * scale) || 2048, scale };
}
function enlargeCacheKey(options, data, targetW, targetH) {
const engine = options.climbStage === "seedvr2" ? "seedvr2" : "ultrasharp";
return [data.input_ref || "", engine, options.climbModel || "default", targetW, targetH].join("|");
}
// Short deterministic hash of the cache key → a stable cache filename, so re-MISSes
// for the same enlarge identity OVERWRITE one file instead of piling new ones up.
function hashKey(s) {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
return h.toString(36);
}
// A LoadImage reads only the input dir, so copy the freshly-enlarged temp image into
// input/ (under a key-stable name) so the tile graph can load it on a cache hit.
async function copyEnlargedToInput(img, nameBase) {
const url = api.apiURL(`/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || "")}&type=${img.type || "temp"}`);
const blob = await (await fetch(url)).blob();
const form = new FormData();
form.append("image", new File([blob], `upcache_${nameBase}.png`, { type: "image/png" }));
form.append("subfolder", "promptchain_upscale_cache");
form.append("overwrite", "true");
const res = await api.fetchApi("/upload/image", { method: "POST", body: form });
const j = await res.json();
return j.subfolder ? `${j.subfolder}/${j.name}` : j.name;
}
// /view URL for a cached enlarged base (input-dir ref) — the compare's "Before".
function enlargedRefUrl(ref) {
const slash = ref.lastIndexOf("/");
const sub = slash >= 0 ? ref.slice(0, slash) : "";
const name = slash >= 0 ? ref.slice(slash + 1) : ref;
return api.apiURL(`/view?filename=${encodeURIComponent(name)}&subfolder=${encodeURIComponent(sub)}&type=input&rand=${Math.random()}`);
}
export function upscaleImageInBackground(prepared, options = {}) {
const tracker = createTracker();
// Cancellation context, shared between the pipeline and tracker.cancel().
const run = { cancelled: false, promptId: null, executionStarted: false };
tracker.cancel = async () => {
if (run.cancelled) return;
run.cancelled = true;
// Free the UI first, then interrupt server-side. A slow or hung interrupt
// must not strand the modal — and since run.cancelled is now set, a stranded
// await would also make every retry Stop a no-op.
tracker.emit({ phase: "cancelled" });
toast("info", "Upscale cancelled.");
try {
if (run.promptId) {
if (run.executionStarted) {
// Ours is the prompt the server is executing — interrupt it.
// The execution_interrupted event handles listener cleanup.
await api.interrupt();
} else {
// Still pending: pull it out of the queue. NEVER blind-interrupt
// here — that would kill whatever the server is running for
// someone else. No ws event follows a queue delete, so clean up
// the listeners ourselves.
await api.fetchApi("/queue", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete: [run.promptId] }),
});
run.cleanup?.();
}
}
} catch (e) {
console.warn("[PromptChain] upscale cancel request failed", e);
}
};
(async () => {
// Off-screen builds can't re-render missing pose maps (no viewport ever
// mounts) — when a pose-hinted mode needs them and they're gone, say so
// instead of touching the user's tabs.
const mode = options.mode || prepared.caps?.defaultMode || "prompt";
// Only the source-model graft can need pose maps — picker/plain engines
// never hint from them.
const poseHinted = (options.engine || "source") === "source" && (mode === "regional"
|| (mode === "depth" && (options.depthSource || prepared.caps?.defaultDepthSource || "pose") === "pose"));
if (prepared.data.pose_files_ok === false && poseHinted) {
tracker.emit({ phase: "error", message: "this image's 3D pose maps were deleted — drag the image onto the ComfyUI canvas to load its OWN workflow (not a saved .json), let the 3D Poser re-render, then retry. New renders are now pinned so this won't recur." });
return;
}
// Enlarge cache (NoUpscale tile engines): pre-enlarge once, reuse on retry so
// only the tiles re-run. Wrapped so ANY failure falls through to the normal
// fused build — this can never break the upscale, only skip the optimization.
try {
// Skip <=1.02: there the fused build does a plain lanczos (no ESRGAN climb), so
// there's nothing expensive to cache AND a cached ESRGAN base would diverge from
// the fused output. Above 1.02 the cached base equals the fused climb (same
// climbModel, same lanczos fit), so the result is unchanged — just cached.
const { targetW, targetH, scale } = TILE_ENGINES.has(options.engine || "source") && !options.preEnlargedRef
? resolveEnlargeTarget(options, prepared.data)
: { scale: 0 };
if (scale > 1.02) {
const key = enlargeCacheKey(options, prepared.data, targetW, targetH);
let baseRef = _enlargeCache.get(key);
if (baseRef) {
console.log(`[upscale-cache] HIT — reusing enlarged base, skipping pre-process (${targetW}x${targetH})`);
} else {
tracker.emit({ phase: "running", prep: true });
const enlEngine = options.climbStage === "seedvr2" ? "seedvr2" : "ultrasharp";
const temp = await enlargeImageInBackground(prepared.data.input_ref, targetW, targetH, enlEngine, options.climbModel);
if (run.cancelled) return;
baseRef = await copyEnlargedToInput(temp, hashKey(key));
_enlargeCache.set(key, baseRef);
console.log(`[upscale-cache] MISS — enlarged + cached base (${targetW}x${targetH})`);
}
options = { ...options, preEnlargedRef: baseRef, preEnlargedW: targetW, preEnlargedH: targetH };
}
} catch (e) {
console.warn("[PromptChain] enlarge cache pre-pass skipped — using fused build:", e);
options = { ...options, preEnlargedRef: null };
}
tracker.emit({ phase: "building" });
const built = await buildUpscaleGraph(prepared, options);
if (!built) {
tracker.emit({ phase: "error", message: "couldn't build the upscale graph" });
return;
}
const { graph, workflowId } = built;
if (run.cancelled) return;
tracker.emit({ phase: "queueing" });
const serialized = await app.graphToPrompt(graph);
logUpscaleRun(serialized.output, options.engine);
let promptId = null;
// Arm the external-prompt window across the queue round-trip so the main
// graph's execution_start gate recognizes this run even if its event beats
// the post-resolve registration below.
armExternalQueue();
try {
// Mirror the UI queue path: it injects the user's preview-method setting
// as a per-prompt override (extra_data.preview_method) — without it the
// server falls back to the CLI default (usually no previews) and the
// modal's live preview never gets a frame.
const previewMethod = app.extensionManager?.setting?.get?.("Comfy.Execution.PreviewMethod")
?? app.ui?.settings?.getSettingValue?.("Comfy.Execution.PreviewMethod")
?? "default";
const res = await api.queuePrompt(0, { output: serialized.output, workflow: serialized.workflow }, { previewMethod });
promptId = res?.prompt_id || null;
} catch (e) {
disarmExternalQueue();
const message = e?.response?.error?.message || e?.message || "queue rejected the prompt";
tracker.emit({ phase: "error", message });
toast("error", `Upscale failed to queue: ${message}`);
return;
}
if (promptId) externallyTrackedPrompts.add(promptId);
disarmExternalQueue();
run.promptId = promptId;
const startedAt = Date.now();
const meta = collectRecordMeta(graph, prepared.data);
const plan = computeTilePlan(graph, prepared.data, options);
if (!promptId) {
tracker.emit({ phase: "error", message: "queue returned no prompt id" });
return;
}
if (run.cancelled) {
// Cancel raced the queue call — pull the prompt back out.
try {
await api.fetchApi("/queue", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete: [promptId] }),
});
} catch {}
externallyTrackedPrompts.delete(promptId);
return;
}
tracker.emit({ phase: "running" });
const enlargedUrl = options.preEnlargedRef ? enlargedRefUrl(options.preEnlargedRef) : null;
watchExecution({ promptId, workflowId, meta, plan, startedAt, tracker, run, enlargedUrl });
})().catch((e) => {
console.error("[PromptChain] background upscale failed", e);
tracker.emit({ phase: "error", message: e?.message || "unexpected failure" });
toast("error", `Upscale failed: ${e?.message || e}`);
});
return tracker;
}