The overlay's combo grid cards built coverUrl as "getVariantImageUrl(...) if a variant is selected, else getCoverUrl(...)" with no fallback. When the selected variant choice's image didn't exist, the card's background-image silently 404'd and the cell was blank. Mirror the flake-combo node grid behavior: track both base and variant cover URLs on the item, probe-load the variant image, and fall back to the base cover on error. Show base immediately while probing so the cell is never blank. Closes #237
722 lines
35 KiB
JavaScript
722 lines
35 KiB
JavaScript
import { css } from "../utils.js";
|
||
import { openOverlay } from "../modal.js";
|
||
import { fetchFlake, fetchPreset, getCoverUrl, getVariantImageUrl } from "../api.js";
|
||
import { getActiveBatch } from "../queue.js";
|
||
|
||
// Parse a comboKey like "12:0|7:2" into a list of [nodeId, itemIdx] pairs.
|
||
function parseComboKey(key) {
|
||
if (!key) return [];
|
||
return key.split("|").map(pair => {
|
||
const [n, i] = pair.split(":").map(Number);
|
||
return [n, i];
|
||
});
|
||
}
|
||
|
||
// 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 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 presets = node.properties?._combo_presets || [];
|
||
if (presets.length === 0) continue;
|
||
axes.push({
|
||
node, kind: "model",
|
||
label: "Model Preset",
|
||
items: presets.map(p => ({
|
||
label: (node.properties?._combo_display_names?.[p]) || p.split(/[\/\\]+/).pop() || p,
|
||
presetName: p,
|
||
coverUrl: `/flakes/preset_cover?name=${encodeURIComponent(p)}`,
|
||
})),
|
||
});
|
||
} else if (type === "FlakeStack") {
|
||
for (const e of activeEntries(node, "FlakeStack")) fixed.stackFlakes.push(e);
|
||
} else if (type === "FlakeCombo") {
|
||
const flakes = activeEntries(node, "FlakeCombo");
|
||
if (flakes.length === 0) continue;
|
||
axes.push({
|
||
node, kind: "flake",
|
||
label: "Flake Combo",
|
||
items: flakes.map(e => {
|
||
// #237: fall back to the flake's main cover image when no
|
||
// variant is selected (or when the variant image 404s —
|
||
// see card render where we probe-load below).
|
||
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,
|
||
};
|
||
}),
|
||
});
|
||
}
|
||
}
|
||
return { axes, fixed };
|
||
}
|
||
|
||
// ── k-d-tree composite image ───────────────────────────────────────────────
|
||
// Recursively partition a rectangle, alternating split axis, one leaf per
|
||
// image. The first image gets the larger leading half on each split.
|
||
function partition(x, y, w, h, urls, horizontal) {
|
||
if (urls.length === 1) return [{ x, y, w, h, url: urls[0] }];
|
||
const headCount = Math.ceil(urls.length / 2);
|
||
const head = urls.slice(0, headCount);
|
||
const tail = urls.slice(headCount);
|
||
if (horizontal) {
|
||
const hh = h * (headCount / urls.length);
|
||
return [
|
||
...partition(x, y, w, hh, head, !horizontal),
|
||
...partition(x, y + hh, w, h - hh, tail, !horizontal),
|
||
];
|
||
}
|
||
const ww = w * (headCount / urls.length);
|
||
return [
|
||
...partition(x, y, ww, h, head, !horizontal),
|
||
...partition(x + ww, y, w - ww, h, tail, !horizontal),
|
||
];
|
||
}
|
||
|
||
function buildComposite(urls, size = 256) {
|
||
return new Promise((resolve) => {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.fillStyle = "#1a1a1a";
|
||
ctx.fillRect(0, 0, size, size);
|
||
if (urls.length === 0) { resolve(canvas.toDataURL()); return; }
|
||
// For the 3-image case: top half = first, bottom split in two.
|
||
const rects = urls.length === 3
|
||
? [
|
||
{ x: 0, y: 0, w: size, h: size / 2, url: urls[0] },
|
||
{ x: 0, y: size / 2, w: size / 2, h: size / 2, url: urls[1] },
|
||
{ x: size / 2, y: size / 2, w: size / 2, h: size / 2, url: urls[2] },
|
||
]
|
||
: partition(0, 0, size, size, urls, true);
|
||
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;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Convert a single flake entry into label/lora/prompt rows.
|
||
async function flakeEntryRows(entry) {
|
||
const loraRows = [];
|
||
const promptRows = [];
|
||
try {
|
||
const d = await fetchFlake(entry.name);
|
||
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]);
|
||
});
|
||
}
|
||
const pos = ((d.prompt && d.prompt.positive) || "").trim();
|
||
const neg = ((d.prompt && d.prompt.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 */ }
|
||
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) => [axis.node.id, selIdx[i]])
|
||
.sort((a, b) => a[0] - b[0])
|
||
.map(([id, idx]) => `${id}:${idx}`)
|
||
.join("|");
|
||
}
|
||
|
||
// ── Overlay UI ─────────────────────────────────────────────────────────────
|
||
export function openGenerationDataOverlay(model, lastImagesByCombo) {
|
||
let { content, footer, close } = openOverlay();
|
||
// Cap width at 70% of previous (#229 — reduce overlay width by 30%): the
|
||
// previous cap was min(1400px, 95vw); new cap is min(980px, 66vw).
|
||
css(content.parentElement, content.parentElement.style.cssText + "width:auto;max-width:min(980px,66vw);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 half: combo axis grids + combo-specific fields below
|
||
const left = document.createElement("div");
|
||
css(left, "flex:1;display:flex;flex-direction:column;gap:12px;min-width:0;max-height:60vh;overflow:auto;");
|
||
// Right half: 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:60vh;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;gap:6px;overflow-x:auto;padding-bottom:4px;");
|
||
const axisCards = [];
|
||
axis.items.forEach((item, ii) => {
|
||
const card = document.createElement("div");
|
||
card.dataset.nodeId = String(axis.node.id);
|
||
card.dataset.itemIdx = String(ii);
|
||
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) {
|
||
if (Number(c.dataset.nodeId) === nodeId && Number(c.dataset.itemIdx) === 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.
|
||
}
|
||
})();
|
||
// Tear down the listeners when the overlay closes.
|
||
const _origClose = close;
|
||
close = function(...args) {
|
||
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);
|
||
}
|
||
return _origClose(...args);
|
||
};
|
||
|
||
// Right: composite image + common data sections
|
||
const compositeWrap = document.createElement("div");
|
||
css(compositeWrap, "display:flex;flex-direction:column;align-items:center;gap: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:256px;height:256px;object-fit:cover;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;");
|
||
compositeWrap.appendChild(compositeImg);
|
||
compositeWrap.appendChild(compositeLabel);
|
||
compositeWrap.appendChild(compositeDimensions);
|
||
right.appendChild(compositeWrap);
|
||
function applyAspectRatio(w, h) {
|
||
if (!w || !h) return;
|
||
const MAX = 256;
|
||
const ratio = w / h;
|
||
let cw, ch;
|
||
if (ratio >= 1) { cw = MAX; ch = Math.round(MAX / ratio); }
|
||
else { ch = MAX; cw = Math.round(MAX * ratio); }
|
||
compositeImg.style.width = `${cw}px`;
|
||
compositeImg.style.height = `${ch}px`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
let refreshToken = 0;
|
||
async function refreshRight() {
|
||
const token = ++refreshToken;
|
||
const sel = currentSelection();
|
||
const key = combinationKeyFor(model, selIdx);
|
||
const data = await combinationData(model, sel);
|
||
if (token !== refreshToken) return;
|
||
|
||
// Match the preview aspect ratio to the output's resolution (#231).
|
||
applyAspectRatio(data.width, data.height);
|
||
|
||
// Composite (or generated output if we have one for this combination)
|
||
const generated = lastImagesByCombo && lastImagesByCombo[key];
|
||
if (generated) {
|
||
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;
|
||
} else {
|
||
compositeLabel.textContent = "No image generated yet";
|
||
const urls = data.coverUrls.length ? data.coverUrls : (model.fixed.presetName ? [`/flakes/preset_cover?name=${encodeURIComponent(model.fixed.presetName)}`] : []);
|
||
compositeImg.src = await buildComposite(urls);
|
||
}
|
||
compositeDimensions.textContent = data.dimensions || "";
|
||
|
||
// 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);
|
||
for (const s of [s1, s2, s3]) if (s) dataWrap.appendChild(s);
|
||
if (!s1 && !s2 && !s3) {
|
||
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);
|
||
}
|
||
|
||
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:60vh;overflow:auto;");
|
||
content.appendChild(singleWrap);
|
||
|
||
const compositeWrap = document.createElement("div");
|
||
css(compositeWrap, "display:flex;flex-direction:column;align-items:center;gap:4px;");
|
||
const compositeImg = document.createElement("img");
|
||
css(compositeImg, "width:256px;height:256px;object-fit:cover;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;");
|
||
compositeWrap.appendChild(compositeImg);
|
||
compositeWrap.appendChild(compositeLabel);
|
||
compositeWrap.appendChild(compositeDimensions);
|
||
singleWrap.appendChild(compositeWrap);
|
||
function applyAspectRatioSingle(w, h) {
|
||
if (!w || !h) return;
|
||
const MAX = 256;
|
||
const ratio = w / h;
|
||
let cw, ch;
|
||
if (ratio >= 1) { cw = MAX; ch = Math.round(MAX / ratio); }
|
||
else { ch = MAX; cw = Math.round(MAX * ratio); }
|
||
compositeImg.style.width = `${cw}px`;
|
||
compositeImg.style.height = `${ch}px`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
let refreshToken = 0;
|
||
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;
|
||
|
||
// 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));
|
||
applyAspectRatioSingle(data.width, data.height);
|
||
const generated = lastImagesByCombo && (lastImagesByCombo[key] ?? lastImagesByCombo[""]);
|
||
if (generated) {
|
||
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;
|
||
} else {
|
||
compositeLabel.textContent = "No image generated yet";
|
||
const urls = data.coverUrls.length ? data.coverUrls : (model.fixed.presetName ? [`/flakes/preset_cover?name=${encodeURIComponent(model.fixed.presetName)}`] : []);
|
||
compositeImg.src = await buildComposite(urls);
|
||
}
|
||
compositeDimensions.textContent = data.dimensions || "";
|
||
|
||
// 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]);
|
||
for (const s of [s1, s2, s3]) if (s) dataWrap.appendChild(s);
|
||
if (!s1 && !s2 && !s3) {
|
||
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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|