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:
+147
-93
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user