Files
JeyzerMC-comfyui-flakes/web/widgets/generation-data.js
T
Mehdi Chaid 39a0eb4123 feat: improve generation data overlay layout and data display (#210)
- Make overlay panel take nearly full viewport width (95vw).
- Only split into two halves when at least one combo axis has multiple
  options; otherwise use a single centered panel.
- Convert data sections from vertical stacks to flex-wrap grids with
  variable-width items that stack horizontally then vertically.
- Remove Output section; move dimensions below the composite image.
- Update LoRA rows to show actual lora name as label and path as value.
- Show all prompts individually (base + variant) instead of combining
  them into single rows.
2026-05-20 10:55:16 -04:00

540 lines
27 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 } 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: [], loraRows: [], promptRows: [], coverUrls: [], dimensions: 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 || "?"}`;
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 (Model)`, pos]);
if (neg) out.promptRows.push([`${label} · Negative (Model)`, 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 name = lr.name || `LoRA #${idx + 1}`;
const path = lr.path || lr.name || "";
const s = entry.loras?.[idx] ?? lr.strength ?? 1;
const label = `${name} [${Number.isInteger(s) ? s : Number(s).toFixed(2)}]`;
out.loraRows.push([label, path]);
});
}
const pos = (d.positive_prompt || "").trim();
const neg = (d.negative_prompt || "").trim();
if (pos) out.promptRows.push([`${vLabel} · Positive`, pos]);
if (neg) out.promptRows.push([`${vLabel} · Negative`, neg]);
for (const [g, c] of Object.entries(entry.variant || {})) {
const v = d.variants?.[g]?.[c];
if (v?.positive) out.promptRows.push([`${vLabel} · ${g} · Positive`, v.positive]);
if (v?.negative) out.promptRows.push([`${vLabel} · ${g} · Negative`, v.negative]);
}
} 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 + "width:95vw;max-width:1400px;min-width:760px;");
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:14px;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");
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;`);
if (item.coverUrl) {
card.style.backgroundImage = `url(${item.coverUrl})`;
}
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);
// 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");
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);
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 1 auto;min-width:140px;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;
// 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 || "";
// Common fields go on the right (model preset, fixed stack flakes)
dataWrap.replaceChildren();
const s1 = section("Model", data.modelRows);
const s2 = section("LoRAs", data.loraRows);
const s3 = section("Prompts", data.promptRows);
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);
}
// Combo-specific fields on the left below grids
leftDataWrap.replaceChildren();
const comboFlake = sel[model.axes.findIndex(a => a.kind === "flake")];
if (comboFlake && comboFlake.entry) {
try {
const d = await fetchFlake(comboFlake.entry.name);
const label = comboFlake.entry.display_name || d.name || comboFlake.entry.name.split("/").pop() || comboFlake.entry.name;
const choices = Object.values(comboFlake.entry.variant || {}).filter(Boolean);
const vLabel = label + (choices.length ? ` (${choices.join(", ")})` : "");
const comboLoraRows = [];
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 = comboFlake.entry.loras?.[idx] ?? lr.strength ?? 1;
const label = `${name} [${Number.isInteger(s) ? s : Number(s).toFixed(2)}]`;
comboLoraRows.push([label, path]);
});
}
const comboPromptRows = [];
const pos = (d.positive_prompt || "").trim();
const neg = (d.negative_prompt || "").trim();
if (pos) comboPromptRows.push([`${vLabel} · Positive`, pos]);
if (neg) comboPromptRows.push([`${vLabel} · Negative`, neg]);
for (const [g, c] of Object.entries(comboFlake.entry.variant || {})) {
const v = d.variants?.[g]?.[c];
if (v?.positive) comboPromptRows.push([`${vLabel} · ${g} · Positive`, v.positive]);
if (v?.negative) comboPromptRows.push([`${vLabel} · ${g} · Negative`, v.negative]);
}
const cs1 = section("LoRAs", comboLoraRows);
const cs2 = section("Prompts", comboPromptRows);
if (cs1) leftDataWrap.appendChild(cs1);
if (cs2) leftDataWrap.appendChild(cs2);
} catch { /* skip */ }
}
}
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);
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 1 auto;min-width:140px;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 data = await combinationData(model, []);
if (token !== refreshToken) return;
const generated = lastImagesByCombo && 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 || "";
dataWrap.replaceChildren();
const s1 = section("Model", data.modelRows);
const s2 = section("LoRAs", data.loraRows);
const s3 = section("Prompts", data.promptRows);
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);
}