fix: combo highlights and overlay results lost across tab switches and reconnects (#373)

Batch state (running combo per node id, results per FlakeGenerate node id, progress) now lives in a per-workflow store in queue.js. Nodes read it when they render and after a workflow load; the overlay reads results through a getter and re-renders when one arrives. On websocket reconnect or when the tab becomes visible, finished prompts are backfilled from /history and the running one from /queue. Results are cleared at batch start.
This commit is contained in:
Mehdi Chaid
2026-10-15 21:11:23 -04:00
parent b1d482bb60
commit ecf7aceef4
5 changed files with 254 additions and 146 deletions
+147 -93
View File
@@ -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();
}
}
};
+2 -1
View File
@@ -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++) {
+29 -9
View File
@@ -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 });
+2 -1
View File
@@ -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++) {
+74 -42
View File
@@ -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();
}