diff --git a/web/queue.js b/web/queue.js index c8f2189..fe61459 100644 --- a/web/queue.js +++ b/web/queue.js @@ -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("|"); diff --git a/web/widgets/flake-combo.js b/web/widgets/flake-combo.js index 7987213..ebdd801 100644 --- a/web/widgets/flake-combo.js +++ b/web/widgets/flake-combo.js @@ -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(); diff --git a/web/widgets/flake-model-combo.js b/web/widgets/flake-model-combo.js index 7bd0ba4..91bc192 100644 --- a/web/widgets/flake-model-combo.js +++ b/web/widgets/flake-model-combo.js @@ -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); diff --git a/web/widgets/generation-data.js b/web/widgets/generation-data.js index 815b7e1..1c01e3e 100644 --- a/web/widgets/generation-data.js +++ b/web/widgets/generation-data.js @@ -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 ":." (#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); } }