diff --git a/web/queue.js b/web/queue.js index eeee067..72168c4 100644 --- a/web/queue.js +++ b/web/queue.js @@ -97,19 +97,71 @@ function getPromptIdFromEvent(e) { return typeof detail === "string" ? detail : detail?.prompt_id; } -function clearAllHighlights() { +// Live batch state per workflow (#373): running combo item per combo node id, +// results per FlakeGenerate node id, progress counters. Switching workflow tabs +// rebuilds every node and the batch's workflow may not be the active one when +// its events arrive, so nodes read this when they render instead of owning it. +const _batchStates = new WeakMap(); // workflow -> { generating, images, completed, total } +const _batchListeners = new Set(); + +function activeWorkflow() { + // Frontends without a workflow store have a single implicit workflow. + return app.extensionManager?.workflow?.activeWorkflow ?? app; +} + +function activeState() { + return _batchStates.get(activeWorkflow()); +} + +// Running item index of a FlakeCombo / FlakeModelCombo node, if any. +export function getGeneratingIndex(node) { + return activeState()?.generating[node.id]; +} + +// { combo: {comboKey: img}, ad: {comboKey: img}, ab, last } for a FlakeGenerate +// node, or null when no batch of this workflow produced results for it. +export function getBatchImages(node) { + return activeState()?.images[node.id] || null; +} + +export function getBatchProgress() { + return activeState() || null; +} + +// fn() runs after any batch state change; returns an unsubscribe function. +export function subscribeBatch(fn) { + _batchListeners.add(fn); + return () => _batchListeners.delete(fn); +} + +function renderHighlights(state) { + if (activeState() !== state) return; for (const n of app.graph.nodes) { - if (n.type === "FlakeCombo") { - delete n._combo_generating_index; - n._combo_render?.(); - } - if (n.type === "FlakeModelCombo") { - delete n._model_combo_generating_index; - n._model_combo_render?.(); - } + if (n.type === "FlakeCombo") n._combo_render?.(); + else if (n.type === "FlakeModelCombo") n._model_combo_render?.(); } } +function renderResults(state) { + if (activeState() === state) { + for (const n of app.graph.nodes) { + if (n.type === "FlakeGenerate") n._flake_generate_render?.(); + } + } + for (const fn of _batchListeners) fn(); +} + +// The workflow store switches the active workflow only after the new graph's +// nodes are configured, so re-read the store once loading is complete. +app.registerExtension({ + name: "comfyui-flakes.BatchState", + afterLoadGraph() { + const state = activeState(); + renderHighlights(state); + renderResults(state); + }, +}); + // ComfyUI exposes the websocket-event-emitting `api` singleton in slightly // different places depending on frontend version. Try several spots so we // don't silently lose batch tracking on a newer/older frontend. @@ -140,7 +192,7 @@ export function getActiveBatch() { return _activeBatch; } -function setupBatchTracking(combinations, comboKeys, totalJobs) { +function setupBatchTracking(state, combinations, comboKeys) { const comfyApi = _resolveComfyApi(); if (!comfyApi || typeof comfyApi.addEventListener !== "function" || combinations.length === 0) { if (!comfyApi) { @@ -149,11 +201,12 @@ function setupBatchTracking(combinations, comboKeys, totalJobs) { } return null; } - const jobs = totalJobs || combinations.length; + const jobs = state.total; const promptIds = []; // queue order const comboIndexByPrompt = []; // parallel to promptIds: the combination index (#348) - let completedCount = 0; + const done = new Set(); // finished prompt ids (events and /history backfill may both report one) + let closed = false; // Pending event buffer: websocket events for a prompt_id can arrive BEFORE // the REST response that gives us that prompt_id (different transport @@ -164,25 +217,11 @@ function setupBatchTracking(combinations, comboKeys, totalJobs) { const pendingByPid = new Map(); // promptId -> [{type, e}] function applyEventForKnownPid(type, e, pid) { if (type === "execution_start") { - const idx = promptIds.indexOf(pid); - if (idx >= 0) { setComboHighlight(comboIndexByPrompt[idx]); _activeBatch.runningPromptId = pid; } + setRunning(pid); } else if (type === "execution_success" || type === "execution_error" || type === "execution_interrupted") { - const idx = promptIds.indexOf(pid); - if (idx < 0) return; - completedCount++; - for (const n of app.graph.nodes) { - if (n.type === "FlakeGenerate") { - n._batch_completed_count = completedCount; - n._batch_total_count = jobs; - n._batch_progress_render?.(); - } - } - maybeFinish(); + markDone(pid); } else if (type === "executed") { - handleExecuted(e); - } else if (type === "progress") { - // Progress events are handled by the overlay subscription; nothing - // to replay batch-side. We only buffer the lifecycle events. + handleOutput(pid, e.detail.display_node || e.detail.node, e.detail.output); } } function bufferOrApply(type, e) { @@ -228,58 +267,63 @@ function setupBatchTracking(combinations, comboKeys, totalJobs) { }; } - function setComboHighlight(comboIndex) { - const combination = combinations[comboIndex]; - if (!combination) return; - for (const item of combination) { - if (item.type === "combo") { - item.node._combo_generating_index = item.index; - item.node._combo_render?.(); - } else if (item.type === "model_combo") { - item.node._model_combo_generating_index = item.index; - item.node._model_combo_render?.(); - } - // stack_variant items have no node-level highlight (#343). - } - } - - function maybeFinish() { - if (completedCount >= jobs) { - if (_batchCleanup) { - _batchCleanup(); - _batchCleanup = null; - } - } - } - - // The `executed` event carries the per-node output payload. Used to stamp - // generated images onto FlakeGenerate nodes keyed by their comboKey. - function handleExecuted(e) { - const detail = e?.detail || {}; - const pid = typeof detail === "string" ? null : detail.prompt_id; - const output = detail?.output; - const nodeId = detail?.node; - if (!pid || !output) return; + function setRunning(pid) { const idx = promptIds.indexOf(pid); if (idx < 0) return; - const comboKey = (comboKeys && comboKeys[idx]) ?? ""; - const images = output.flake_images; - const adImages = output.flake_images_ad; - const isAB = Array.isArray(output.adetailer_ab) && output.adetailer_ab.length > 0; - if (!Array.isArray(images) || images.length === 0) return; - const candidates = nodeId - ? [app.graph.getNodeById(nodeId)].filter(n => n && n.type === "FlakeGenerate") - : app.graph.nodes.filter(n => n.type === "FlakeGenerate"); - for (const fg of candidates) { - fg.properties = fg.properties || {}; - fg.properties._images_by_combo = fg.properties._images_by_combo || {}; - fg.properties._images_by_combo[comboKey] = images[0]; - if (isAB && Array.isArray(adImages) && adImages.length > 0) { - fg.properties._images_by_combo_ad = fg.properties._images_by_combo_ad || {}; - fg.properties._images_by_combo_ad[comboKey] = adImages[0]; - fg.properties._adetailer_ab = true; - } + _activeBatch.runningPromptId = pid; + 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; } + renderHighlights(state); + } + + function markDone(pid) { + if (!promptIds.includes(pid) || done.has(pid)) return; + done.add(pid); + state.completed = done.size; + if (done.size >= jobs && _batchCleanup === cleanup) { + _batchCleanup(); + _batchCleanup = null; + } + } + + // A FlakeGenerate output (from the `executed` event or /history): store + // its images by the prompt's comboKey under the producing node's id. + function handleOutput(pid, nodeId, output) { + const idx = promptIds.indexOf(pid); + const images = output?.flake_images; + if (idx < 0 || !Array.isArray(images) || images.length === 0) return; + const comboKey = comboKeys[idx] ?? ""; + const entry = state.images[nodeId] || (state.images[nodeId] = { combo: {}, ad: {}, ab: false, last: null }); + entry.combo[comboKey] = images[0]; + entry.last = images[0]; + const adImages = output.flake_images_ad; + if (Array.isArray(output.adetailer_ab) && output.adetailer_ab.length > 0 && Array.isArray(adImages) && adImages.length > 0) { + entry.ad[comboKey] = adImages[0]; + entry.ab = true; + } + renderResults(state); + } + + // Websocket events are lost while the socket is down (background tabs, + // reconnects): recover finished prompts from /history and the running one + // from /queue. Prompts gone from both were deleted from the queue. + async function backfill() { + if (closed) return; + const queue = await (await comfyApi.fetchApi("/queue")).json(); + if (closed) return; + const queued = new Set([...queue.queue_running, ...queue.queue_pending].map(q => q[1])); + for (const pid of promptIds.slice()) { + if (done.has(pid) || queued.has(pid)) continue; + const history = (await (await comfyApi.fetchApi(`/history/${pid}`)).json())[pid]; + if (closed) return; + for (const [nodeId, output] of Object.entries(history?.outputs || {})) handleOutput(pid, nodeId, output); + markDone(pid); + } + const running = queue.queue_running[0]?.[1]; + if (running && !closed) setRunning(running); } // All websocket lifecycle/output events route through bufferOrApply so @@ -289,19 +333,25 @@ function setupBatchTracking(combinations, comboKeys, totalJobs) { const onExecError = (e) => bufferOrApply("execution_error", e); const onExecInterrupted = (e) => bufferOrApply("execution_interrupted", e); const onExecuted = (e) => bufferOrApply("executed", e); + const onVisibility = () => { if (document.visibilityState === "visible") backfill(); }; comfyApi.addEventListener("execution_start", onExecStart); comfyApi.addEventListener("execution_success", onExecSuccess); comfyApi.addEventListener("execution_error", onExecError); comfyApi.addEventListener("execution_interrupted", onExecInterrupted); comfyApi.addEventListener("executed", onExecuted); + comfyApi.addEventListener("reconnected", backfill); + document.addEventListener("visibilitychange", onVisibility); - const cleanup = () => { + function cleanup() { + closed = true; comfyApi.removeEventListener("execution_start", onExecStart); comfyApi.removeEventListener("execution_success", onExecSuccess); comfyApi.removeEventListener("execution_error", onExecError); comfyApi.removeEventListener("execution_interrupted", onExecInterrupted); comfyApi.removeEventListener("executed", onExecuted); + comfyApi.removeEventListener("reconnected", backfill); + document.removeEventListener("visibilitychange", onVisibility); if (_origApiQueuePrompt) { comfyApi.queuePrompt = _origApiQueuePrompt; } @@ -310,8 +360,9 @@ function setupBatchTracking(combinations, comboKeys, totalJobs) { _activeBatch.comboKeys = []; _activeBatch.runningPromptId = null; pendingByPid.clear(); - clearAllHighlights(); - }; + state.generating = {}; + renderHighlights(state); + } return { promptIds, cleanup }; } @@ -369,6 +420,9 @@ app.queuePrompt = async function(number, batchCount = 1) { } if (orderedNodes.length === 0 && stackAxes.length === 0) { + // A plain run supersedes this workflow's finished batch results. + const state = activeState(); + if (state && state.completed >= state.total) _batchStates.delete(activeWorkflow()); return _originalQueuePrompt.call(this, number, batchCount); } @@ -469,18 +523,22 @@ app.queuePrompt = async function(number, batchCount = 1) { const runsPerCombo = Math.max(1, batchCount | 0); const totalJobs = combinations.length * runsPerCombo; + // Fresh state for this workflow: the "[i/N] done" label restarts at "[0/N]" + // (#227) and the previous batch's results are dropped. + const state = { generating: {}, images: {}, completed: 0, total: totalJobs }; + _batchStates.set(activeWorkflow(), state); + for (const n of flakeGenerateNodes) { + n.properties._images_by_combo = {}; + n.properties._images_by_combo_ad = {}; + n.properties._adetailer_ab = false; + } + renderResults(state); + const comboKeys = []; // parallel to promptIds; comboKeys[idx] = key for promptIds[idx] - const tracker = setupBatchTracking(combinations, comboKeys, totalJobs); + const tracker = setupBatchTracking(state, combinations, comboKeys); if (tracker) { _batchCleanup = tracker.cleanup; } - // Initialize progress counters on every FlakeGenerate node so the "[i/N] done" - // label resets to "[0/N]" at batch start (#227). - for (const n of flakeGenerateNodes) { - n._batch_completed_count = 0; - n._batch_total_count = totalJobs; - n._batch_progress_render?.(); - } try { for (let comboIdx = 0; comboIdx < combinations.length; comboIdx++) { @@ -536,9 +594,5 @@ app.queuePrompt = async function(number, batchCount = 1) { if (orig.widget) orig.widget.value = orig.value; if (orig.overrideWidget) orig.overrideWidget.value = orig.overrideValue; } - // If no tracker is available, clear highlights immediately as fallback - if (!tracker) { - clearAllHighlights(); - } } }; diff --git a/web/widgets/flake-combo.js b/web/widgets/flake-combo.js index 7657507..30f9bbe 100644 --- a/web/widgets/flake-combo.js +++ b/web/widgets/flake-combo.js @@ -7,6 +7,7 @@ import { import { fetchList, fetchFlake, getCoverUrl, getVariantImageUrl, fetchFlakeMeta } from "../api.js"; import { openEditModal } from "../flake-modal.js"; import { openFileLoadPicker } from "../pickers.js"; +import { getGeneratingIndex } from "../queue.js"; function makeComboBlock({ entry, idx, isActive, isGenerating, onEdit, onRemove, onReplace, onToggleBypass, onSingleOut, onDuplicate, onDragStart, onDragOver, onDrop, onDragEnd }) { const hasCover = !!entry.name; @@ -558,7 +559,7 @@ export function setupFlakeComboWidget(node) { function render() { const flakes = readAllFlakes(); const activeIdx = node.properties._combo_active_index || 0; - const generatingIdx = node._combo_generating_index; + const generatingIdx = getGeneratingIndex(node); grid.replaceChildren(); for (let i = 0; i < flakes.length; i++) { diff --git a/web/widgets/flake-generate.js b/web/widgets/flake-generate.js index a841f59..2f75dc7 100644 --- a/web/widgets/flake-generate.js +++ b/web/widgets/flake-generate.js @@ -1,5 +1,5 @@ import { css, setWidgetHidden } from "../utils.js"; -import { computeJobCount } from "../queue.js"; +import { computeJobCount, getBatchImages, getBatchProgress } from "../queue.js"; import { buildModel, openGenerationDataOverlay } from "./generation-data.js"; const ACCENT = "#4a9eff"; @@ -61,10 +61,9 @@ export function setupFlakeGenerateWidget(node) { } } function renderBatchProgress() { - const total = node._batch_total_count ?? 0; - const done = node._batch_completed_count ?? 0; - if (total > 0) { - doneLabel.textContent = `[${done}/${total}] done`; + const progress = getBatchProgress(); + if (progress?.total > 0) { + doneLabel.textContent = `[${progress.completed}/${progress.total}] done`; doneLabel.style.display = "block"; } else { doneLabel.style.display = "none"; @@ -72,7 +71,6 @@ export function setupFlakeGenerateWidget(node) { } refreshJobCount(); renderBatchProgress(); - node._batch_progress_render = renderBatchProgress; const jobsPoll = setInterval(() => { refreshJobCount(); renderBatchProgress(); }, 200); // Generation Data button — width fits content (no flex:1) per #227. @@ -100,9 +98,7 @@ export function setupFlakeGenerateWidget(node) { // buildModel is async (fetches variant meta for All Variants cards, #345). const model = startNode ? await buildModel(startNode) : { axes: [], fixed: { presetName: null, stackFlakes: [] } }; const adetailer = findWidget("adetailer")?.value || "Off"; - openGenerationDataOverlay(model, node.properties._images_by_combo || {}, { - lastImagesByComboAd: node.properties._images_by_combo_ad || {}, - adetailerAB: !!node.properties._adetailer_ab, + openGenerationDataOverlay(model, comboImages, { adetailer, adetailerDenoise: findWidget("adetailer_denoise")?.value, adetailerSteps: findWidget("adetailer_steps")?.value, @@ -194,6 +190,30 @@ export function setupFlakeGenerateWidget(node) { container.appendChild(imageContainer); + // Per-combo results: the live batch state when this workflow ran a batch, + // else what the saved workflow carried (#373). + function comboImages() { + return getBatchImages(node) || { + combo: node.properties._images_by_combo || {}, + ad: node.properties._images_by_combo_ad || {}, + ab: !!node.properties._adetailer_ab, + }; + } + + // Called by queue.js when batch results change, and after a workflow load. + // Mirrors them into properties so they are saved with the workflow. + node._flake_generate_render = function () { + const entry = getBatchImages(node); + if (!entry) return; + node.properties._images_by_combo = entry.combo; + node.properties._images_by_combo_ad = entry.ad; + node.properties._adetailer_ab = entry.ab; + if (entry.last) { + node.properties._last_image = entry.last; + showImage(entry.last); + } + }; + // ── Add DOM widget ── const genWidget = node.addDOMWidget("flake_generate_ui", "div", container, { serialize: false, margin: 4 }); diff --git a/web/widgets/flake-model-combo.js b/web/widgets/flake-model-combo.js index caf323b..7bd0ba4 100644 --- a/web/widgets/flake-model-combo.js +++ b/web/widgets/flake-model-combo.js @@ -6,6 +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"; 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"); @@ -244,7 +245,7 @@ export function setupFlakeModelComboWidget(node) { function render() { const presets = readPresets(); const activeIdx = node.properties._combo_active_index || 0; - const generatingIdx = node._model_combo_generating_index; + const generatingIdx = getGeneratingIndex(node); grid.replaceChildren(); for (let i = 0; i < presets.length; i++) { diff --git a/web/widgets/generation-data.js b/web/widgets/generation-data.js index e644b02..ec41a71 100644 --- a/web/widgets/generation-data.js +++ b/web/widgets/generation-data.js @@ -1,7 +1,7 @@ import { css } from "../utils.js"; import { openOverlay } from "../modal.js"; import { fetchFlake, fetchPreset, getCoverUrl, getVariantImageUrl, fetchFlakeMeta } from "../api.js"; -import { getActiveBatch } from "../queue.js"; +import { getActiveBatch, subscribeBatch } 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. @@ -397,10 +397,10 @@ export function combinationKeyFor(model, selIdx) { } // ── Overlay UI ───────────────────────────────────────────────────────────── -export function openGenerationDataOverlay(model, lastImagesByCombo, opts = {}) { +// 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 { - lastImagesByComboAd = {}, - adetailerAB = false, adetailer = "Off", adetailerDenoise, adetailerSteps, @@ -409,7 +409,8 @@ export function openGenerationDataOverlay(model, lastImagesByCombo, opts = {}) { upscaleModel, upscaleFactor, } = opts; - let { content, footer, close } = openOverlay(); + 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;"); @@ -608,16 +609,19 @@ export function openGenerationDataOverlay(model, lastImagesByCombo, opts = {}) { // Leave width at 0% — onProgress will fill it in. } })(); - // Tear down the listeners when the overlay closes. - const _origClose = close; - close = function(...args) { + // Re-render the image when a result for the selected combination arrives. + const unsubscribe = subscribeBatch(() => { + if (shownData && 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); } - return _origClose(...args); }; // Right: composite image + common data sections @@ -705,22 +709,20 @@ export function openGenerationDataOverlay(model, lastImagesByCombo, opts = {}) { return wrap; } - let refreshToken = 0; - async function refreshRight() { - const token = ++refreshToken; - const sel = currentSelection(); + function selectedResult() { const key = combinationKeyFor(model, selIdx); - const data = await combinationData(model, sel); - if (token !== refreshToken) return; + const images = getImages(); + return (showAd ? images.ad[key] : images.combo[key]) || null; + } + // Generated output for the selected combination, else the cover placeholder. + let imageToken = 0; + async function renderImage(data) { + const token = ++imageToken; + const generated = selectedResult(); + currentGenerated = generated; // Match the preview aspect ratio to the output's resolution (#231). applyAspectRatio(data.width, data.height); - - // Composite (or generated output if we have one for this combination) - const generated = showAd - ? (lastImagesByComboAd && lastImagesByComboAd[key]) - : (lastImagesByCombo && lastImagesByCombo[key]); - currentGenerated = generated || null; if (generated) { dimsFromImage = true; compositeImg.src = `/view?filename=${encodeURIComponent(generated.filename)}&type=${generated.type || "output"}&subfolder=${encodeURIComponent(generated.subfolder || "")}`; @@ -737,9 +739,21 @@ export function openGenerationDataOverlay(model, lastImagesByCombo, opts = {}) { // fill the field with no letterbox; buildComposite uses the same // dims (square fallback when unknown) (#354). compositeImg.style.aspectRatio = (data.width && data.height) ? `${data.width} / ${data.height}` : "1 / 1"; - compositeImg.src = await buildComposite(urls, data.width, data.height); + const src = await buildComposite(urls, data.width, data.height); + 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). @@ -928,27 +942,23 @@ export function openGenerationDataOverlay(model, lastImagesByCombo, opts = {}) { return rows; } - let refreshToken = 0; - 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; + // 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; + } - // 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)); + let imageToken = 0; + async function renderImage(data) { + const token = ++imageToken; + const generated = selectedResult(); + currentGenerated = generated; applyAspectRatioSingle(data.width, data.height); - const generated = showAd - ? (lastImagesByComboAd && (lastImagesByComboAd[key] ?? lastImagesByComboAd[""])) - : (lastImagesByCombo && (lastImagesByCombo[key] ?? lastImagesByCombo[""])); - currentGenerated = generated || null; if (generated) { dimsFromImage = true; compositeImg.src = `/view?filename=${encodeURIComponent(generated.filename)}&type=${generated.type || "output"}&subfolder=${encodeURIComponent(generated.subfolder || "")}`; @@ -965,9 +975,26 @@ export function openGenerationDataOverlay(model, lastImagesByCombo, opts = {}) { // fill the field with no letterbox; buildComposite uses the same // dims (square fallback when unknown) (#354). compositeImg.style.aspectRatio = (data.width && data.height) ? `${data.width} / ${data.height}` : "1 / 1"; - compositeImg.src = await buildComposite(urls, data.width, data.height); + const src = await buildComposite(urls, data.width, data.height); + 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(); @@ -984,6 +1011,11 @@ export function openGenerationDataOverlay(model, lastImagesByCombo, opts = {}) { } } + const unsubscribe = subscribeBatch(() => { + if (shownData && selectedResult() !== currentGenerated) renderImage(shownData); + }); + handlers.onClose = unsubscribe; + refreshRight(); }