feat: combine Models+Inputs into Generation Data combination overlay (#188, #189, #190, #191)

Replaces the two Models/Inputs preview buttons with a single Generation
Data button (#188) opening a split overlay:

- Left half: one horizontal-scroll grid per upstream Flake Model Combo /
  Flake Combo node, defaulting to the first item, selectable (#189).
- Right half: model/output/LoRA/prompt data for the selected combination
  only, pulling fixed preset + common stack flakes + the chosen combo
  flake per axis (#190).
- Top of right half: k-d-tree composite of the selected combo covers
  (top half + bottom quarters for the 3-item case), with a
  'No image generated yet' label that swaps to the actual output +
  filename once that combination has been generated (#191).

queue.js stamps an order-independent combination key onto FlakeGenerate
before each queued prompt so onExecuted can map outputs to combinations.
closes #187
This commit is contained in:
Mehdi Chaid
2026-05-15 21:57:43 -04:00
parent 9ff0abc2b0
commit f8c82d4ea4
3 changed files with 465 additions and 244 deletions
+11
View File
@@ -113,6 +113,8 @@ app.queuePrompt = async function(number, batchCount = 1) {
}
}
const flakeGenerateNodes = app.graph.nodes.filter(n => n.type === "FlakeGenerate");
try {
for (const combination of combinations) {
for (const item of combination) {
@@ -129,6 +131,15 @@ app.queuePrompt = async function(number, batchCount = 1) {
item.node._model_combo_render?.();
}
}
// Stamp the active combination key (order-independent: combo node
// id : selected index, sorted by node id) so FlakeGenerate can map
// the resulting image to this combination in its data overlay.
const comboKey = combination
.map(it => [it.node.id, it.index])
.sort((a, b) => a[0] - b[0])
.map(([id, idx]) => `${id}:${idx}`)
.join("|");
for (const fg of flakeGenerateNodes) fg._pending_combination_key = comboKey;
await _originalQueuePrompt.call(this, number, 1);
}
} finally {
+48 -244
View File
@@ -1,7 +1,8 @@
import { css } from "../utils.js";
import { CATEGORIES, CATEGORY_STYLE, makeOverlay, makeButton } from "./flake-preview.js";
import { fetchFlake, fetchPreset } from "../api.js";
import { computeJobCount } from "../queue.js";
import { buildModel, openGenerationDataOverlay } from "./generation-data.js";
const ACCENT = "#4a9eff";
export function setupFlakeGenerateWidget(node) {
if (!node.properties) node.properties = {};
@@ -9,13 +10,13 @@ export function setupFlakeGenerateWidget(node) {
const container = document.createElement("div");
css(container, "display:flex;flex-direction:column;gap:4px;padding:4px 6px;font-size:12px;color:#ddd;");
// ── Jobs label (left of the preview buttons) ──
const jobsRow = document.createElement("div");
css(jobsRow, "display:flex;align-items:center;gap:8px;font-size:12px;color:#ddd;");
// ── Jobs label + Generation Data button (below the Seed section) ──
const topRow = document.createElement("div");
css(topRow, "display:flex;align-items:center;gap:8px;font-size:12px;color:#ddd;min-height:36px;");
const jobsLabel = document.createElement("span");
css(jobsLabel, "font-size:11px;font-weight:600;color:#4a9eff;");
jobsRow.appendChild(jobsLabel);
container.appendChild(jobsRow);
css(jobsLabel, "font-size:11px;font-weight:600;color:" + ACCENT + ";flex-shrink:0;");
topRow.appendChild(jobsLabel);
let lastJobCount = -1;
function refreshJobCount() {
@@ -26,211 +27,33 @@ export function setupFlakeGenerateWidget(node) {
}
refreshJobCount();
const jobsPoll = setInterval(refreshJobCount, 200);
const origOnRemoved = node.onRemoved;
node.onRemoved = function () {
clearInterval(jobsPoll);
return origOnRemoved?.apply(this, arguments);
};
// ── Preview Grid (1x2 — 2 buttons) ──
const gridContainer = document.createElement("div");
css(gridContainer, "position:relative;display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:0;font-size:12px;color:#ddd;min-height:70px;");
let currentPreviewData = node.properties._preview_data || null;
function renderPreviewGrid(previewData) {
gridContainer.replaceChildren();
for (const cat of CATEGORIES) {
const info = CATEGORY_STYLE[cat];
const data = previewData ? (previewData[cat] || {}) : {};
const hasData = previewData && Object.keys(data).length > 0;
const btn = makeButton(cat, info, hasData, (category) => {
const catData = currentPreviewData ? (currentPreviewData[category] || {}) : {};
makeOverlay(category, catData);
});
gridContainer.appendChild(btn);
}
}
// Walk the graph upstream from this node and collect all FlakeStack/FlakeCombo
// nodes in order, plus the terminal FlakeModelPreset.
function collectUpstreamChain(startNode) {
const chain = []; // [{type, node}]
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 });
// Follow flake_data input upstream
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") {
chain.push({ type: t, node: current });
break;
} else {
break;
}
}
// Reverse so preset is first, then stacks in order
return chain.reverse();
}
// Strip img/<family>/ prefix from a LoRA path for cleaner display
function stripLoraPrefix(path) {
return path.replace(/^img\/[^/]+\//, "");
}
async function buildPreviewFromUpstream() {
const genDataBtn = document.createElement("div");
css(genDataBtn, `flex:1;background:#222;border:1px solid ${ACCENT}44;border-radius:8px;padding:8px 6px;cursor:pointer;transition:border-color 0.15s,background 0.15s;display:flex;align-items:center;justify-content:center;gap:6px;min-height:30px;box-sizing:border-box;`);
const gdIcon = document.createElement("span");
gdIcon.textContent = "📊";
gdIcon.style.fontSize = "14px";
genDataBtn.appendChild(gdIcon);
const gdLabel = document.createElement("span");
gdLabel.textContent = "Generation Data";
css(gdLabel, `font-size:11px;font-weight:600;color:${ACCENT};`);
genDataBtn.appendChild(gdLabel);
genDataBtn.addEventListener("mouseenter", () => { genDataBtn.style.borderColor = ACCENT; genDataBtn.style.background = "#282828"; });
genDataBtn.addEventListener("mouseleave", () => { genDataBtn.style.borderColor = ACCENT + "44"; genDataBtn.style.background = "#222"; });
genDataBtn.addEventListener("click", (e) => {
e.stopPropagation();
const flakeInput = node.inputs?.find(i => i.name === "flake_data");
if (!flakeInput?.link) return;
const graph = node.graph;
if (!graph) return;
const link = graph.links[flakeInput.link];
if (!link) return;
const upstreamNode = graph.getNodeById(link.origin_id);
if (!upstreamNode) return;
const chain = collectUpstreamChain(upstreamNode);
// Collect individual fields; we'll group them before storing
let checkpoint = null, vae = null, textEncoder = null;
let clipSkip = null, width = null, height = null;
let steps = null, cfg = null, sampler = null, scheduler = null;
const loraRows = []; // [{label, strength, path}]
const modelsInfo = {};
const inputsInfo = {};
let filenamePrefix = null;
for (const { type, node: chainNode } of chain) {
if (type === "FlakeModelPreset") {
const presetWidget = chainNode.widgets?.find(w => w.name === "preset");
const presetName = presetWidget?.value;
if (!presetName || presetName === "Select a preset..." || presetName === "No model preset is selected") continue;
try {
const data = await fetchPreset(presetName);
const label = data.display_name || presetName.split("/").pop() || presetName;
if (data.checkpoint) checkpoint = data.checkpoint;
if (data.vae && data.vae !== "baked-in") vae = data.vae;
if (data.text_encoder && data.text_encoder !== "baked-in") textEncoder = data.text_encoder;
if (data.clip_skip) clipSkip = String(data.clip_skip);
if (data.width) width = String(data.width);
if (data.height) height = String(data.height);
if (data.steps) steps = String(data.steps);
if (data.cfg) cfg = String(data.cfg);
if (data.sampler) sampler = data.sampler;
if (data.scheduler) scheduler = data.scheduler;
if (data.filename_prefix) filenamePrefix = data.filename_prefix;
// Preset prompts
const pos = (data.prompt?.positive || "").trim();
const neg = (data.prompt?.negative || "").trim();
if (pos) inputsInfo[`${label} · Positive`] = pos;
if (neg) inputsInfo[`${label} · Negative`] = neg;
} catch { /* skip */ }
} else if (type === "FlakeStack" || type === "FlakeCombo") {
// For FlakeCombo, read the full list from properties (flakes_json only holds the active entry)
let entries;
if (type === "FlakeCombo") {
entries = chainNode.properties?._combo_flakes || [];
} else {
const flakesWidget = chainNode.widgets?.find(w => w.name === "flakes_json");
if (!flakesWidget) continue;
try { entries = JSON.parse(flakesWidget.value || "[]"); } catch { continue; }
}
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (entry.inline || entry.bypassed || !entry.name) continue;
try {
const data = await fetchFlake(entry.name);
const label = entry.display_name || data.name || entry.name.split("/").pop() || entry.name;
// LoRAs — collect for grouped display
if (Array.isArray(data.loras)) {
data.loras.forEach((lr, i) => {
const rawPath = lr.name || lr.path || `LoRA #${i + 1}`;
const strength = entry.loras?.[i] ?? lr.strength ?? 1;
loraRows.push({ label, strength, path: rawPath });
});
}
// Resolution override
if (data.width) width = String(data.width);
if (data.height) height = String(data.height);
// Prompts
const pos = (data.positive_prompt || "").trim();
const neg = (data.negative_prompt || "").trim();
const variant = entry.variant || {};
let posExtra = "";
let negExtra = "";
for (const [group, choice] of Object.entries(variant)) {
const v = data.variants?.[group]?.[choice];
if (v?.positive) posExtra += (posExtra ? ", " : "") + v.positive;
if (v?.negative) negExtra += (negExtra ? ", " : "") + v.negative;
}
const finalPos = [pos, posExtra].filter(Boolean).join(", ");
const finalNeg = [neg, negExtra].filter(Boolean).join(", ");
if (finalPos) inputsInfo[`${label} · Positive`] = finalPos;
if (finalNeg) inputsInfo[`${label} · Negative`] = finalNeg;
// ControlNets
if (Array.isArray(data.controlnets)) {
data.controlnets.forEach((cn, i) => {
if (cn.model_name || cn.image_name) {
inputsInfo[`${label} · ControlNet ${i + 1}`] = [cn.model_name, cn.image_name, cn.strength != null ? `strength: ${cn.strength}` : ""].filter(Boolean).join(" | ");
}
});
}
} catch { /* skip */ }
}
}
let startNode = null;
if (flakeInput?.link && graph) {
const link = graph.links[flakeInput.link];
if (link) startNode = graph.getNodeById(link.origin_id);
}
// Build grouped Models rows
if (checkpoint) modelsInfo["Checkpoint"] = checkpoint;
// Group: Width · Height · Clip Skip · Steps · CFG on one line
const row1Parts = [
width && `W: ${width}`,
height && `H: ${height}`,
clipSkip && `Skip: ${clipSkip}`,
steps && `Steps: ${steps}`,
cfg && `CFG: ${cfg}`,
].filter(Boolean);
if (row1Parts.length) modelsInfo["Resolution & Sampling"] = row1Parts.join(" · ");
// Group: VAE · Text Encoder · Sampler · Scheduler on one line
const row2Parts = [
vae && `VAE: ${vae}`,
textEncoder && `TE: ${textEncoder}`,
sampler && `Sampler: ${sampler}`,
scheduler && `Sched: ${scheduler}`,
].filter(Boolean);
if (row2Parts.length) modelsInfo["Pipeline"] = row2Parts.join(" · ");
// LoRA rows: "LoRA [strength]" → "stripped/path"
for (const lr of loraRows) {
const strengthStr = Number.isInteger(lr.strength) ? String(lr.strength) : lr.strength.toFixed(2);
modelsInfo[`LoRA [${strengthStr}]`] = stripLoraPrefix(lr.path);
}
// Build Inputs with filename prefix as the first field
const orderedInputs = {};
if (filenamePrefix) orderedInputs["Filename Prefix"] = filenamePrefix;
Object.assign(orderedInputs, inputsInfo);
const hasAny = Object.keys(modelsInfo).length > 0 || Object.keys(orderedInputs).length > 0;
if (!hasAny) return;
currentPreviewData = { Models: modelsInfo, Inputs: orderedInputs };
node.properties._preview_data = currentPreviewData;
renderPreviewGrid(currentPreviewData);
}
renderPreviewGrid(currentPreviewData);
container.appendChild(gridContainer);
const model = startNode ? buildModel(startNode) : { axes: [], fixed: { presetName: null, stackFlakes: [] } };
openGenerationDataOverlay(model, node.properties._images_by_combo || {});
});
topRow.appendChild(genDataBtn);
container.appendChild(topRow);
// ── Image Display ──
const imageContainer = document.createElement("div");
@@ -283,38 +106,27 @@ export function setupFlakeGenerateWidget(node) {
// ── Add DOM widget ──
const genWidget = node.addDOMWidget("flake_generate_ui", "div", container, { serialize: false, margin: 4 });
const origOnConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function (type, index, connected, link_info) {
const r = origOnConnectionsChange?.apply(this, arguments);
const flakeInputIdx = node.inputs?.findIndex(i => i.name === "flake_data") ?? 0;
if (type === 1 && index === flakeInputIdx) {
if (connected) {
buildPreviewFromUpstream();
} else {
currentPreviewData = null;
node.properties._preview_data = null;
renderPreviewGrid(null);
}
}
return r;
const origOnRemoved = node.onRemoved;
node.onRemoved = function () {
clearInterval(jobsPoll);
return origOnRemoved?.apply(this, arguments);
};
const origOnExecuted = node.onExecuted;
node.onExecuted = function (output) {
const r = origOnExecuted?.apply(this, arguments);
if (output) {
if (output.flake_images && output.flake_images.length > 0) {
const img = output.flake_images[0];
showImage(img);
node.properties._last_image = img;
} else {
clearImage();
}
if (output.preview_data) {
currentPreviewData = Array.isArray(output.preview_data) ? output.preview_data[0] : output.preview_data;
node.properties._preview_data = currentPreviewData;
renderPreviewGrid(currentPreviewData);
}
if (output && output.flake_images && output.flake_images.length > 0) {
const img = output.flake_images[0];
showImage(img);
node.properties._last_image = img;
// Record the generated image keyed by the active combination (set
// by queue.js before this prompt) so the Generation Data overlay
// can show it for that combination. Empty key = no combo nodes.
const comboKey = node._pending_combination_key || "";
node.properties._images_by_combo = node.properties._images_by_combo || {};
node.properties._images_by_combo[comboKey] = img;
} else if (output && output.flake_images) {
clearImage();
}
return r;
};
@@ -328,14 +140,6 @@ export function setupFlakeGenerateWidget(node) {
} else {
clearImage();
}
if (node.properties?._preview_data) {
currentPreviewData = node.properties._preview_data;
renderPreviewGrid(currentPreviewData);
} else {
renderPreviewGrid(null);
}
// Rebuild from the graph after links are restored so both buttons stay active
setTimeout(() => buildPreviewFromUpstream(), 0);
return r;
};
+406
View File
@@ -0,0 +1,406 @@
import { css } from "../utils.js";
import { openOverlay } from "../modal.js";
import { fetchFlake, fetchPreset, getCoverUrl, getVariantImageUrl } from "../api.js";
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 => {
const variantSel = Object.entries(e.variant || {}).find(([, c]) => c);
let cover = getCoverUrl(e.name);
if (variantSel) cover = getVariantImageUrl(e.name, variantSel[0], variantSel[1]);
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: cover,
};
}),
});
}
}
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;
}
});
}
// ── Per-combination data assembly ──────────────────────────────────────────
async function combinationData(model, selection) {
// selection: array of chosen item per axis (same order as model.axes)
let presetName = model.fixed.presetName;
model.axes.forEach((axis, i) => {
if (axis.kind === "model") presetName = selection[i].presetName;
});
const out = { modelRows: [], outputRows: [], loraRows: [], promptRows: [], coverUrls: [] };
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.outputRows.push(["Dimensions", `${d.width || "?"} × ${d.height || "?"}`]);
if (d.filename_prefix) out.outputRows.push(["Filename", d.filename_prefix]);
const label = d.display_name || presetName.split("/").pop() || presetName;
const pos = (d.prompt?.positive || "").trim();
const neg = (d.prompt?.negative || "").trim();
if (pos) out.promptRows.push([`${label} · Positive`, pos]);
if (neg) out.promptRows.push([`${label} · Negative`, neg]);
} catch { /* skip */ }
}
// Gather flakes: fixed stack flakes + the selected combo flake per flake-axis
const flakeEntries = [...model.fixed.stackFlakes];
model.axes.forEach((axis, i) => {
if (axis.kind === "flake") flakeEntries.push(selection[i].entry);
});
for (const entry of flakeEntries) {
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 p = (lr.name || lr.path || `LoRA #${idx + 1}`).replace(/^img\/[^/]+\//, "");
const s = entry.loras?.[idx] ?? lr.strength ?? 1;
out.loraRows.push([`LoRA [${Number.isInteger(s) ? s : Number(s).toFixed(2)}]`, p]);
});
}
const pos = (d.positive_prompt || "").trim();
const neg = (d.negative_prompt || "").trim();
let posX = "", negX = "";
for (const [g, c] of Object.entries(entry.variant || {})) {
const v = d.variants?.[g]?.[c];
if (v?.positive) posX += (posX ? ", " : "") + v.positive;
if (v?.negative) negX += (negX ? ", " : "") + v.negative;
}
const fp = [pos, posX].filter(Boolean).join(", ");
const fn = [neg, negX].filter(Boolean).join(", ");
if (fp) out.promptRows.push([`${vLabel} · Positive`, fp]);
if (fn) out.promptRows.push([`${vLabel} · Negative`, fn]);
} catch { /* skip */ }
}
// 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) {
const { content, footer, close } = openOverlay();
css(content.parentElement, content.parentElement.style.cssText + "min-width:760px;max-width:920px;");
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 split = document.createElement("div");
css(split, "display:flex;gap:14px;align-items:flex-start;");
content.appendChild(split);
// Left half: one horizontal scroll grid per combo axis
const left = document.createElement("div");
css(left, "flex:1;display:flex;flex-direction:column;gap:12px;min-width:0;");
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)
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");
css(card, `position:relative;flex:0 0 auto;width:72px;height:80px;border-radius:4px;cursor:pointer;background:#2a2a2a;background-image:url(${item.coverUrl});background-size:cover;background-position:center;border:2px solid ${ii === selIdx[ai] ? ACCENT : "transparent"};box-sizing:border-box;`);
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);
});
// Right half: composite image + label + data sections
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;");
compositeWrap.appendChild(compositeImg);
compositeWrap.appendChild(compositeLabel);
right.appendChild(compositeWrap);
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:4px;`);
wrap.appendChild(h);
const list = document.createElement("div");
css(list, "display:flex;flex-direction:column;gap:4px;");
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;");
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; // a newer selection superseded this
// 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);
}
dataWrap.replaceChildren();
const s1 = section("Model", data.modelRows);
const s2 = section("Output", data.outputRows);
const s3 = section("LoRAs", data.loraRows);
const s4 = section("Prompts", data.promptRows);
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);
}
}
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);
}