1293 lines
65 KiB
JavaScript
1293 lines
65 KiB
JavaScript
import { css, effectiveVariant, expandEntryVariants, choiceFlakeLinks } from "../utils.js";
|
||
import { openOverlay } from "../modal.js";
|
||
import { fetchFlake, fetchPreset, getCoverUrl, getPresetCoverUrl, getVariantImageUrl, THUMB, fetchFlakeMeta, cachedVariantDefaults, pickedImageUrl } from "../api.js";
|
||
import { getActiveBatch, subscribeBatch, variantKey, itemImageJobs, stackImageJobs } from "../queue.js";
|
||
|
||
// Cartesian product of a flake's variant groups ({group:[choices]}) -> one
|
||
// {group: choice} per combination (#345).
|
||
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). itemKey is the
|
||
// item part of the comboKey queue.js stamps for it (#383).
|
||
function makeFlakeCard(e, itemKey) {
|
||
// #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 variant = effectiveVariant(e.variant, cachedVariantDefaults(e.name));
|
||
const variantSel = Object.entries(variant)[0];
|
||
const baseCover = getCoverUrl(e.name);
|
||
const variantCover = variantSel ? getVariantImageUrl(e.name, variantSel[0], variantSel[1]) : null;
|
||
const choices = Object.values(variant);
|
||
const base = e.display_name || e.name.split("/").pop() || e.name;
|
||
const label = base + (choices.length ? ` (${choices.join(", ")})` : "");
|
||
// A Flake Animate item's job shows its input image instead (#441); the
|
||
// overlay's card stack keeps the flake cover under it (#456).
|
||
if (e.image) {
|
||
const imageUrl = pickedImageUrl(e.image);
|
||
return { label: `${label} · ${e.image.split(/[\\/]/).pop()}`, entry: e, coverUrl: imageUrl, imageUrl, stackCoverUrls: [variantCover, baseCover], itemKey };
|
||
}
|
||
return {
|
||
label,
|
||
entry: e,
|
||
coverUrl: variantCover || baseCover,
|
||
baseCoverUrl: baseCover,
|
||
variantCoverUrl: variantCover,
|
||
// The axis cards are small: they load thumbnails (#451).
|
||
thumbUrl: getCoverUrl(e.name, THUMB),
|
||
variantThumbUrl: variantSel ? getVariantImageUrl(e.name, variantSel[0], variantSel[1], THUMB) : null,
|
||
itemKey,
|
||
};
|
||
}
|
||
|
||
// Parse a comboKey like "12:k3x9a|7:p0d2q.hair=red" into [nodeId, itemKey] pairs.
|
||
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";
|
||
|
||
// Preset source nodes: the image ones and their Flake Animate counterparts.
|
||
const PRESET_TYPES = ["FlakeModelPreset", "FlakeAnimPreset"];
|
||
export const MODEL_COMBO_TYPES = ["FlakeModelCombo", "FlakeAnimPresetCombo"];
|
||
export const STACK_TYPES = ["FlakeStack", "FlakeAnimStack"];
|
||
export const FLAKE_COMBO_TYPES = ["FlakeCombo", "FlakeAnimCombo"];
|
||
|
||
// ── Upstream chain ─────────────────────────────────────────────────────────
|
||
// Walk the flake_data / flake_anim_data input upstream from the Flake Generate
|
||
// node and collect, in pipeline order, every node that contributes generation data:
|
||
// - PRESET_TYPES / MODEL_COMBO_TYPES (terminal source)
|
||
// - STACK_TYPES (common, non-combinatorial flakes)
|
||
// - FLAKE_COMBO_TYPES (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 (STACK_TYPES.includes(t) || FLAKE_COMBO_TYPES.includes(t)) {
|
||
chain.push({ type: t, node: current });
|
||
const fdInput = current.inputs?.find(i => i.name === "flake_data" || i.name === "flake_anim_data");
|
||
if (!fdInput?.link) break;
|
||
const link = current.graph?.links?.[fdInput.link];
|
||
if (!link) break;
|
||
current = current.graph?.getNodeById(link.origin_id);
|
||
} else if (PRESET_TYPES.includes(t) || MODEL_COMBO_TYPES.includes(t)) {
|
||
chain.push({ type: t, node: current });
|
||
break;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
return chain.reverse();
|
||
}
|
||
|
||
// Active flakes of a FlakeStack with their index in flakes_json.
|
||
function stackEntries(node) {
|
||
const w = node.widgets?.find(w => w.name === "flakes_json");
|
||
let entries;
|
||
try { entries = JSON.parse(w?.value || "[]"); } catch { entries = []; }
|
||
if (!Array.isArray(entries)) return [];
|
||
return entries.map((e, i) => ({ e, i })).filter(({ e }) => !e.inline && !e.bypassed && e.name);
|
||
}
|
||
|
||
// Variant combinations of an "All Variants" flake, [] when its meta can't be read.
|
||
async function flakeVariantCombos(name) {
|
||
try { return variantCombos(await fetchFlakeMeta(name)); } catch { return []; }
|
||
}
|
||
|
||
// 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 (PRESET_TYPES.includes(type)) {
|
||
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 (MODEL_COMBO_TYPES.includes(type)) {
|
||
const allPresets = node.properties?._combo_presets || [];
|
||
const uids = node.properties._combo_uids;
|
||
const bypassed = new Set(node.properties?._combo_bypassed || []);
|
||
const presets = allPresets
|
||
.map((p, i) => ({ p, uid: uids[i] }))
|
||
.filter(({ p }) => !bypassed.has(p));
|
||
if (presets.length === 0) continue;
|
||
axes.push({
|
||
node, kind: "model",
|
||
label: "Model Preset",
|
||
items: presets.map(({ p, uid }) => ({
|
||
label: (node.properties?._combo_display_names?.[p]) || p.split(/[\/\\]+/).pop() || p,
|
||
presetName: p,
|
||
coverUrl: getPresetCoverUrl(p),
|
||
thumbUrl: getPresetCoverUrl(p, THUMB),
|
||
itemKey: uid,
|
||
})),
|
||
});
|
||
} else if (STACK_TYPES.includes(type)) {
|
||
for (const { e, i } of stackEntries(node)) {
|
||
// An "All Variants" stack flake is an axis of its own, like in
|
||
// queue.js (#343).
|
||
const combos = e._all_variants ? await flakeVariantCombos(e.name) : [];
|
||
if (combos.length > 1) {
|
||
axes.push({
|
||
node, kind: "flake", label: "Flake Stack",
|
||
items: combos.map(variant => makeFlakeCard({ ...e, variant }, `s${i}.${variantKey(variant)}`)),
|
||
});
|
||
} else {
|
||
fixed.stackFlakes.push(e);
|
||
}
|
||
}
|
||
// So are a Flake Animate Stack's input images (#441).
|
||
const imageJobs = stackImageJobs(node);
|
||
if (imageJobs.length) {
|
||
axes.push({
|
||
node, kind: "image", label: "Input Image",
|
||
items: imageJobs.map(({ image, key }) => ({ label: image.split(/[\\/]/).pop(), coverUrl: pickedImageUrl(image), itemKey: key })),
|
||
});
|
||
}
|
||
} else if (FLAKE_COMBO_TYPES.includes(type)) {
|
||
const allFlakes = node.properties?._combo_flakes || [];
|
||
const flakes = Array.isArray(allFlakes) ? allFlakes.filter(e => !e.inline && !e.bypassed && e.name) : [];
|
||
if (flakes.length === 0) continue;
|
||
const items = [];
|
||
for (const e of flakes) {
|
||
// One card per variant combination (#345) or "Multiple" choice
|
||
// (#406), like the queue.js expansion. The meta load also gives
|
||
// the default variant choices the cards show (#388).
|
||
await fetchFlakeMeta(e.name).catch(() => {});
|
||
// Then one per input image (#441).
|
||
const combos = e._all_variants ? await flakeVariantCombos(e.name) : expandEntryVariants(e);
|
||
if (combos.length > (e._all_variants ? 0 : 1)) {
|
||
for (const variant of combos) {
|
||
for (const { image, key } of itemImageJobs(e, `${e._uid}.${variantKey(variant)}`)) items.push(makeFlakeCard({ ...e, variant, image }, key));
|
||
}
|
||
} else {
|
||
for (const { image, key } of itemImageJobs(e, e._uid)) items.push(makeFlakeCard({ ...e, image }, key));
|
||
}
|
||
}
|
||
axes.push({ node, kind: "flake", label: "Flake Combo", items });
|
||
}
|
||
}
|
||
return { axes, fixed };
|
||
}
|
||
|
||
// ── placeholder image ───────────────────────────────────────────────────────
|
||
// Seeded PRNG (FNV-1a hash of the key into mulberry32) so a combination's card
|
||
// layout is the same on every render.
|
||
function seededRandom(key) {
|
||
let h = 2166136261;
|
||
for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 16777619);
|
||
return () => {
|
||
h = (h + 0x6D2B79F5) | 0;
|
||
let t = Math.imul(h ^ (h >>> 15), 1 | h);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
function loadImage(url) {
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.onload = () => resolve(img);
|
||
img.onerror = () => resolve(null);
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
// First image of `urls` that loads, else null (#410).
|
||
async function loadFirstImage(urls) {
|
||
for (const url of urls) {
|
||
const img = await loadImage(url);
|
||
if (img) return img;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 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] : []);
|
||
}
|
||
|
||
// A variant choice's prompts: a `prompts:` list or its flat pair (#468).
|
||
function choicePrompts(v) {
|
||
return Array.isArray(v?.prompts) ? v.prompts : [v || {}];
|
||
}
|
||
|
||
// Placeholder for a combination with no output yet (#375): one card per selected
|
||
// cover, at the cover's own aspect ratio, scattered over a jittered grid that
|
||
// fills both axes of the canvas, with a rotation (±15°) and offset seeded by
|
||
// `seedKey` (#384). A missing cover is drawn as a plain card. The canvas has the
|
||
// `aspect` of the image box it fills (#411); its long side is DPR-aware
|
||
// (~1024 logical px × devicePixelRatio, capped at 2×) so it isn't blurred in
|
||
// the overlay (#354). A Flake Generate Animation input image (`topUrl`) goes on
|
||
// top as a larger, centered, unrotated card (#456).
|
||
async function buildCardStack(urlLists, seedKey, aspect, topUrl) {
|
||
const long = Math.round(1024 * Math.min(window.devicePixelRatio || 1, 2));
|
||
const cw = aspect >= 1 ? long : Math.round(long * aspect);
|
||
const ch = aspect >= 1 ? Math.round(long / aspect) : long;
|
||
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);
|
||
const [images, topImg] = await Promise.all([Promise.all(urlLists.map(loadFirstImage)), topUrl ? loadImage(topUrl) : null]);
|
||
const rand = seededRandom(seedKey);
|
||
const n = images.length;
|
||
const cols = Math.max(1, Math.min(n, Math.ceil(Math.sqrt(n * aspect))));
|
||
const rows = Math.max(1, Math.ceil(n / cols));
|
||
const cellW = cw / cols, cellH = ch / rows;
|
||
const cardEdge = Math.min(cellW, cellH, ch * 0.7) * 0.8;
|
||
const border = Math.max(2, Math.round(cardEdge * 0.035));
|
||
const cardSize = (img, edge) => {
|
||
const ar = img ? img.width / img.height : 3 / 4;
|
||
return [(ar >= 1 ? edge : edge * ar) + 2 * border, (ar >= 1 ? edge / ar : edge) + 2 * border];
|
||
};
|
||
const drawCard = (img, cx, cy, w, h, angle, edge) => {
|
||
ctx.save();
|
||
ctx.translate(cx, cy);
|
||
ctx.rotate(angle);
|
||
ctx.shadowColor = "rgba(0,0,0,0.55)";
|
||
ctx.shadowBlur = edge * 0.08;
|
||
ctx.shadowOffsetY = edge * 0.02;
|
||
ctx.fillStyle = "#fff";
|
||
ctx.fillRect(-w / 2, -h / 2, w, h);
|
||
ctx.shadowColor = "transparent";
|
||
ctx.fillStyle = "#2a2a2a";
|
||
if (img) ctx.drawImage(img, -w / 2 + border, -h / 2 + border, w - 2 * border, h - 2 * border);
|
||
else ctx.fillRect(-w / 2 + border, -h / 2 + border, w - 2 * border, h - 2 * border);
|
||
ctx.restore();
|
||
};
|
||
images.forEach((img, i) => {
|
||
const [w, h] = cardSize(img, cardEdge);
|
||
const angle = (rand() * 2 - 1) * Math.PI / 12;
|
||
const row = Math.floor(i / cols);
|
||
// Center a partial last row.
|
||
const inRow = Math.min(cols, n - row * cols);
|
||
const x = ((cols - inRow) / 2 + (i % cols) + 0.5 + (rand() * 2 - 1) * 0.2) * cellW;
|
||
const y = (row + 0.5 + (rand() * 2 - 1) * 0.2) * cellH;
|
||
// Keep the rotated card inside the canvas when it fits.
|
||
const ex = (w * Math.abs(Math.cos(angle)) + h * Math.abs(Math.sin(angle))) / 2;
|
||
const ey = (w * Math.abs(Math.sin(angle)) + h * Math.abs(Math.cos(angle))) / 2;
|
||
const clamp = (v, e, size) => (2 * e < size ? Math.min(Math.max(v, e), size - e) : size / 2);
|
||
const cx = clamp(x, ex, cw);
|
||
const cy = clamp(y, ey, ch);
|
||
drawCard(img, cx, cy, w, h, angle, cardEdge);
|
||
});
|
||
if (topUrl) {
|
||
const topEdge = Math.min(cw, ch) * 0.75;
|
||
drawCard(topImg, cw / 2, ch / 2, ...cardSize(topImg, topEdge), 0, topEdge);
|
||
}
|
||
return canvas.toDataURL();
|
||
}
|
||
|
||
// 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;
|
||
await fetchFlakeMeta(entry.name);
|
||
const variant = effectiveVariant(entry.variant, cachedVariantDefaults(entry.name));
|
||
const choices = Object.values(variant);
|
||
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(variant)) {
|
||
const v = d.variants?.[g]?.[c];
|
||
for (const p of choicePrompts(v)) {
|
||
if (p.positive) promptRows.push([`${vLabel} · ${g} · Positive`, p.positive]);
|
||
if (p.negative) promptRows.push([`${vLabel} · ${g} · Negative`, p.negative]);
|
||
}
|
||
// Choice LoRAs with the grid item's strength overrides (#413).
|
||
if (Array.isArray(v?.loras)) {
|
||
v.loras.forEach((lr, idx) => {
|
||
const name = lr.name || `LoRA #${idx + 1}`;
|
||
const s = entry.variant_loras?.[g]?.[c]?.[idx] ?? lr.strength ?? 1;
|
||
loraRows.push([`${name} [${Number.isInteger(s) ? s : Number(s).toFixed(2)}]`, lr.path || lr.name || ""]);
|
||
});
|
||
}
|
||
}
|
||
} catch { /* skip */ }
|
||
|
||
// Linked flake surfacing, one block per link (#234); the overrides mirror
|
||
// flake_io._apply_flake_link.
|
||
const links = hostData?.flake_links || (hostData?.flake_link ? [hostData.flake_link] : []);
|
||
const linkOverrides = links.map((link, li) => [link, (Array.isArray(entry.flake_link_overrides) ? entry.flake_link_overrides[li] : li === 0 && entry.flake_link_override) || {}]);
|
||
// Then the selected choices' links with their per-choice overrides (#420).
|
||
for (const [g, c, cl] of choiceFlakeLinks(hostData?.variants, entry.variant)) {
|
||
cl.forEach((link, li) => linkOverrides.push([link, entry.variant_link_overrides?.[g]?.[c]?.[li] || {}]));
|
||
}
|
||
for (const [link, ovr] of linkOverrides) {
|
||
if (!link?.target) continue;
|
||
try {
|
||
const linkTarget = link.target;
|
||
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 link variant > linked flake's default.
|
||
await fetchFlakeMeta(linkTarget);
|
||
const linkVariant = effectiveVariant({ ...(link.variant || {}), ...(ovr.variant || {}) }, cachedVariantDefaults(linkTarget));
|
||
const effChoices = Object.values(linkVariant);
|
||
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(linkVariant)) {
|
||
const v = linked.variants?.[g]?.[c];
|
||
for (const p of choicePrompts(v)) {
|
||
if (p.positive) promptRows.push([`${groupLabel} · ${g} · Positive`, p.positive]);
|
||
if (p.negative) promptRows.push([`${groupLabel} · ${g} · Negative`, p.negative]);
|
||
}
|
||
// Linked choice LoRAs: grid override > link yaml > choice yaml (#414).
|
||
if (Array.isArray(v?.loras)) {
|
||
v.loras.forEach((lr, idx) => {
|
||
const name = lr.name || `LoRA #${idx + 1}`;
|
||
const s = ovr.variant_loras?.[g]?.[c]?.[idx] ?? link.variant_loras?.[g]?.[c]?.[idx] ?? lr.strength ?? 1;
|
||
loraRows.push([`[linked] ${name} [${Number.isInteger(s) ? s : Number(s).toFixed(2)}]`, lr.path || lr.name || ""]);
|
||
});
|
||
}
|
||
}
|
||
} 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: [],
|
||
imageUrl: null,
|
||
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 candidates per selected item, in order: the variant image then the
|
||
// flake cover, since a variant choice may have no image (#410). A Flake
|
||
// Animate job's input image is kept apart for the top card (#456).
|
||
model.axes.forEach((axis, i) => {
|
||
const it = selection[i];
|
||
if (axis.kind === "image") {
|
||
out.imageUrl = it.coverUrl;
|
||
return;
|
||
}
|
||
if (it.imageUrl) out.imageUrl = it.imageUrl;
|
||
out.coverUrls.push((it.stackCoverUrls || (it.baseCoverUrl ? [it.variantCoverUrl, it.baseCoverUrl] : [it.coverUrl])).filter(Boolean));
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// Stable, order-independent identity for a combination: each axis's node id
|
||
// paired with the selected item's key, 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, axis.items[selIdx[i]].itemKey])
|
||
.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,
|
||
video = false,
|
||
} = 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;
|
||
}
|
||
// Fixed-height overlay; each panel is its own (only) scroller (#411).
|
||
const box = content.parentElement;
|
||
css(box, box.style.cssText + "height:85vh;width:min(1200px,85vw);max-width:none;");
|
||
content.style.overflow = "hidden";
|
||
|
||
// Fit Output Image in View (#390): on, the output is contain-fit in its box;
|
||
// off, it shows at natural size and the box scrolls (#412).
|
||
let fitOn = localStorage.getItem("flakes.overlayFitImage") !== "0";
|
||
let onFitChange = () => {};
|
||
const fitToggle = document.createElement("label");
|
||
css(fitToggle, "display:flex;align-items:center;gap:6px;cursor:pointer;margin-left:auto;font-size:12px;color:#ddd;");
|
||
const fitChk = document.createElement("input");
|
||
fitChk.type = "checkbox";
|
||
fitChk.checked = fitOn;
|
||
css(fitChk, "cursor:pointer;margin:0;");
|
||
const fitSpan = document.createElement("span");
|
||
fitSpan.textContent = "Fit Output Image in View";
|
||
fitToggle.appendChild(fitChk);
|
||
fitToggle.appendChild(fitSpan);
|
||
fitChk.addEventListener("change", () => {
|
||
fitOn = fitChk.checked;
|
||
localStorage.setItem("flakes.overlayFitImage", fitOn ? "1" : "0");
|
||
onFitChange();
|
||
});
|
||
header.appendChild(fitToggle);
|
||
// A landscape image widens the overlay to ~the screen width (#412); portrait,
|
||
// square and unknown keep today's width. `wrap` is the single-panel column.
|
||
// Given the fitted image's box, the overlay hugs the contain-fit image
|
||
// instead (#417): the box height is fixed, so one measurement suffices.
|
||
function sizeOverlay(w, h, wrap, fitBox) {
|
||
const landscape = w > h;
|
||
box.style.width = landscape ? "94vw" : "min(1200px, 85vw)";
|
||
if (wrap) wrap.style.maxWidth = landscape || fitBox ? "none" : "520px";
|
||
if (!fitBox || !w || !h) return;
|
||
const extra = box.offsetWidth - fitBox.clientWidth;
|
||
box.style.width = `min(94vw, ${Math.max(520, extra + fitBox.clientHeight * w / h)}px)`;
|
||
}
|
||
// Panel metadata stays collapsed behind a ^ / v toggle at the bottom (#408).
|
||
// onToggle(open) runs before the metadata is shown or hidden (#411).
|
||
function metaToggle(panel, meta, onToggle) {
|
||
const btn = document.createElement("button");
|
||
btn.textContent = "^";
|
||
css(btn, "align-self:center;background:none;border:none;color:#ddd;font-size:28px;line-height:1;padding:2px 12px;cursor:pointer;opacity:0.5;");
|
||
btn.addEventListener("mouseenter", () => { btn.style.opacity = "0.9"; });
|
||
btn.addEventListener("mouseleave", () => { btn.style.opacity = "0.5"; });
|
||
meta.style.display = "none";
|
||
btn.addEventListener("click", () => {
|
||
const open = meta.style.display === "none";
|
||
onToggle?.(open);
|
||
meta.style.display = open ? "flex" : "none";
|
||
btn.textContent = open ? "v" : "^";
|
||
});
|
||
panel.appendChild(btn);
|
||
panel.appendChild(meta);
|
||
return btn;
|
||
}
|
||
// The image fills its box contain-fit; Fit off shows an output at natural
|
||
// size, scrolling on both axes inside the box (#412). The card stack always fills.
|
||
function fitImage(img, imageBox, stacked) {
|
||
const fill = fitOn || stacked;
|
||
img.style.width = img.style.height = fill ? "100%" : "auto";
|
||
imageBox.style.overflow = fill ? "hidden" : "auto";
|
||
}
|
||
// Flake Generate Animation results play in a <video> in place of the
|
||
// composite <img>; previews and covers stay images (#434). No native
|
||
// controls: their double-click goes fullscreen instead of revealing (#439).
|
||
function addCompositeVideo(imageBox, img, onSize) {
|
||
const v = document.createElement("video");
|
||
Object.assign(v, { loop: true, muted: true, autoplay: true, title: "Click to play/pause, double-click to reveal in file explorer" });
|
||
css(v, img.style.cssText + "display:none;cursor:pointer;");
|
||
v.addEventListener("click", () => (v.paused ? v.play() : v.pause()));
|
||
v.addEventListener("loadedmetadata", () => onSize(v.videoWidth, v.videoHeight));
|
||
imageBox.appendChild(v);
|
||
return v;
|
||
}
|
||
// Collapsed metadata: the image fills the panel, no scrollbar. Expanded: the
|
||
// image keeps its height and the panel scrolls (#411).
|
||
function freezeImage(panel, wrap, open) {
|
||
wrap.style.flex = open ? `0 0 ${wrap.offsetHeight}px` : "1";
|
||
panel.style.overflowY = open ? "auto" : "hidden";
|
||
}
|
||
|
||
const hasCombos = model.axes.some(a => a.items.length > 1);
|
||
|
||
if (hasCombos) {
|
||
const split = document.createElement("div");
|
||
css(split, "display:flex;gap:8px;flex:1;min-height:0;");
|
||
content.appendChild(split);
|
||
|
||
// Left: combo axis grids + combo-specific fields below
|
||
const left = document.createElement("div");
|
||
css(left, "flex:0 0 380px;display:flex;flex-direction:column;gap:12px;min-width:0;overflow-x:hidden;overflow-y:auto;");
|
||
// Right: composite image + common data sections
|
||
const right = document.createElement("div");
|
||
css(right, "flex:1;display:flex;flex-direction:column;gap:10px;min-width:0;overflow:hidden;");
|
||
split.appendChild(left);
|
||
split.appendChild(right);
|
||
|
||
function runningComboKey() {
|
||
const batch = getActiveBatch();
|
||
const idx = batch.promptIds.indexOf(batch.runningPromptId);
|
||
return idx < 0 ? null : batch.comboKeys[idx];
|
||
}
|
||
|
||
// selection state: index per axis. Opens on the combination being
|
||
// generated, else the last one that produced an image, else the first (#423).
|
||
const startPairs = parseComboKey(runningComboKey() ?? getImages().lastKey);
|
||
const selIdx = model.axes.map(axis => Math.max(0, axis.items.findIndex(item =>
|
||
startPairs.some(([nodeId, itemKey]) => nodeId === Number(axis.node.id) && itemKey === item.itemKey))));
|
||
|
||
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);
|
||
card.dataset.itemKey = item.itemKey;
|
||
css(card, `position:relative;flex:0 0 auto;width:108px;height:120px;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.variantThumbUrl) {
|
||
const probe = new Image();
|
||
probe.onload = () => { card.style.backgroundImage = `url(${item.variantThumbUrl})`; };
|
||
probe.onerror = () => {
|
||
card.style.backgroundImage = `url(${item.thumbUrl})`;
|
||
};
|
||
probe.src = item.variantThumbUrl;
|
||
// Show base immediately while probe resolves so there's
|
||
// never an empty cell.
|
||
card.style.backgroundImage = `url(${item.thumbUrl})`;
|
||
} else if (item.thumbUrl || item.coverUrl) {
|
||
card.style.backgroundImage = `url(${item.thumbUrl || item.coverUrl})`;
|
||
}
|
||
// Progress bar (above the caption) — shown while the active
|
||
// batch's running prompt includes this (nodeId, itemKey) pair.
|
||
const progressTrack = document.createElement("div");
|
||
css(progressTrack, "position:absolute;left:3px;right:3px;bottom:19px;height:4px;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:11px;color:#fff;background:rgba(0,0,0,0.6);padding:2px 4px;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);
|
||
});
|
||
cards.at(-1)?.[selIdx.at(-1)].scrollIntoView({ block: "nearest" });
|
||
|
||
// Left: combo-specific fields container (below grids)
|
||
const leftDataWrap = document.createElement("div");
|
||
css(leftDataWrap, "display:flex;flex-direction:column;gap:10px;");
|
||
const leftToggle = metaToggle(left, 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 pairs = parseComboKey(runningComboKey());
|
||
const out = [];
|
||
for (const [nodeId, itemKey] of pairs) {
|
||
for (const axisCards of cards) {
|
||
for (const c of axisCards) {
|
||
if (Number(c.dataset.nodeId) === nodeId && c.dataset.itemKey === itemKey) {
|
||
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 preview or result for the selected combination arrives.
|
||
const unsubscribe = subscribeBatch(() => {
|
||
const preview = selectedPreview();
|
||
if (shownData && (preview !== shownPreview || (!preview && 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, "flex:1;min-height:0;display:flex;flex-direction:column;gap:4px;box-sizing:border-box;padding:0 4px;");
|
||
const imageBox = document.createElement("div");
|
||
css(imageBox, "flex:1;min-height:0;display:flex;overflow:hidden;border-radius:6px;border:1px solid #333;background:#1a1a1a;box-sizing:border-box;");
|
||
const compositeImg = document.createElement("img");
|
||
css(compositeImg, "display:block;margin:auto;flex:none;width:100%;height:100%;max-width:none;object-fit:contain;");
|
||
imageBox.appendChild(compositeImg);
|
||
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 captionBox = document.createElement("div");
|
||
css(captionBox, "display:none;max-height:90px;overflow:auto;font-size:12px;color:#ddd;white-space:pre-wrap;word-break:break-word;user-select:text;");
|
||
const adToggleWrap = document.createElement("div");
|
||
css(adToggleWrap, "display:flex;align-items:center;justify-content:center;gap:8px;font-size:12px;color:#ddd;");
|
||
// Name + dimensions above the preview image (#349).
|
||
compositeWrap.appendChild(compositeLabel);
|
||
compositeWrap.appendChild(compositeDimensions);
|
||
compositeWrap.appendChild(imageBox);
|
||
compositeWrap.appendChild(captionBox);
|
||
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}`;
|
||
}
|
||
if (!stacked) {
|
||
shownSize = [compositeImg.naturalWidth, compositeImg.naturalHeight];
|
||
resize();
|
||
}
|
||
});
|
||
|
||
// 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";
|
||
const reveal = () => {
|
||
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));
|
||
};
|
||
compositeImg.addEventListener("dblclick", reveal);
|
||
|
||
let stacked = false;
|
||
const fit = () => { fitImage(compositeImg, imageBox, stacked); fitImage(compositeVideo, imageBox, stacked); };
|
||
let shownSize = null; // natural size of the shown output / preview (#417)
|
||
const resize = () => { if (!stacked && shownSize) sizeOverlay(...shownSize, null, fitOn && imageBox); };
|
||
onFitChange = () => { fit(); resize(); };
|
||
const compositeVideo = addCompositeVideo(imageBox, compositeImg, (w, h) => {
|
||
compositeDimensions.textContent = `${w} × ${h}`;
|
||
shownSize = [w, h];
|
||
resize();
|
||
});
|
||
compositeVideo.addEventListener("dblclick", reveal);
|
||
|
||
const dataWrap = document.createElement("div");
|
||
css(dataWrap, "display:flex;flex-direction:column;gap:10px;");
|
||
metaToggle(right, dataWrap, (open) => freezeImage(right, compositeWrap, open));
|
||
|
||
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;
|
||
}
|
||
function selectedPreview() {
|
||
const preview = getImages().preview;
|
||
return preview && preview.comboKey === combinationKeyFor(model, selIdx) ? preview.url : null;
|
||
}
|
||
|
||
// Live preview while the selected combination samples (#374), else its
|
||
// generated output, else the cover placeholder.
|
||
let imageToken = 0;
|
||
let shownPreview = null;
|
||
async function renderImage(data) {
|
||
const token = ++imageToken;
|
||
const key = combinationKeyFor(model, selIdx);
|
||
shownPreview = selectedPreview();
|
||
const generated = shownPreview ? null : selectedResult();
|
||
currentGenerated = generated;
|
||
captionBox.textContent = generated?.caption || "";
|
||
captionBox.style.display = generated?.caption ? "block" : "none";
|
||
const playing = video && !!generated;
|
||
compositeImg.style.display = playing ? "none" : "block";
|
||
compositeVideo.style.display = playing ? "block" : "none";
|
||
if (!playing) compositeVideo.pause();
|
||
stacked = false;
|
||
fit();
|
||
if (shownPreview) {
|
||
dimsFromImage = false;
|
||
compositeImg.src = shownPreview;
|
||
compositeLabel.textContent = "Generating…";
|
||
compositeDimensions.textContent = data.dimensions || "";
|
||
} else if (generated) {
|
||
dimsFromImage = true;
|
||
const url = `/view?filename=${encodeURIComponent(generated.filename)}&type=${generated.type || "output"}&subfolder=${encodeURIComponent(generated.subfolder || "")}`;
|
||
if (playing) compositeVideo.src = url;
|
||
else compositeImg.src = url;
|
||
const sub = (generated.subfolder || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||
compositeLabel.textContent = sub ? `${sub}/${generated.filename}` : generated.filename;
|
||
compositeDimensions.textContent = (!playing && compositeImg.complete && compositeImg.naturalWidth)
|
||
? `${compositeImg.naturalWidth} × ${compositeImg.naturalHeight}`
|
||
: (data.dimensions || "");
|
||
} else {
|
||
dimsFromImage = false;
|
||
compositeLabel.textContent = "No image generated yet";
|
||
const urlLists = data.coverUrls.length ? data.coverUrls : (model.fixed.presetName ? [[`/flakes/preset_cover?name=${encodeURIComponent(model.fixed.presetName)}`]] : []);
|
||
stacked = true;
|
||
fit();
|
||
sizeOverlay(data.width, data.height);
|
||
const src = await buildCardStack(urlLists, key, (imageBox.clientWidth || 1) / (imageBox.clientHeight || 1), video ? data.imageUrl : null);
|
||
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);
|
||
leftToggle.style.display = cs1 || cs2 ? "" : "none";
|
||
}
|
||
|
||
// 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, "flex:1;min-height:0;display:flex;flex-direction:column;gap:10px;width:100%;max-width:520px;margin:0 auto;overflow:hidden;");
|
||
content.appendChild(singleWrap);
|
||
|
||
const compositeWrap = document.createElement("div");
|
||
css(compositeWrap, "flex:1;min-height:0;display:flex;flex-direction:column;gap:4px;box-sizing:border-box;padding:0 4px;");
|
||
const imageBox = document.createElement("div");
|
||
css(imageBox, "flex:1;min-height:0;display:flex;overflow:hidden;border-radius:6px;border:1px solid #333;background:#1a1a1a;box-sizing:border-box;");
|
||
const compositeImg = document.createElement("img");
|
||
css(compositeImg, "display:block;margin:auto;flex:none;width:100%;height:100%;max-width:none;object-fit:contain;");
|
||
imageBox.appendChild(compositeImg);
|
||
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 captionBox = document.createElement("div");
|
||
css(captionBox, "display:none;max-height:90px;overflow:auto;font-size:12px;color:#ddd;white-space:pre-wrap;word-break:break-word;user-select:text;");
|
||
const adToggleWrap = document.createElement("div");
|
||
css(adToggleWrap, "display:flex;align-items:center;justify-content:center;gap:8px;font-size:12px;color:#ddd;");
|
||
// Name + dimensions above the preview image (#349).
|
||
compositeWrap.appendChild(compositeLabel);
|
||
compositeWrap.appendChild(compositeDimensions);
|
||
compositeWrap.appendChild(imageBox);
|
||
compositeWrap.appendChild(captionBox);
|
||
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}`;
|
||
}
|
||
if (!stacked) {
|
||
shownSize = [compositeImg.naturalWidth, compositeImg.naturalHeight];
|
||
resize();
|
||
}
|
||
});
|
||
|
||
// 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";
|
||
const reveal = () => {
|
||
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));
|
||
};
|
||
compositeImg.addEventListener("dblclick", reveal);
|
||
|
||
let stacked = false;
|
||
const fit = () => { fitImage(compositeImg, imageBox, stacked); fitImage(compositeVideo, imageBox, stacked); };
|
||
let shownSize = null; // natural size of the shown output / preview (#417)
|
||
const resize = () => { if (!stacked && shownSize) sizeOverlay(...shownSize, singleWrap, fitOn && imageBox); };
|
||
onFitChange = () => { fit(); resize(); };
|
||
const compositeVideo = addCompositeVideo(imageBox, compositeImg, (w, h) => {
|
||
compositeDimensions.textContent = `${w} × ${h}`;
|
||
shownSize = [w, h];
|
||
resize();
|
||
});
|
||
compositeVideo.addEventListener("dblclick", reveal);
|
||
|
||
const dataWrap = document.createElement("div");
|
||
css(dataWrap, "display:flex;flex-direction:column;gap:10px;width:100%;");
|
||
metaToggle(singleWrap, dataWrap, (open) => freezeImage(singleWrap, compositeWrap, open));
|
||
|
||
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;
|
||
}
|
||
function selectedPreview() {
|
||
return getImages().preview?.url || null;
|
||
}
|
||
|
||
let imageToken = 0;
|
||
let shownPreview = null;
|
||
async function renderImage(data) {
|
||
const token = ++imageToken;
|
||
shownPreview = selectedPreview();
|
||
const generated = shownPreview ? null : selectedResult();
|
||
currentGenerated = generated;
|
||
captionBox.textContent = generated?.caption || "";
|
||
captionBox.style.display = generated?.caption ? "block" : "none";
|
||
const playing = video && !!generated;
|
||
compositeImg.style.display = playing ? "none" : "block";
|
||
compositeVideo.style.display = playing ? "block" : "none";
|
||
if (!playing) compositeVideo.pause();
|
||
stacked = false;
|
||
fit();
|
||
if (shownPreview) {
|
||
dimsFromImage = false;
|
||
compositeImg.src = shownPreview;
|
||
compositeLabel.textContent = "Generating…";
|
||
compositeDimensions.textContent = data.dimensions || "";
|
||
} else if (generated) {
|
||
dimsFromImage = true;
|
||
const url = `/view?filename=${encodeURIComponent(generated.filename)}&type=${generated.type || "output"}&subfolder=${encodeURIComponent(generated.subfolder || "")}`;
|
||
if (playing) compositeVideo.src = url;
|
||
else compositeImg.src = url;
|
||
const sub = (generated.subfolder || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
||
compositeLabel.textContent = sub ? `${sub}/${generated.filename}` : generated.filename;
|
||
compositeDimensions.textContent = (!playing && compositeImg.complete && compositeImg.naturalWidth)
|
||
? `${compositeImg.naturalWidth} × ${compositeImg.naturalHeight}`
|
||
: (data.dimensions || "");
|
||
} else {
|
||
dimsFromImage = false;
|
||
compositeLabel.textContent = "No image generated yet";
|
||
const urlLists = data.coverUrls.length ? data.coverUrls : (model.fixed.presetName ? [[`/flakes/preset_cover?name=${encodeURIComponent(model.fixed.presetName)}`]] : []);
|
||
stacked = true;
|
||
fit();
|
||
sizeOverlay(data.width, data.height, singleWrap);
|
||
const src = await buildCardStack(urlLists, key, (imageBox.clientWidth || 1) / (imageBox.clientHeight || 1), video ? data.imageUrl : null);
|
||
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(() => {
|
||
const preview = selectedPreview();
|
||
if (shownData && (preview !== shownPreview || (!preview && 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);
|
||
}
|