fix: overlay previews break when Flake Combo grid items are reordered (#383)

Combo keys use a persistent per-item uid (Flake Combo entry _uid, Flake Model Combo _combo_uids) instead of grid positions, and a sorted group=choice sub-key for All Variants items. Results, live previews, progress bars and generating highlights follow the item when it moves. The overlay now also has an axis for FlakeStack All Variants flakes.
This commit is contained in:
Mehdi Chaid
2026-10-16 19:37:33 -04:00
parent b4ce9ca0f7
commit 4ea6165025
4 changed files with 104 additions and 82 deletions
+29 -16
View File
@@ -33,6 +33,18 @@ export function getComboPresets(node) {
return node.properties?._combo_presets || [];
}
// Persistent id of a combo grid item (#383): Flake Combo entries carry it as
// `_uid`, Flake Model Combo keeps `_combo_uids` parallel to `_combo_presets`.
// Combo keys use it, so results, previews and highlights survive reordering.
export function newItemUid() {
return Math.random().toString(36).slice(2, 10);
}
// Sub-key of an "All Variants" item: its selection as sorted group=choice pairs.
export function variantKey(variant) {
return Object.keys(variant).sort().map(g => `${g}=${variant[g]}`).join(",");
}
// Active (non-bypassed) flakes for a FlakeCombo node.
function activeComboFlakes(node) {
return getComboFlakes(node).filter(f => !f.bypassed);
@@ -113,8 +125,8 @@ function activeState() {
return _batchStates.get(activeWorkflow());
}
// Running item index of a FlakeCombo / FlakeModelCombo node, if any.
export function getGeneratingIndex(node) {
// Uid of the running item of a FlakeCombo / FlakeModelCombo node, if any.
export function getGeneratingUid(node) {
return activeState()?.generating[node.id];
}
@@ -340,7 +352,7 @@ function setupBatchTracking(state, combinations, comboKeys) {
state.generating = {};
for (const item of combinations[comboIndexByPrompt[idx]]) {
// stack_variant items have no node-level highlight (#343).
if (item.type === "combo" || item.type === "model_combo") state.generating[item.node.id] = item.index;
if (item.type === "combo" || item.type === "model_combo") state.generating[item.node.id] = item.uid;
}
renderHighlights(state);
}
@@ -478,8 +490,8 @@ app.queuePrompt = async function(number, batchCount = 1) {
const meta = await fetchFlakeMeta(flake.name);
const combos = enumerateVariantCombos(meta);
if (combos.length <= 1) continue;
stackAxes.push(combos.map((variant, vi) => ({
node, type: "stack_variant", flakeIndex: index, value: variant, index, vkey: `s${index}.${vi}`,
stackAxes.push(combos.map((variant) => ({
node, type: "stack_variant", flakeIndex: index, value: variant, vkey: `s${index}.${variantKey(variant)}`,
})));
}
}
@@ -500,9 +512,7 @@ app.queuePrompt = async function(number, batchCount = 1) {
window.alert("FlakeCombo node has no flakes selected.");
return;
}
const activeFlakes = flakes
.map((flake, i) => ({ flake, i }))
.filter(({ flake }) => !flake.bypassed);
const activeFlakes = flakes.filter(flake => !flake.bypassed);
if (activeFlakes.length === 0) {
window.alert("FlakeCombo node has no active (non-bypassed) flakes.");
return;
@@ -511,14 +521,15 @@ app.queuePrompt = async function(number, batchCount = 1) {
// ordinary flakes stay a single item. vkey keeps each combo's
// comboKey unique (#343).
const comboItems = [];
for (const { flake, i } of activeFlakes) {
for (const flake of activeFlakes) {
const uid = flake._uid;
if (flake._all_variants) {
const meta = await fetchFlakeMeta(flake.name);
enumerateVariantCombos(meta).forEach((variant, vi) => {
comboItems.push({ node, type: "combo", value: { ...flake, variant }, index: i, vkey: `${i}.${vi}` });
});
for (const variant of enumerateVariantCombos(meta)) {
comboItems.push({ node, type: "combo", value: { ...flake, variant }, uid, vkey: `${uid}.${variantKey(variant)}` });
}
} else {
comboItems.push({ node, type: "combo", value: flake, index: i, vkey: `${i}` });
comboItems.push({ node, type: "combo", value: flake, uid, vkey: uid });
}
}
optionsArrays.push(comboItems);
@@ -536,13 +547,15 @@ app.queuePrompt = async function(number, batchCount = 1) {
window.alert("FlakeModelCombo node has no active (non-bypassed) presets.");
return;
}
// Keep the original preset index so combo highlighting maps correctly.
// index picks the preset's overrides when its combination is queued.
const uids = node.properties._combo_uids;
optionsArrays.push(activePresets.map(({ preset, i }) => ({
node,
type: "model_combo",
value: preset,
index: i,
vkey: String(i),
uid: uids[i],
vkey: uids[i],
})));
}
}
@@ -636,7 +649,7 @@ app.queuePrompt = async function(number, batchCount = 1) {
}
}
const comboKey = combination
.map(it => [it.node.id, it.vkey ?? String(it.index)])
.map(it => [it.node.id, it.vkey])
.sort((a, b) => a[0] - b[0])
.map(([id, vk]) => `${id}:${vk}`)
.join("|");
+5 -3
View File
@@ -7,7 +7,7 @@ import {
import { fetchList, fetchFlake, getCoverUrl, getVariantImageUrl, fetchFlakeMeta, cachedVariantDefaults } from "../api.js";
import { openEditModal } from "../flake-modal.js";
import { openFileLoadPicker } from "../pickers.js";
import { getGeneratingIndex } from "../queue.js";
import { getGeneratingUid, newItemUid } from "../queue.js";
function makeComboBlock({ entry, idx, isActive, isGenerating, onEdit, onRemove, onReplace, onToggleBypass, onSingleOut, onDuplicate, onDragStart, onDragOver, onDrop, onDragEnd }) {
const hasCover = !!entry.name;
@@ -512,6 +512,7 @@ export function setupFlakeComboWidget(node) {
delete entry.option;
}
if (!entry.variant) entry.variant = {};
if (!entry._uid) entry._uid = newItemUid();
}
return flakes;
}
@@ -557,7 +558,7 @@ export function setupFlakeComboWidget(node) {
function render() {
const flakes = readAllFlakes();
const activeIdx = node.properties._combo_active_index || 0;
const generatingIdx = getGeneratingIndex(node);
const generatingUid = getGeneratingUid(node);
grid.replaceChildren();
for (let i = 0; i < flakes.length; i++) {
@@ -565,7 +566,7 @@ export function setupFlakeComboWidget(node) {
entry: flakes[i],
idx: i,
isActive: i === activeIdx,
isGenerating: generatingIdx != null && i === generatingIdx,
isGenerating: generatingUid != null && flakes[i]._uid === generatingUid,
onEdit: handleEdit,
onRemove: handleRemove,
onReplace: handleReplace,
@@ -689,6 +690,7 @@ export function setupFlakeComboWidget(node) {
function handleDuplicate(idx) {
const arr = readAllFlakes();
const copy = JSON.parse(JSON.stringify(arr[idx]));
copy._uid = newItemUid();
arr.splice(idx + 1, 0, copy);
writeAllFlakes(arr);
render();
+19 -3
View File
@@ -6,7 +6,7 @@ import {
import { openPresetPicker } from "../pickers.js";
import { openPresetEditModal, refreshPresetOptions } from "../preset-modal.js";
import { fetchPreset } from "../api.js";
import { getGeneratingIndex } from "../queue.js";
import { getGeneratingUid, newItemUid } from "../queue.js";
export function makeModelComboBlock({ preset, display_name, idx, isActive, isBypassed, isGenerating, overrides, onOverrideChange, onActivate, onToggleBypass, onSingleOut, onDuplicate, onRemove, onReplace, onEdit, onDragStart, onDragOver, onDrop, onDragEnd }) {
const block = document.createElement("div");
@@ -175,6 +175,14 @@ export function setupFlakeModelComboWidget(node) {
if (!Array.isArray(node.properties._combo_overrides)) node.properties._combo_overrides = [];
return node.properties._combo_overrides;
}
// Per-item uids, aligned by index with _combo_presets (#383). Presets
// saved without one get one here.
function uidsArr() {
if (!Array.isArray(node.properties._combo_uids)) node.properties._combo_uids = [];
const uids = node.properties._combo_uids;
while (uids.length < readPresets().length) uids.push(newItemUid());
return uids;
}
function getOverridesAt(i) {
const arr = overridesArr();
while (arr.length <= i) arr.push({});
@@ -245,7 +253,8 @@ export function setupFlakeModelComboWidget(node) {
function render() {
const presets = readPresets();
const activeIdx = node.properties._combo_active_index || 0;
const generatingIdx = getGeneratingIndex(node);
const uids = uidsArr();
const generatingUid = getGeneratingUid(node);
grid.replaceChildren();
for (let i = 0; i < presets.length; i++) {
@@ -256,7 +265,7 @@ export function setupFlakeModelComboWidget(node) {
idx: i,
isActive: i === activeIdx,
isBypassed: isPresetBypassed(presets[i]),
isGenerating: generatingIdx != null && i === generatingIdx,
isGenerating: generatingUid != null && uids[i] === generatingUid,
overrides: getOverridesAt(i),
onOverrideChange: () => updateActivePreset(),
onActivate: (idx) => {
@@ -284,6 +293,7 @@ export function setupFlakeModelComboWidget(node) {
onDuplicate: (idx) => {
const arr = readPresets();
if (arr[idx] == null) return;
uidsArr().splice(idx + 1, 0, newItemUid());
arr.splice(idx + 1, 0, arr[idx]);
overridesArr().splice(idx + 1, 0, JSON.parse(JSON.stringify(getOverridesAt(idx))));
writePresets(arr);
@@ -291,6 +301,7 @@ export function setupFlakeModelComboWidget(node) {
},
onRemove: (idx) => {
const arr = readPresets();
uidsArr().splice(idx, 1);
arr.splice(idx, 1);
overridesArr().splice(idx, 1);
if (node.properties._combo_active_index >= arr.length) {
@@ -340,10 +351,12 @@ export function setupFlakeModelComboWidget(node) {
_hideDropIndicator(el);
if (dragSrcIdx === null || idx === dragSrcIdx) return;
const arr = readPresets();
const uids = uidsArr();
const [moved] = arr.splice(dragSrcIdx, 1);
let insertIdx = idx;
if (dragSrcIdx < idx) insertIdx--;
arr.splice(insertIdx, 0, moved);
uids.splice(insertIdx, 0, uids.splice(dragSrcIdx, 1)[0]);
// Keep per-instance overrides aligned with the reordered presets.
const ov = overridesArr();
while (ov.length < arr.length) ov.push({});
@@ -383,6 +396,7 @@ export function setupFlakeModelComboWidget(node) {
// block): create a brand-new model preset, or add an existing one (#278).
async function pushPreset(name) {
const arr = readPresets();
uidsArr().push(newItemUid());
arr.push(name);
overridesArr().push({});
node.properties._combo_display_names[name] = await fetchDisplayName(name);
@@ -427,8 +441,10 @@ export function setupFlakeModelComboWidget(node) {
_hideDropIndicator(addBtn);
if (dragSrcIdx === null) return;
const arr = readPresets();
const uids = uidsArr();
const [moved] = arr.splice(dragSrcIdx, 1);
arr.push(moved);
uids.push(uids.splice(dragSrcIdx, 1)[0]);
const ov = overridesArr();
while (ov.length < arr.length) ov.push({});
const [movedOv] = ov.splice(dragSrcIdx, 1);
+51 -60
View File
@@ -1,10 +1,10 @@
import { css, effectiveVariant } from "../utils.js";
import { openOverlay } from "../modal.js";
import { fetchFlake, fetchPreset, getCoverUrl, getVariantImageUrl, fetchFlakeMeta, cachedVariantDefaults } from "../api.js";
import { getActiveBatch, subscribeBatch } from "../queue.js";
import { getActiveBatch, subscribeBatch, variantKey } from "../queue.js";
// Cartesian product of a flake's variant groups ({group:[choices]}) -> one
// {group: choice} per combination (#345). Order matches queue.js so keys align.
// {group: choice} per combination (#345).
function variantCombos(meta) {
const groups = Object.keys(meta || {}).filter(g => Array.isArray(meta[g]) && meta[g].length);
let acc = [{}];
@@ -16,8 +16,9 @@ function variantCombos(meta) {
return acc;
}
// Build one combo-axis card descriptor for a flake entry (#345).
function makeFlakeCard(e, i, variantSub) {
// 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));
@@ -32,13 +33,11 @@ function makeFlakeCard(e, i, variantSub) {
coverUrl: variantCover || baseCover,
baseCoverUrl: baseCover,
variantCoverUrl: variantCover,
originalIndex: i,
variantSub: variantSub,
itemKey,
};
}
// Parse a comboKey like "12:0|7:2.1" into [nodeId, itemIdxStr] pairs. The item
// part is kept as a string so all-variants sub-keys ("0.1") compare exactly (#345).
// 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 => {
@@ -89,16 +88,18 @@ export function collectChain(startNode) {
return chain.reverse();
}
function activeEntries(node, type) {
// 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;
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 = []; }
}
try { entries = JSON.parse(w?.value || "[]"); } catch { entries = []; }
if (!Array.isArray(entries)) return [];
return entries.filter(e => !e.inline && !e.bypassed && e.name);
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
@@ -117,49 +118,52 @@ export async function buildModel(startNode) {
}
} else if (type === "FlakeModelCombo") {
const allPresets = node.properties?._combo_presets || [];
const uids = node.properties._combo_uids;
const bypassed = new Set(node.properties?._combo_bypassed || []);
// Keep each kept preset's ORIGINAL index within _combo_presets so the
// overlay's combo key matches the key queue.js stamps (which uses the
// original index). Filtering shifts positions otherwise (#268).
const presets = allPresets
.map((p, i) => ({ p, i }))
.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, i }) => ({
items: presets.map(({ p, uid }) => ({
label: (node.properties?._combo_display_names?.[p]) || p.split(/[\/\\]+/).pop() || p,
presetName: p,
coverUrl: `/flakes/preset_cover?name=${encodeURIComponent(p)}`,
originalIndex: i,
itemKey: uid,
})),
});
} else if (type === "FlakeStack") {
for (const e of activeEntries(node, "FlakeStack")) fixed.stackFlakes.push(e);
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);
}
}
} else if (type === "FlakeCombo") {
// Preserve the ORIGINAL index within _combo_flakes (the array queue.js
// iterates) so the overlay combo key aligns with the queued key (#268).
const allFlakes = node.properties?._combo_flakes || [];
const flakes = Array.isArray(allFlakes)
? allFlakes.map((e, i) => ({ e, i })).filter(({ e }) => !e.inline && !e.bypassed && e.name)
: [];
const flakes = Array.isArray(allFlakes) ? allFlakes.filter(e => !e.inline && !e.bypassed && e.name) : [];
if (flakes.length === 0) continue;
const items = [];
for (const { e, i } of flakes) {
if (e._all_variants) {
// One card per variant combination (#345), mirroring the
// queue.js expansion so keys (id:idx.variantSub) align.
let combos = [];
try { combos = variantCombos(await fetchFlakeMeta(e.name)); } catch { combos = []; }
if (combos.length > 1) {
combos.forEach((variant, vi) => items.push(makeFlakeCard({ ...e, variant }, i, vi)));
continue;
}
for (const e of flakes) {
// One card per variant combination (#345), like the queue.js
// expansion.
const combos = e._all_variants ? await flakeVariantCombos(e.name) : [];
if (combos.length) {
for (const variant of combos) items.push(makeFlakeCard({ ...e, variant }, `${e._uid}.${variantKey(variant)}`));
} else {
// Loads the default variant choices the card shows (#388).
await fetchFlakeMeta(e.name).catch(() => {});
items.push(makeFlakeCard(e, e._uid));
}
// Loads the default variant choices the card shows (#388).
await fetchFlakeMeta(e.name).catch(() => {});
items.push(makeFlakeCard(e, i, null));
}
axes.push({ node, kind: "flake", label: "Flake Combo", items });
}
@@ -403,18 +407,11 @@ async function combinationData(model, selection) {
}
// 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
// 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) => {
const item = axis.items[selIdx[i]];
const idx = item?.originalIndex ?? selIdx[i];
// All-variants cards carry a sub-index so the key matches the queued
// comboKey "<id>:<idx>.<variantSub>" (#345).
const vk = (item && item.variantSub != null) ? `${idx}.${item.variantSub}` : `${idx}`;
return [axis.node.id, vk];
})
.map((axis, i) => [axis.node.id, axis.items[selIdx[i]].itemKey])
.sort((a, b) => a[0] - b[0])
.map(([id, vk]) => `${id}:${vk}`)
.join("|");
@@ -502,12 +499,7 @@ export function openGenerationDataOverlay(model, getImages, opts = {}) {
axis.items.forEach((item, ii) => {
const card = document.createElement("div");
card.dataset.nodeId = String(axis.node.id);
// Use the original index (plus the all-variants sub-index, #345)
// so progress-bar matching against the queued comboKey aligns (#268).
{
const baseIdx = item.originalIndex ?? ii;
card.dataset.itemIdx = item.variantSub != null ? `${baseIdx}.${item.variantSub}` : String(baseIdx);
}
card.dataset.itemKey = item.itemKey;
css(card, `position:relative;flex:0 0 auto;width:72px;height:80px;border-radius:4px;cursor:pointer;background:#2a2a2a;background-size:cover;background-position:center;border:2px solid ${ii === selIdx[ai] ? ACCENT : "transparent"};box-sizing:border-box;`);
// #237: probe-load variant image and fall back to base cover if 404,
// matching the flake-combo node grid behavior. Without this the
@@ -527,7 +519,7 @@ export function openGenerationDataOverlay(model, getImages, opts = {}) {
card.style.backgroundImage = `url(${item.coverUrl})`;
}
// Progress bar (above the caption) — shown while the active
// batch's running prompt includes this (nodeId, itemIdx) pair.
// batch's running prompt includes this (nodeId, itemKey) pair.
const progressTrack = document.createElement("div");
css(progressTrack, "position:absolute;left:2px;right:2px;bottom:14px;height:3px;background:rgba(0,0,0,0.55);border-radius:2px;overflow:hidden;display:none;");
const progressFill = document.createElement("div");
@@ -572,11 +564,10 @@ export function openGenerationDataOverlay(model, getImages, opts = {}) {
const key = batch.comboKeys[idx];
const pairs = parseComboKey(key);
const out = [];
for (const [nodeId, itemIdx] of pairs) {
for (const [nodeId, itemKey] of pairs) {
for (const axisCards of cards) {
for (const c of axisCards) {
// itemIdx is a string ("0" or "0.1") to support variant sub-keys (#345).
if (Number(c.dataset.nodeId) === nodeId && String(c.dataset.itemIdx) === String(itemIdx)) {
if (Number(c.dataset.nodeId) === nodeId && c.dataset.itemKey === itemKey) {
out.push(c);
}
}