Files
JeyzerMC-comfyui-flakes/web/widgets/generation-data.js
T
Mehdi Chaid ecf7aceef4 fix: combo highlights and overlay results lost across tab switches and reconnects (#373)
Batch state (running combo per node id, results per FlakeGenerate node id, progress) now lives in a per-workflow store in queue.js. Nodes read it when they render and after a workflow load; the overlay reads results through a getter and re-renders when one arrives. On websocket reconnect or when the tab becomes visible, finished prompts are backfilled from /history and the running one from /queue. Results are cleared at batch start.
2026-10-15 21:11:23 -04:00

1028 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { css } from "../utils.js";
import { openOverlay } from "../modal.js";
import { fetchFlake, fetchPreset, getCoverUrl, getVariantImageUrl, fetchFlakeMeta } from "../api.js";
import { getActiveBatch, subscribeBatch } from "../queue.js";
// Cartesian product of a flake's variant groups ({group:[choices]}) -> one
// {group: choice} per combination (#345). Order matches queue.js so keys align.
function variantCombos(meta) {
const groups = Object.keys(meta || {}).filter(g => Array.isArray(meta[g]) && meta[g].length);
let acc = [{}];
for (const g of groups) {
const next = [];
for (const a of acc) for (const ch of meta[g]) next.push({ ...a, [g]: ch });
acc = next;
}
return acc;
}
// Build one combo-axis card descriptor for a flake entry (#345).
function makeFlakeCard(e, i, variantSub) {
// #237: fall back to the flake's main cover image when no variant is selected
// (or when the variant image 404s — see card render's probe-load).
const variantSel = Object.entries(e.variant || {}).find(([, c]) => c);
const baseCover = getCoverUrl(e.name);
const variantCover = variantSel ? getVariantImageUrl(e.name, variantSel[0], variantSel[1]) : null;
const choices = Object.values(e.variant || {}).filter(Boolean);
const base = e.display_name || e.name.split("/").pop() || e.name;
return {
label: base + (choices.length ? ` (${choices.join(", ")})` : ""),
entry: e,
coverUrl: variantCover || baseCover,
baseCoverUrl: baseCover,
variantCoverUrl: variantCover,
originalIndex: i,
variantSub: variantSub,
};
}
// Parse a comboKey like "12:0|7:2.1" into [nodeId, itemIdxStr] pairs. The item
// part is kept as a string so all-variants sub-keys ("0.1") compare exactly (#345).
function parseComboKey(key) {
if (!key) return [];
return key.split("|").map(pair => {
const ci = pair.indexOf(":");
return [Number(pair.slice(0, ci)), pair.slice(ci + 1)];
});
}
// Resolve the ComfyUI api singleton (same pattern as queue.js).
function _resolveComfyApi() {
return (
window.comfyAPI?.api?.api
|| window.comfyAPI?.api
|| (typeof window !== "undefined" && window.app?.api)
|| null
);
}
const ACCENT = "#4a9eff";
// ── Upstream chain ─────────────────────────────────────────────────────────
// Walk the flake_data input upstream from the Flake Generate node and collect,
// in pipeline order, every node that contributes generation data:
// - FlakeModelPreset / FlakeModelCombo (terminal source)
// - FlakeStack (common, non-combinatorial flakes)
// - FlakeCombo (combinatorial flakes)
export function collectChain(startNode) {
const chain = [];
const visited = new Set();
let current = startNode;
while (current && !visited.has(current.id)) {
visited.add(current.id);
const t = current.type;
if (t === "FlakeStack" || t === "FlakeCombo") {
chain.push({ type: t, node: current });
const fdInput = current.inputs?.find(i => i.name === "flake_data");
if (!fdInput?.link) break;
const link = current.graph?.links?.[fdInput.link];
if (!link) break;
current = current.graph?.getNodeById(link.origin_id);
} else if (t === "FlakeModelPreset" || t === "FlakeModelCombo") {
chain.push({ type: t, node: current });
break;
} else {
break;
}
}
return chain.reverse();
}
function activeEntries(node, type) {
let entries;
if (type === "FlakeCombo") {
entries = node.properties?._combo_flakes || [];
} else {
const w = node.widgets?.find(w => w.name === "flakes_json");
try { entries = JSON.parse(w?.value || "[]"); } catch { entries = []; }
}
if (!Array.isArray(entries)) return [];
return entries.filter(e => !e.inline && !e.bypassed && e.name);
}
// Build the combo "axes" (one per Flake Model Combo / Flake Combo node) plus
// the fixed (non-combinatorial) preset and common-stack flakes.
export async function buildModel(startNode) {
const chain = collectChain(startNode);
const axes = []; // [{ node, kind:'model'|'flake', label, items:[{label,name,coverUrl,...}] }]
const fixed = { presetName: null, stackFlakes: [] };
for (const { type, node } of chain) {
if (type === "FlakeModelPreset") {
const w = node.widgets?.find(w => w.name === "preset");
const v = w?.value;
if (v && v !== "Select a preset..." && v !== "No model preset is selected") {
fixed.presetName = v;
}
} else if (type === "FlakeModelCombo") {
const allPresets = node.properties?._combo_presets || [];
const bypassed = new Set(node.properties?._combo_bypassed || []);
// Keep each kept preset's ORIGINAL index within _combo_presets so the
// overlay's combo key matches the key queue.js stamps (which uses the
// original index). Filtering shifts positions otherwise (#268).
const presets = allPresets
.map((p, i) => ({ p, i }))
.filter(({ p }) => !bypassed.has(p));
if (presets.length === 0) continue;
axes.push({
node, kind: "model",
label: "Model Preset",
items: presets.map(({ p, i }) => ({
label: (node.properties?._combo_display_names?.[p]) || p.split(/[\/\\]+/).pop() || p,
presetName: p,
coverUrl: `/flakes/preset_cover?name=${encodeURIComponent(p)}`,
originalIndex: i,
})),
});
} else if (type === "FlakeStack") {
for (const e of activeEntries(node, "FlakeStack")) fixed.stackFlakes.push(e);
} else if (type === "FlakeCombo") {
// Preserve the ORIGINAL index within _combo_flakes (the array queue.js
// iterates) so the overlay combo key aligns with the queued key (#268).
const allFlakes = node.properties?._combo_flakes || [];
const flakes = Array.isArray(allFlakes)
? allFlakes.map((e, i) => ({ e, i })).filter(({ e }) => !e.inline && !e.bypassed && e.name)
: [];
if (flakes.length === 0) continue;
const items = [];
for (const { e, i } of flakes) {
if (e._all_variants) {
// One card per variant combination (#345), mirroring the
// queue.js expansion so keys (id:idx.variantSub) align.
let combos = [];
try { combos = variantCombos(await fetchFlakeMeta(e.name)); } catch { combos = []; }
if (combos.length > 1) {
combos.forEach((variant, vi) => items.push(makeFlakeCard({ ...e, variant }, i, vi)));
continue;
}
}
items.push(makeFlakeCard(e, i, null));
}
axes.push({ node, kind: "flake", label: "Flake Combo", items });
}
}
return { axes, fixed };
}
// ── composite image ─────────────────────────────────────────────────────────
// Vertical-first split with a column interleave (#350): even-indexed covers fill
// the left column (top→bottom), odd-indexed covers fill the right column
// (top→bottom). A single image fills the canvas; a column with one item spans
// full height (so 3 items → right item full height, left split in two).
function layoutRects(urls, W, H) {
if (urls.length === 1) return [{ x: 0, y: 0, w: W, h: H, url: urls[0] }];
const left = [], right = [];
urls.forEach((u, i) => (i % 2 === 0 ? left : right).push(u));
const colW = right.length ? W / 2 : W;
const rects = [];
const place = (col, x, w) => {
const n = col.length;
col.forEach((u, j) => rects.push({ x, y: (H * j) / n, w, h: H / n, url: u }));
};
place(left, 0, colW);
if (right.length) place(right, colW, W - colW);
return rects;
}
// Render the cover mosaic at the output aspect ratio. The long edge is DPR-aware
// (~1024 logical px × devicePixelRatio, capped at 2×) so it isn't upscaled and
// blurred when shown large in the overlay (#350, #354).
function buildComposite(urls, width, height, longEdge = Math.round(1024 * Math.min(window.devicePixelRatio || 1, 2))) {
return new Promise((resolve) => {
let cw = longEdge, ch = longEdge;
if (width && height) {
if (width >= height) ch = Math.max(1, Math.round((longEdge * height) / width));
else cw = Math.max(1, Math.round((longEdge * width) / height));
}
const canvas = document.createElement("canvas");
canvas.width = cw;
canvas.height = ch;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#1a1a1a";
ctx.fillRect(0, 0, cw, ch);
if (urls.length === 0) { resolve(canvas.toDataURL()); return; }
const rects = layoutRects(urls, cw, ch);
let pending = rects.length;
if (pending === 0) { resolve(canvas.toDataURL()); return; }
for (const r of rects) {
const img = new Image();
img.onload = () => {
// cover-fit
const ar = img.width / img.height;
const rar = r.w / r.h;
let sx = 0, sy = 0, sw = img.width, sh = img.height;
if (ar > rar) { sw = img.height * rar; sx = (img.width - sw) / 2; }
else { sh = img.width / rar; sy = (img.height - sh) / 2; }
ctx.drawImage(img, sx, sy, sw, sh, r.x, r.y, r.w, r.h);
if (--pending === 0) resolve(canvas.toDataURL());
};
img.onerror = () => { if (--pending === 0) resolve(canvas.toDataURL()); };
img.src = r.url;
}
});
}
// A flake's prompts: the `prompts:` list or the legacy single `prompt:` (#371).
function flakePrompts(d) {
return Array.isArray(d.prompts) ? d.prompts : (d.prompt ? [d.prompt] : []);
}
// Convert a single flake entry into label/lora/prompt rows.
// If the host flake has a flake_link, also append the linked flake's effective
// rows (post-override) — #236. The effective variant/strength is derived
// from per-grid override > yaml default > linked flake's own default.
async function flakeEntryRows(entry) {
const loraRows = [];
const promptRows = [];
let hostData = null;
try {
const d = await fetchFlake(entry.name);
hostData = d;
const label = entry.display_name || d.name || entry.name.split("/").pop() || entry.name;
const choices = Object.values(entry.variant || {}).filter(Boolean);
const vLabel = label + (choices.length ? ` (${choices.join(", ")})` : "");
if (Array.isArray(d.loras)) {
d.loras.forEach((lr, idx) => {
const name = lr.name || `LoRA #${idx + 1}`;
const path = lr.path || lr.name || "";
const s = entry.loras?.[idx] ?? lr.strength ?? 1;
loraRows.push([`${name} [${Number.isInteger(s) ? s : Number(s).toFixed(2)}]`, path]);
});
}
for (const p of flakePrompts(d)) {
const pos = (p?.positive || "").trim();
const neg = (p?.negative || "").trim();
if (pos) promptRows.push([`${vLabel} · Positive`, pos]);
if (neg) promptRows.push([`${vLabel} · Negative`, neg]);
}
for (const [g, c] of Object.entries(entry.variant || {})) {
const v = d.variants?.[g]?.[c];
if (v?.positive) promptRows.push([`${vLabel} · ${g} · Positive`, v.positive]);
if (v?.negative) promptRows.push([`${vLabel} · ${g} · Negative`, v.negative]);
}
} catch { /* skip */ }
// Linked flake surfacing.
if (hostData?.flake_link?.target) {
try {
const linkTarget = hostData.flake_link.target;
const link = hostData.flake_link;
const ovr = entry.flake_link_override || {};
const linked = await fetchFlake(linkTarget);
const hostLabel = entry.display_name || hostData.name || entry.name.split("/").pop() || entry.name;
const linkLabel = (linked.name || linkTarget).split("/").pop();
const groupLabel = `${hostLabel} · linked: ${linkLabel}`;
// Effective variant choices = grid override > yaml default.
const effectiveVariant = { ...(link.variant || {}), ...(ovr.variant || {}) };
const effChoices = Object.values(effectiveVariant).filter(Boolean);
const linkChoiceSuffix = effChoices.length ? ` (${effChoices.join(", ")})` : "";
// Linked LoRA rows with effective strengths.
if (Array.isArray(linked.loras)) {
linked.loras.forEach((lr, idx) => {
const name = lr.name || `LoRA #${idx + 1}`;
const path = lr.path || lr.name || "";
let s = ovr.lora_strengths?.[idx];
if (s === null || s === undefined) s = (link.lora_strengths || [])[idx];
if (s === null || s === undefined) s = lr.strength ?? 1;
loraRows.push([`[linked] ${name} [${Number.isInteger(s) ? s : Number(s).toFixed(2)}]`, path]);
});
}
// Linked prompts
for (const p of flakePrompts(linked)) {
const linkPos = (p?.positive || "").trim();
const linkNeg = (p?.negative || "").trim();
if (linkPos) promptRows.push([`${groupLabel}${linkChoiceSuffix} · Positive`, linkPos]);
if (linkNeg) promptRows.push([`${groupLabel}${linkChoiceSuffix} · Negative`, linkNeg]);
}
for (const [g, c] of Object.entries(effectiveVariant)) {
if (!c) continue;
const v = linked.variants?.[g]?.[c];
if (v?.positive) promptRows.push([`${groupLabel} · ${g} · Positive`, v.positive]);
if (v?.negative) promptRows.push([`${groupLabel} · ${g} · Negative`, v.negative]);
}
} catch { /* missing linked target — skip silently */ }
}
return { loraRows, promptRows };
}
// ── Per-combination data assembly ──────────────────────────────────────────
// Returns common (model preset + stack flakes) and combo (all combo-axis selected
// flakes) data buckets separately, so the overlay can show common on the right
// and ALL combo-axis data on the left (#228). The previous unified bucket was
// the root cause of combo data leaking into the right panel and second-combo
// data being dropped from the left panel.
async function combinationData(model, selection) {
let presetName = model.fixed.presetName;
model.axes.forEach((axis, i) => {
if (axis.kind === "model") presetName = selection[i].presetName;
});
const out = {
modelRows: [],
commonLoraRows: [],
commonPromptRows: [],
comboLoraRows: [],
comboPromptRows: [],
coverUrls: [],
dimensions: null,
width: null,
height: null,
};
if (presetName) {
try {
const d = await fetchPreset(presetName);
const m = out.modelRows;
if (d.checkpoint) m.push(["Checkpoint", d.checkpoint]);
if (d.vae && d.vae !== "baked-in") m.push(["VAE", d.vae]);
if (d.text_encoder && d.text_encoder !== "baked-in") m.push(["Text Encoder", d.text_encoder]);
if (d.clip_skip) m.push(["Clip Skip", String(d.clip_skip)]);
if (d.sampler) m.push(["Sampler", d.sampler]);
if (d.scheduler) m.push(["Scheduler", d.scheduler]);
if (d.cfg) m.push(["CFG", String(d.cfg)]);
if (d.steps) m.push(["Steps", String(d.steps)]);
if (d.width || d.height) {
out.dimensions = `${d.width || "?"} × ${d.height || "?"}`;
out.width = d.width || null;
out.height = d.height || null;
}
const label = d.display_name || presetName.split("/").pop() || presetName;
const pos = (d.prompt?.positive || "").trim();
const neg = (d.prompt?.negative || "").trim();
if (pos) out.commonPromptRows.push([`${label} · Positive (Model)`, pos]);
if (neg) out.commonPromptRows.push([`${label} · Negative (Model)`, neg]);
} catch { /* skip */ }
}
// Common: fixed stack flakes (Flake Stack contributions are shared across all combinations).
for (const entry of model.fixed.stackFlakes) {
const rows = await flakeEntryRows(entry);
out.commonLoraRows.push(...rows.loraRows);
out.commonPromptRows.push(...rows.promptRows);
}
// Combo: every flake-axis's currently-selected entry (multiple axes are
// each rendered as their own block on the left panel).
for (let i = 0; i < model.axes.length; i++) {
const axis = model.axes[i];
if (axis.kind !== "flake") continue;
const entry = selection[i]?.entry;
if (!entry) continue;
const rows = await flakeEntryRows(entry);
out.comboLoraRows.push(...rows.loraRows);
out.comboPromptRows.push(...rows.promptRows);
}
// Cover urls for the composite = the selected combo item covers, in order.
out.coverUrls = model.axes.map((_, i) => selection[i].coverUrl);
return out;
}
// Stable, order-independent identity for a combination: each axis's node id
// paired with the selected item index, sorted by node id. Must match the key
// queue.js stamps onto FlakeGenerate at queue time.
export function combinationKeyFor(model, selIdx) {
return model.axes
.map((axis, i) => {
const item = axis.items[selIdx[i]];
const idx = item?.originalIndex ?? selIdx[i];
// All-variants cards carry a sub-index so the key matches the queued
// comboKey "<id>:<idx>.<variantSub>" (#345).
const vk = (item && item.variantSub != null) ? `${idx}.${item.variantSub}` : `${idx}`;
return [axis.node.id, vk];
})
.sort((a, b) => a[0] - b[0])
.map(([id, vk]) => `${id}:${vk}`)
.join("|");
}
// ── Overlay UI ─────────────────────────────────────────────────────────────
// getImages() returns the Flake Generate node's current per-combo results
// ({ combo, ad, ab }); it is re-read whenever batch state changes (#373).
export function openGenerationDataOverlay(model, getImages, opts = {}) {
const {
adetailer = "Off",
adetailerDenoise,
adetailerSteps,
adetailerBbox,
upscale = false,
upscaleModel,
upscaleFactor,
} = opts;
const { content, footer, close, handlers } = openOverlay();
const adetailerAB = getImages().ab;
// Wider overlay (#324): give the right-hand image + data panel more room.
css(content.parentElement, content.parentElement.style.cssText + "width:auto;max-width:min(1200px,85vw);min-width:0;");
const header = document.createElement("div");
css(header, "display:flex;align-items:center;gap:8px;margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid #333;");
const icon = document.createElement("span");
icon.textContent = "📊";
icon.style.fontSize = "18px";
header.appendChild(icon);
const title = document.createElement("span");
title.textContent = "Generation Data";
css(title, `font-size:15px;font-weight:600;color:${ACCENT};`);
header.appendChild(title);
content.appendChild(header);
if (model.axes.length === 0 && !model.fixed.presetName && model.fixed.stackFlakes.length === 0) {
const empty = document.createElement("div");
css(empty, "font-size:13px;color:#555;text-align:center;padding:40px;");
empty.textContent = "No generation data available";
content.appendChild(empty);
const cb = document.createElement("button");
cb.textContent = "Close";
css(cb, "padding:6px 18px;background:#333;color:#ddd;border:1px solid #555;border-radius:6px;cursor:pointer;font-size:13px;");
cb.addEventListener("click", () => close());
footer.appendChild(cb);
return;
}
const hasCombos = model.axes.some(a => a.items.length > 1);
if (hasCombos) {
const split = document.createElement("div");
css(split, "display:flex;gap:8px;align-items:flex-start;");
content.appendChild(split);
// Left third: combo axis grids + combo-specific fields below
const left = document.createElement("div");
css(left, "flex:0 0 33%;display:flex;flex-direction:column;gap:12px;min-width:0;max-height:78vh;overflow:auto;");
// Right two-thirds: composite image + common data sections
const right = document.createElement("div");
css(right, "flex:1;display:flex;flex-direction:column;gap:10px;min-width:0;max-height:78vh;overflow:auto;");
split.appendChild(left);
split.appendChild(right);
// selection state: index per axis
const selIdx = model.axes.map(() => 0);
function currentSelection() {
return model.axes.map((axis, i) => axis.items[selIdx[i]]);
}
const cards = []; // per-axis: array of card elements (to update selected ring)
// Left: combo axis grids
model.axes.forEach((axis, ai) => {
const section = document.createElement("div");
const lbl = document.createElement("div");
lbl.textContent = axis.label;
css(lbl, "font-size:11px;font-weight:600;color:#aaa;margin-bottom:4px;");
section.appendChild(lbl);
const scroll = document.createElement("div");
css(scroll, "display:flex;flex-wrap:wrap;gap:6px;padding-bottom:4px;");
const axisCards = [];
axis.items.forEach((item, ii) => {
const card = document.createElement("div");
card.dataset.nodeId = String(axis.node.id);
// Use the original index (plus the all-variants sub-index, #345)
// so progress-bar matching against the queued comboKey aligns (#268).
{
const baseIdx = item.originalIndex ?? ii;
card.dataset.itemIdx = item.variantSub != null ? `${baseIdx}.${item.variantSub}` : String(baseIdx);
}
css(card, `position:relative;flex:0 0 auto;width:72px;height:80px;border-radius:4px;cursor:pointer;background:#2a2a2a;background-size:cover;background-position:center;border:2px solid ${ii === selIdx[ai] ? ACCENT : "transparent"};box-sizing:border-box;`);
// #237: probe-load variant image and fall back to base cover if 404,
// matching the flake-combo node grid behavior. Without this the
// card showed no image when a missing variant choice image was
// selected.
if (item.variantCoverUrl) {
const probe = new Image();
probe.onload = () => { card.style.backgroundImage = `url(${item.variantCoverUrl})`; };
probe.onerror = () => {
if (item.baseCoverUrl) card.style.backgroundImage = `url(${item.baseCoverUrl})`;
};
probe.src = item.variantCoverUrl;
// Show base immediately while probe resolves so there's
// never an empty cell.
if (item.baseCoverUrl) card.style.backgroundImage = `url(${item.baseCoverUrl})`;
} else if (item.coverUrl) {
card.style.backgroundImage = `url(${item.coverUrl})`;
}
// Progress bar (above the caption) — shown while the active
// batch's running prompt includes this (nodeId, itemIdx) pair.
const progressTrack = document.createElement("div");
css(progressTrack, "position:absolute;left:2px;right:2px;bottom:14px;height:3px;background:rgba(0,0,0,0.55);border-radius:2px;overflow:hidden;display:none;");
const progressFill = document.createElement("div");
css(progressFill, `height:100%;width:0%;background:${ACCENT};transition:width 0.15s ease;`);
progressTrack.appendChild(progressFill);
card.appendChild(progressTrack);
card._progressTrack = progressTrack;
card._progressFill = progressFill;
const cap = document.createElement("div");
cap.textContent = item.label;
cap.title = item.label;
css(cap, "position:absolute;left:0;right:0;bottom:0;font-size:9px;color:#fff;background:rgba(0,0,0,0.6);padding:1px 3px;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;border-radius:0 0 2px 2px;");
card.appendChild(cap);
card.addEventListener("click", () => {
selIdx[ai] = ii;
axisCards.forEach((c, k) => { c.style.borderColor = k === ii ? ACCENT : "transparent"; });
refreshRight();
});
axisCards.push(card);
scroll.appendChild(card);
});
cards.push(axisCards);
section.appendChild(scroll);
left.appendChild(section);
});
// Left: combo-specific fields container (below grids)
const leftDataWrap = document.createElement("div");
css(leftDataWrap, "display:flex;flex-direction:column;gap:10px;");
left.appendChild(leftDataWrap);
// ── Progress bars on combo grid items (#230) ──
// Walk through every card and update its progress track based on the
// currently-running prompt (mapped to combo via queue.js's _activeBatch).
function findCardsForRunningCombo() {
const batch = getActiveBatch();
const pid = batch.runningPromptId;
if (!pid) return [];
const idx = batch.promptIds.indexOf(pid);
if (idx < 0) return [];
const key = batch.comboKeys[idx];
const pairs = parseComboKey(key);
const out = [];
for (const [nodeId, itemIdx] of pairs) {
for (const axisCards of cards) {
for (const c of axisCards) {
// itemIdx is a string ("0" or "0.1") to support variant sub-keys (#345).
if (Number(c.dataset.nodeId) === nodeId && String(c.dataset.itemIdx) === String(itemIdx)) {
out.push(c);
}
}
}
}
return out;
}
function clearAllProgress() {
for (const axisCards of cards) {
for (const c of axisCards) {
if (c._progressTrack) c._progressTrack.style.display = "none";
if (c._progressFill) c._progressFill.style.width = "0%";
}
}
}
const _progressApi = _resolveComfyApi();
const onProgress = (e) => {
const detail = e?.detail || {};
const value = detail.value ?? 0;
const max = detail.max ?? 1;
const pct = Math.max(0, Math.min(100, (value / Math.max(1, max)) * 100));
const activeCards = findCardsForRunningCombo();
if (activeCards.length === 0) return;
for (const axisCards of cards) {
for (const c of axisCards) {
if (activeCards.includes(c)) {
c._progressTrack.style.display = "block";
c._progressFill.style.width = `${pct}%`;
} else {
c._progressTrack.style.display = "none";
c._progressFill.style.width = "0%";
}
}
}
};
const onExecDoneForProgress = () => clearAllProgress();
if (_progressApi && typeof _progressApi.addEventListener === "function") {
_progressApi.addEventListener("progress", onProgress);
_progressApi.addEventListener("execution_success", onExecDoneForProgress);
_progressApi.addEventListener("execution_error", onExecDoneForProgress);
_progressApi.addEventListener("execution_interrupted", onExecDoneForProgress);
}
// Late-open seed (#241): if a batch is already in flight when the
// overlay opens, immediately mark the running combo's grid cards so the
// progress bar appears at "indeterminate / 0%" even before the next
// progress event arrives. Once a progress event fires, onProgress
// refines the width.
(function seedFromActiveBatch() {
const batch = getActiveBatch();
if (!batch.runningPromptId) return;
const activeCards = findCardsForRunningCombo();
for (const c of activeCards) {
if (c._progressTrack) c._progressTrack.style.display = "block";
// Leave width at 0% — onProgress will fill it in.
}
})();
// Re-render the image when a result for the selected combination arrives.
const unsubscribe = subscribeBatch(() => {
if (shownData && selectedResult() !== currentGenerated) renderImage(shownData);
});
// Tear down the listeners however the overlay closes (button, Esc, outside click).
handlers.onClose = () => {
unsubscribe();
if (_progressApi && typeof _progressApi.removeEventListener === "function") {
_progressApi.removeEventListener("progress", onProgress);
_progressApi.removeEventListener("execution_success", onExecDoneForProgress);
_progressApi.removeEventListener("execution_error", onExecDoneForProgress);
_progressApi.removeEventListener("execution_interrupted", onExecDoneForProgress);
}
};
// Right: composite image + common data sections
const compositeWrap = document.createElement("div");
css(compositeWrap, "display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;box-sizing:border-box;padding:0 4px;");
const compositeImg = document.createElement("img");
// Aspect ratio is set per-combination in refreshRight (#231); start at
// a 1:1 placeholder while data loads.
css(compositeImg, "width:100%;height:auto;aspect-ratio:1/1;object-fit:contain;border-radius:6px;border:1px solid #333;background:#1a1a1a;");
const compositeLabel = document.createElement("div");
css(compositeLabel, "font-size:11px;color:#888;text-align:center;");
const compositeDimensions = document.createElement("div");
css(compositeDimensions, "font-size:10px;color:#666;text-align:center;");
const adToggleWrap = document.createElement("div");
css(adToggleWrap, "display:flex;align-items:center;gap:8px;font-size:12px;color:#ddd;");
// Name + dimensions above the preview image (#349).
compositeWrap.appendChild(compositeLabel);
compositeWrap.appendChild(compositeDimensions);
compositeWrap.appendChild(compositeImg);
compositeWrap.appendChild(adToggleWrap);
right.appendChild(compositeWrap);
// Show the ACTUAL generated image dimensions (not the preset's configured
// size) when a real output is displayed (#342). The cover-composite
// placeholder keeps the preset dims.
let dimsFromImage = false;
compositeImg.addEventListener("load", () => {
if (dimsFromImage && compositeImg.naturalWidth) {
compositeDimensions.textContent = `${compositeImg.naturalWidth} × ${compositeImg.naturalHeight}`;
}
});
// Double-click the overlay image to reveal it on disk, like the Flake
// Generate node image (#340). Tracks the current combination's generated file.
let currentGenerated = null;
compositeImg.style.cursor = "pointer";
compositeImg.title = "Double-click to reveal in file explorer";
compositeImg.addEventListener("dblclick", () => {
if (!currentGenerated) return;
fetch("/flakes/reveal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: currentGenerated.filename,
subfolder: currentGenerated.subfolder || "",
type: currentGenerated.type || "output",
}),
}).catch((e) => console.error("[flakes] reveal failed", e));
});
function applyAspectRatio(w, h) {
if (!w || !h) return;
// Full-width image (#339): the panel sets the width; height follows the
// output aspect ratio.
compositeImg.style.aspectRatio = `${w} / ${h}`;
}
const dataWrap = document.createElement("div");
css(dataWrap, "display:flex;flex-direction:column;gap:10px;");
right.appendChild(dataWrap);
function section(titleText, rows) {
if (!rows.length) return null;
const wrap = document.createElement("div");
const h = document.createElement("div");
h.textContent = titleText;
css(h, `font-size:11px;font-weight:600;color:${ACCENT};margin-bottom:6px;`);
wrap.appendChild(h);
const list = document.createElement("div");
css(list, "display:flex;flex-wrap:wrap;gap:6px;");
for (const [k, v] of rows) {
const row = document.createElement("div");
css(row, "background:#181818;border:1px solid #333;border-radius:6px;padding:6px 8px;flex:0 0 auto;min-width:0;max-width:100%;box-sizing:border-box;");
const ke = document.createElement("div");
ke.textContent = k;
css(ke, "font-size:10px;color:#888;margin-bottom:2px;");
const ve = document.createElement("div");
ve.textContent = typeof v === "string" ? v : String(v);
css(ve, "font-size:12px;color:#ddd;word-break:break-word;white-space:pre-wrap;");
row.appendChild(ke);
row.appendChild(ve);
list.appendChild(row);
}
wrap.appendChild(list);
return wrap;
}
function selectedResult() {
const key = combinationKeyFor(model, selIdx);
const images = getImages();
return (showAd ? images.ad[key] : images.combo[key]) || null;
}
// Generated output for the selected combination, else the cover placeholder.
let imageToken = 0;
async function renderImage(data) {
const token = ++imageToken;
const generated = selectedResult();
currentGenerated = generated;
// Match the preview aspect ratio to the output's resolution (#231).
applyAspectRatio(data.width, data.height);
if (generated) {
dimsFromImage = true;
compositeImg.src = `/view?filename=${encodeURIComponent(generated.filename)}&type=${generated.type || "output"}&subfolder=${encodeURIComponent(generated.subfolder || "")}`;
const sub = (generated.subfolder || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
compositeLabel.textContent = sub ? `${sub}/${generated.filename}` : generated.filename;
compositeDimensions.textContent = (compositeImg.complete && compositeImg.naturalWidth)
? `${compositeImg.naturalWidth} × ${compositeImg.naturalHeight}`
: (data.dimensions || "");
} else {
dimsFromImage = false;
compositeLabel.textContent = "No image generated yet";
const urls = data.coverUrls.length ? data.coverUrls : (model.fixed.presetName ? [`/flakes/preset_cover?name=${encodeURIComponent(model.fixed.presetName)}`] : []);
// Match the element AR to the composite canvas so the cover tiles
// fill the field with no letterbox; buildComposite uses the same
// dims (square fallback when unknown) (#354).
compositeImg.style.aspectRatio = (data.width && data.height) ? `${data.width} / ${data.height}` : "1 / 1";
const src = await buildComposite(urls, data.width, data.height);
if (token !== imageToken) return;
compositeImg.src = src;
compositeDimensions.textContent = data.dimensions || "";
}
}
let refreshToken = 0;
let shownData = null; // combinationData of the selected combination
async function refreshRight() {
const token = ++refreshToken;
const data = await combinationData(model, currentSelection());
if (token !== refreshToken) return;
shownData = data;
renderImage(data);
// Right panel: common data only (Model Preset + Flake Stack).
// Combo-axis data lives exclusively on the left (#228).
dataWrap.replaceChildren();
const s1 = section("Model", data.modelRows);
const s2 = section("LoRAs", data.commonLoraRows);
const s3 = section("Prompts", data.commonPromptRows);
const s4 = section("Generation settings", extraInfoRows());
for (const s of [s1, s2, s3, s4]) if (s) dataWrap.appendChild(s);
if (!s1 && !s2 && !s3 && !s4) {
const empty = document.createElement("div");
css(empty, "font-size:12px;color:#555;text-align:center;padding:20px;");
empty.textContent = "No data for this combination";
dataWrap.appendChild(empty);
}
// Left panel below grids: combo-axis-specific data (all flake-axis
// selected entries, not just the first — that was the #228 bug).
leftDataWrap.replaceChildren();
const cs1 = section("LoRAs", data.comboLoraRows);
const cs2 = section("Prompts", data.comboPromptRows);
if (cs1) leftDataWrap.appendChild(cs1);
if (cs2) leftDataWrap.appendChild(cs2);
}
// ADetailer A/B toggle (#328): only visible when this run produced both
// regular and ADetailer outputs. It switches the previewed image.
let showAd = false;
let adToggle = null;
if (adetailerAB) {
adToggle = document.createElement("label");
css(adToggle, "display:flex;align-items:center;gap:6px;cursor:pointer;");
const chk = document.createElement("input");
chk.type = "checkbox";
chk.checked = false;
css(chk, "cursor:pointer;margin:0;");
const span = document.createElement("span");
span.textContent = "Show ADetailer output";
adToggle.appendChild(chk);
adToggle.appendChild(span);
chk.addEventListener("change", () => {
showAd = chk.checked;
refreshRight();
});
adToggleWrap.appendChild(adToggle);
}
// Build extra info rows for ADetailer/Upscale settings (#324).
function extraInfoRows() {
const rows = [];
if (adetailer !== "Off") {
rows.push(["ADetailer", adetailer]);
if (adetailerDenoise != null) rows.push(["ADetailer denoise", String(adetailerDenoise)]);
if (adetailerSteps != null) rows.push(["ADetailer steps", String(adetailerSteps)]);
if (adetailerBbox) rows.push(["ADetailer bbox", adetailerBbox]);
}
if (upscale) {
rows.push(["Upscale", "On"]);
if (upscaleModel) rows.push(["Upscale model", upscaleModel]);
if (upscaleFactor != null) rows.push(["Upscale factor", String(upscaleFactor)]);
}
return rows;
}
refreshRight();
} else {
// No combo nodes: single centered panel
const singleWrap = document.createElement("div");
css(singleWrap, "display:flex;flex-direction:column;align-items:center;gap:10px;max-width:520px;margin:0 auto;max-height:72vh;overflow:auto;");
content.appendChild(singleWrap);
const compositeWrap = document.createElement("div");
css(compositeWrap, "display:flex;flex-direction:column;align-items:center;gap:4px;width:100%;box-sizing:border-box;padding:0 4px;");
const compositeImg = document.createElement("img");
css(compositeImg, "width:100%;height:auto;aspect-ratio:1/1;object-fit:contain;border-radius:6px;border:1px solid #333;background:#1a1a1a;");
const compositeLabel = document.createElement("div");
css(compositeLabel, "font-size:11px;color:#888;text-align:center;");
const compositeDimensions = document.createElement("div");
css(compositeDimensions, "font-size:10px;color:#666;text-align:center;");
const adToggleWrap = document.createElement("div");
css(adToggleWrap, "display:flex;align-items:center;gap:8px;font-size:12px;color:#ddd;");
// Name + dimensions above the preview image (#349).
compositeWrap.appendChild(compositeLabel);
compositeWrap.appendChild(compositeDimensions);
compositeWrap.appendChild(compositeImg);
compositeWrap.appendChild(adToggleWrap);
singleWrap.appendChild(compositeWrap);
// Show the ACTUAL generated image dimensions when a real output is shown (#342).
let dimsFromImage = false;
compositeImg.addEventListener("load", () => {
if (dimsFromImage && compositeImg.naturalWidth) {
compositeDimensions.textContent = `${compositeImg.naturalWidth} × ${compositeImg.naturalHeight}`;
}
});
// Double-click the overlay image to reveal it on disk, like the Flake
// Generate node image (#340). Tracks the current combination's generated file.
let currentGenerated = null;
compositeImg.style.cursor = "pointer";
compositeImg.title = "Double-click to reveal in file explorer";
compositeImg.addEventListener("dblclick", () => {
if (!currentGenerated) return;
fetch("/flakes/reveal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: currentGenerated.filename,
subfolder: currentGenerated.subfolder || "",
type: currentGenerated.type || "output",
}),
}).catch((e) => console.error("[flakes] reveal failed", e));
});
function applyAspectRatioSingle(w, h) {
if (!w || !h) return;
// Full-width image (#339): the panel sets the width; height follows the
// output aspect ratio.
compositeImg.style.aspectRatio = `${w} / ${h}`;
}
const dataWrap = document.createElement("div");
css(dataWrap, "display:flex;flex-direction:column;gap:10px;width:100%;");
singleWrap.appendChild(dataWrap);
function section(titleText, rows) {
if (!rows.length) return null;
const wrap = document.createElement("div");
const h = document.createElement("div");
h.textContent = titleText;
css(h, `font-size:11px;font-weight:600;color:${ACCENT};margin-bottom:6px;`);
wrap.appendChild(h);
const list = document.createElement("div");
css(list, "display:flex;flex-wrap:wrap;gap:6px;");
for (const [k, v] of rows) {
const row = document.createElement("div");
css(row, "background:#181818;border:1px solid #333;border-radius:6px;padding:6px 8px;flex:0 0 auto;min-width:0;max-width:100%;box-sizing:border-box;");
const ke = document.createElement("div");
ke.textContent = k;
css(ke, "font-size:10px;color:#888;margin-bottom:2px;");
const ve = document.createElement("div");
ve.textContent = typeof v === "string" ? v : String(v);
css(ve, "font-size:12px;color:#ddd;word-break:break-word;white-space:pre-wrap;");
row.appendChild(ke);
row.appendChild(ve);
list.appendChild(row);
}
wrap.appendChild(list);
return wrap;
}
// ADetailer A/B toggle (#328) for the single-panel path.
let showAd = false;
if (adetailerAB) {
const adToggle = document.createElement("label");
css(adToggle, "display:flex;align-items:center;gap:6px;cursor:pointer;");
const chk = document.createElement("input");
chk.type = "checkbox";
chk.checked = false;
css(chk, "cursor:pointer;margin:0;");
const span = document.createElement("span");
span.textContent = "Show ADetailer output";
adToggle.appendChild(chk);
adToggle.appendChild(span);
chk.addEventListener("change", () => {
showAd = chk.checked;
refreshRight();
});
adToggleWrap.appendChild(adToggle);
}
// Extra info rows for ADetailer/Upscale settings (#324).
function extraInfoRows() {
const rows = [];
if (adetailer !== "Off") {
rows.push(["ADetailer", adetailer]);
if (adetailerDenoise != null) rows.push(["ADetailer denoise", String(adetailerDenoise)]);
if (adetailerSteps != null) rows.push(["ADetailer steps", String(adetailerSteps)]);
if (adetailerBbox) rows.push(["ADetailer bbox", adetailerBbox]);
}
if (upscale) {
rows.push(["Upscale", "On"]);
if (upscaleModel) rows.push(["Upscale model", upscaleModel]);
if (upscaleFactor != null) rows.push(["Upscale factor", String(upscaleFactor)]);
}
return rows;
}
// Match the key flake-generate.js writes when there's no combo
// (queue.js stamps an empty key in that case).
const key = model.axes.length === 0
? ""
: combinationKeyFor(model, model.axes.map(() => 0));
function selectedResult() {
const images = getImages();
const byKey = showAd ? images.ad : images.combo;
return byKey[key] ?? byKey[""] ?? null;
}
let imageToken = 0;
async function renderImage(data) {
const token = ++imageToken;
const generated = selectedResult();
currentGenerated = generated;
applyAspectRatioSingle(data.width, data.height);
if (generated) {
dimsFromImage = true;
compositeImg.src = `/view?filename=${encodeURIComponent(generated.filename)}&type=${generated.type || "output"}&subfolder=${encodeURIComponent(generated.subfolder || "")}`;
const sub = (generated.subfolder || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
compositeLabel.textContent = sub ? `${sub}/${generated.filename}` : generated.filename;
compositeDimensions.textContent = (compositeImg.complete && compositeImg.naturalWidth)
? `${compositeImg.naturalWidth} × ${compositeImg.naturalHeight}`
: (data.dimensions || "");
} else {
dimsFromImage = false;
compositeLabel.textContent = "No image generated yet";
const urls = data.coverUrls.length ? data.coverUrls : (model.fixed.presetName ? [`/flakes/preset_cover?name=${encodeURIComponent(model.fixed.presetName)}`] : []);
// Match the element AR to the composite canvas so the cover tiles
// fill the field with no letterbox; buildComposite uses the same
// dims (square fallback when unknown) (#354).
compositeImg.style.aspectRatio = (data.width && data.height) ? `${data.width} / ${data.height}` : "1 / 1";
const src = await buildComposite(urls, data.width, data.height);
if (token !== imageToken) return;
compositeImg.src = src;
compositeDimensions.textContent = data.dimensions || "";
}
}
let refreshToken = 0;
let shownData = null;
async function refreshRight() {
const token = ++refreshToken;
// Even when no axis has multiple options, we may still have one or
// more axes with a single item each (e.g. one preset, one combo
// flake). combinationData needs an entry per axis, otherwise it
// dereferences `undefined` and the composite image stays empty.
const sel = model.axes.map(axis => axis.items[0]).filter(Boolean);
const data = await combinationData(model, sel);
if (token !== refreshToken) return;
shownData = data;
renderImage(data);
// Single panel: no left/right split, so merge common+combo data.
dataWrap.replaceChildren();
const s1 = section("Model", data.modelRows);
const s2 = section("LoRAs", [...data.commonLoraRows, ...data.comboLoraRows]);
const s3 = section("Prompts", [...data.commonPromptRows, ...data.comboPromptRows]);
const s4 = section("Generation settings", extraInfoRows());
for (const s of [s1, s2, s3, s4]) if (s) dataWrap.appendChild(s);
if (!s1 && !s2 && !s3 && !s4) {
const empty = document.createElement("div");
css(empty, "font-size:12px;color:#555;text-align:center;padding:20px;");
empty.textContent = "No data for this combination";
dataWrap.appendChild(empty);
}
}
const unsubscribe = subscribeBatch(() => {
if (shownData && selectedResult() !== currentGenerated) renderImage(shownData);
});
handlers.onClose = unsubscribe;
refreshRight();
}
const closeBtn = document.createElement("button");
closeBtn.textContent = "Close";
css(closeBtn, "padding:6px 18px;background:#333;color:#ddd;border:1px solid #555;border-radius:6px;cursor:pointer;font-size:13px;");
closeBtn.addEventListener("click", () => close());
footer.appendChild(closeBtn);
}