From 51ecf0db611c3f5cd9cb98f2b8d44c5220cdbe51 Mon Sep 17 00:00:00 2001 From: Ewald <129623145+MajoorWaldi@users.noreply.github.com> Date: Thu, 5 Feb 2026 00:11:25 +0100 Subject: [PATCH] feat: collect and display distinct prompts for multi-output workflows in the UI --- backend/features/geninfo/parser.py | 156 +++++++++++++++++- .../sidebar/parsers/geninfoParser.js | 8 + .../sidebar/sections/GenerationSection.js | 58 +++++++ js/entry.js | 82 +++++++-- js/features/grid/GridView.js | 151 ++++++++++++----- js/features/panel/AssetsManagerPanel.js | 13 +- 6 files changed, 410 insertions(+), 58 deletions(-) diff --git a/backend/features/geninfo/parser.py b/backend/features/geninfo/parser.py index d130c8fd..f52019b3 100644 --- a/backend/features/geninfo/parser.py +++ b/backend/features/geninfo/parser.py @@ -1452,6 +1452,64 @@ def _extract_input_files(nodes_by_id: Dict[str, Any]) -> List[Dict[str, Any]]: return inputs +def _collect_all_prompts_from_sinks( + nodes_by_id: Dict[str, Any], + sinks: List[str], + max_sinks: int = 20 +) -> Tuple[List[str], List[str]]: + """ + Collect all distinct positive and negative prompts from multiple sinks. + + For multi-output workflows (like batch generation with different prompts), + this gathers all unique prompts to show the user what variants were generated. + + Returns: + (all_positive_prompts, all_negative_prompts) - deduplicated lists + """ + all_positive: List[str] = [] + all_negative: List[str] = [] + seen_pos: Set[str] = set() + seen_neg: Set[str] = set() + + for sink_id in sinks[:max_sinks]: + try: + # Try primary sampler first + sampler_id, _ = _select_primary_sampler(nodes_by_id, sink_id) + if not sampler_id: + sampler_id, _ = _select_advanced_sampler(nodes_by_id, sink_id) + if not sampler_id: + continue + + sampler_node = nodes_by_id.get(sampler_id) + if not isinstance(sampler_node, dict): + continue + + ins = _inputs(sampler_node) + + # Extract positive prompt + if _is_link(ins.get("positive")): + items = _collect_texts_from_conditioning(nodes_by_id, ins.get("positive"), branch="positive") + for text, _ in items: + t = text.strip() + if t and t not in seen_pos: + seen_pos.add(t) + all_positive.append(t) + + # Extract negative prompt + if _is_link(ins.get("negative")): + items = _collect_texts_from_conditioning(nodes_by_id, ins.get("negative"), branch="negative") + for text, _ in items: + t = text.strip() + if t and t not in seen_neg: + seen_neg.add(t) + all_negative.append(t) + + except Exception: + continue + + return all_positive, all_negative + + def _determine_workflow_type(nodes_by_id: Dict[str, Any], sink_node_id: str, sampler_id: Optional[str]) -> str: """ Classify: T2I, I2I, T2V, I2V, V2V @@ -1635,7 +1693,15 @@ def parse_geninfo_from_prompt(prompt_graph: Any, workflow: Any = None) -> Result return Result.Ok(None) def _normalize_graph_input(prompt_graph: Any, workflow: Any) -> Optional[Dict[str, Dict[str, Any]]]: - """Normalize prompt graph or workflow into nodes_by_id dict.""" + """Normalize prompt graph or workflow into nodes_by_id dict. + + Handles both ComfyUI prompt-graph format and LiteGraph workflow format. + LiteGraph format has: + - nodes: list of {id, type, inputs: [{name, link}], widgets_values: [...]} + - links: list of [link_id, src_node, src_slot, tgt_node, tgt_slot, type] + Prompt-graph format has: + - dict with node_id as key: {class_type, inputs: {name: value}} + """ target_graph = prompt_graph if not isinstance(target_graph, dict) or not target_graph: @@ -1647,10 +1713,86 @@ def _normalize_graph_input(prompt_graph: Any, workflow: Any) -> Optional[Dict[st nodes_by_id: Dict[str, Dict[str, Any]] = {} if "nodes" in target_graph and isinstance(target_graph["nodes"], list): + # LiteGraph format - need to convert inputs list to dict and apply widgets_values + links = target_graph.get("links", []) + + # Build link map: link_id -> (src_node_id, src_slot) + link_to_source: Dict[int, Tuple[int, int]] = {} + for link in links: + if isinstance(link, list) and len(link) >= 3: + link_id, src_node, src_slot = link[0], link[1], link[2] + link_to_source[link_id] = (src_node, src_slot) + for node in target_graph["nodes"]: if isinstance(node, dict): - nodes_by_id[str(node.get("id"))] = node + node_id = str(node.get("id")) + # Convert LiteGraph node to prompt-graph-like format + converted = { + "class_type": node.get("type"), + "type": node.get("type"), # Keep both for compatibility + "id": node.get("id"), + "inputs": {}, + # Preserve original fields for other parsers + "widgets_values": node.get("widgets_values"), + "outputs": node.get("outputs"), + "properties": node.get("properties"), + "title": node.get("title"), + "mode": node.get("mode"), + } + + raw_inputs = node.get("inputs", []) + widgets_values = node.get("widgets_values", []) + + # Build inputs dict from: + # 1. Links (for connected inputs) -> store as ["node_id", slot] + # 2. widgets_values (for widget inputs) + # Note: widgets_values can be a list or a dict (e.g., VHS nodes use dict) + widgets_list = widgets_values if isinstance(widgets_values, list) else [] + + if isinstance(raw_inputs, list): + widget_idx = 0 + for inp in raw_inputs: + if isinstance(inp, dict): + name = inp.get("name") + if not name: + continue + link_id = inp.get("link") + if link_id is not None and link_id in link_to_source: + # Connected input - store as [source_node_id, source_slot] + src_node_id, src_slot = link_to_source[link_id] + converted["inputs"][name] = [str(src_node_id), src_slot] + elif "widget" in inp: + # Widget input - try to get value from widgets_values + # LiteGraph stores widget values in order they appear + if widget_idx < len(widgets_list): + converted["inputs"][name] = widgets_list[widget_idx] + widget_idx += 1 + elif isinstance(raw_inputs, dict): + # Already in dict format (shouldn't happen for LiteGraph but handle it) + converted["inputs"] = raw_inputs + + # Handle widgets_values as dict (VHS and some other nodes) + if isinstance(widgets_values, dict): + for k, v in widgets_values.items(): + if k not in converted["inputs"]: + converted["inputs"][k] = v + + # Also copy widgets_values to inputs if they have meaningful names + # This helps with nodes that have prompts in widgets but no widget definition + if widgets_list: + # For text encoder nodes, widgets_values[0] is usually the text + node_type_lower = _lower(node.get("type")) + if "text" not in converted["inputs"]: + if any(x in node_type_lower for x in ["primitive", "string", "text", "encode"]): + for wv in widgets_list: + if isinstance(wv, str) and len(wv.strip()) > 10: + converted["inputs"]["text"] = wv + converted["inputs"]["value"] = wv + break + + nodes_by_id[node_id] = converted else: + # Prompt-graph format - use as-is for k, v in target_graph.items(): if isinstance(v, dict): nodes_by_id[str(k)] = v @@ -2041,6 +2183,16 @@ def _extract_geninfo(nodes_by_id: Dict[str, Any], sinks: List[str], workflow_met if input_files: out["inputs"] = input_files + # For multi-output workflows, collect all distinct prompts + # This helps users see all variations when a workflow generates multiple outputs + if len(sinks) > 1: + all_pos, all_neg = _collect_all_prompts_from_sinks(nodes_by_id, sinks) + # Only add if there are multiple distinct prompts + if len(all_pos) > 1: + out["all_positive_prompts"] = all_pos + if len(all_neg) > 1: + out["all_negative_prompts"] = all_neg + # Do-not-lie: if nothing useful extracted besides engine, return None. if len(out.keys()) <= 1: if workflow_meta: diff --git a/js/components/sidebar/parsers/geninfoParser.js b/js/components/sidebar/parsers/geninfoParser.js index 2310429d..a3619923 100644 --- a/js/components/sidebar/parsers/geninfoParser.js +++ b/js/components/sidebar/parsers/geninfoParser.js @@ -50,6 +50,14 @@ export function normalizeGenerationMetadata(raw) { mapped.inputs = inputs; } + // Multi-output workflow prompts + if (Array.isArray(geninfo.all_positive_prompts) && geninfo.all_positive_prompts.length > 1) { + mapped.all_positive_prompts = geninfo.all_positive_prompts; + } + if (Array.isArray(geninfo.all_negative_prompts) && geninfo.all_negative_prompts.length > 1) { + mapped.all_negative_prompts = geninfo.all_negative_prompts; + } + const size = geninfo.size || null; if (size && typeof size === "object") { if (size.width !== undefined) mapped.width = size.width; diff --git a/js/components/sidebar/sections/GenerationSection.js b/js/components/sidebar/sections/GenerationSection.js index bf771198..b2b15caf 100644 --- a/js/components/sidebar/sections/GenerationSection.js +++ b/js/components/sidebar/sections/GenerationSection.js @@ -127,6 +127,64 @@ export function createGenerationSection(asset) { container.appendChild(createInfoBox("Negative Prompt", cleaned.negative, "#F44336")); } + // Multi-output workflows: Show all distinct prompts + if (metadata.all_positive_prompts && Array.isArray(metadata.all_positive_prompts) && metadata.all_positive_prompts.length > 1) { + const multiBox = document.createElement("div"); + multiBox.style.cssText = ` + background: rgba(0,0,0,0.3); + border-left: 3px solid #FF9800; + border-radius: 6px; + padding: 12px; + margin-top: 4px; + `; + + const header = document.createElement("div"); + header.textContent = `All Prompts (${metadata.all_positive_prompts.length} variants)`; + header.title = "This workflow generates multiple outputs with different prompts"; + header.style.cssText = ` + font-size: 11px; + font-weight: 600; + color: #FF9800; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; + cursor: pointer; + `; + + const list = document.createElement("div"); + list.style.cssText = "display: none; flex-direction: column; gap: 6px; max-height: 200px; overflow-y: auto;"; + + metadata.all_positive_prompts.forEach((p, i) => { + const item = document.createElement("div"); + item.style.cssText = ` + font-size: 11px; + color: #ddd; + padding: 6px 8px; + background: rgba(255,255,255,0.05); + border-radius: 4px; + word-break: break-word; + `; + item.textContent = `${i + 1}. ${p}`; + item.title = `Variant ${i + 1}: ${p}`; + list.appendChild(item); + }); + + // Toggle expand/collapse + let expanded = false; + header.onclick = () => { + expanded = !expanded; + list.style.display = expanded ? "flex" : "none"; + header.textContent = expanded + ? `All Prompts (${metadata.all_positive_prompts.length} variants) ▲` + : `All Prompts (${metadata.all_positive_prompts.length} variants) ▼`; + }; + header.textContent += " ▼"; + + multiBox.appendChild(header); + multiBox.appendChild(list); + container.appendChild(multiBox); + } + const modelData = []; const models = metadata.models && typeof metadata.models === "object" ? metadata.models : null; const pickModelName = (m) => { diff --git a/js/entry.js b/js/entry.js index 27a18bcd..59a80e8e 100644 --- a/js/entry.js +++ b/js/entry.js @@ -8,7 +8,7 @@ import { testAPI, triggerStartupScan } from "./app/bootstrap.js"; import { ensureStyleLoaded } from "./app/style.js"; import { registerMajoorSettings } from "./app/settings.js"; import { initDragDrop } from "./features/dnd/DragDrop.js"; -import { loadAssets, upsertAsset, captureAnchor, restoreAnchor, isAtBottom, scrollToBottom } from "./features/grid/GridView.js"; +import { loadAssets, upsertAsset, captureAnchor, restoreAnchor, isAtBottom, scrollToBottom, flushUpsertBatch } from "./features/grid/GridView.js"; import { renderAssetsManager, getActiveGridContainer } from "./features/panel/AssetsManagerPanel.js"; import { extractOutputFiles } from "./utils/extractOutputFiles.js"; import { post, get } from "./api/client.js"; @@ -25,6 +25,13 @@ const RAF_STABILIZE_FRAMES = 2; const AUTO_REFRESH_DEBOUNCE_MS = 500; const AUTO_INSERT_FETCH_LIMIT = 50; +// Queue limits to prevent memory exhaustion and UI freeze +const PENDING_FILES_MAX = 200; // Max files in queue before dropping oldest +const EXECUTION_STARTS_MAX = 50; // Max tracked prompt IDs +const EXECUTION_STARTS_TTL_MS = 300000; // 5 min TTL for orphan cleanup +const PROCESS_QUEUE_DEBOUNCE_MS = 100; // Debounce queue processing +const PROCESS_QUEUE_BATCH_MAX = 20; // Max files per batch to avoid long blocking + const _raf = () => new Promise((resolve) => { try { @@ -178,12 +185,37 @@ app.registerExtension({ api._mjrPendingFiles = api._mjrPendingFiles || []; api._mjrProcessingQueue = false; // Mutex for async processing api._mjrExecutionStarts = api._mjrExecutionStarts || new Map(); + api._mjrProcessQueueTimer = null; // Debounce timer + api._mjrLastQueueProcess = 0; // Last process timestamp + + // Cleanup old execution starts to prevent memory leak + const cleanupExecutionStarts = () => { + try { + if (!api._mjrExecutionStarts || api._mjrExecutionStarts.size === 0) return; + const now = Date.now(); + const toDelete = []; + for (const [id, ts] of api._mjrExecutionStarts.entries()) { + if (now - ts > EXECUTION_STARTS_TTL_MS) { + toDelete.push(id); + } + } + for (const id of toDelete) { + api._mjrExecutionStarts.delete(id); + } + // Also limit size + while (api._mjrExecutionStarts.size > EXECUTION_STARTS_MAX) { + const oldest = api._mjrExecutionStarts.keys().next().value; + if (oldest) api._mjrExecutionStarts.delete(oldest); + } + } catch {} + }; // Listen for execution start to effectively measure generation time const onExecutionStart = (event) => { const promptId = event?.detail?.prompt_id; if (promptId) { api._mjrExecutionStarts.set(promptId, Date.now()); + cleanupExecutionStarts(); } }; api.addEventListener("execution_start", onExecutionStart); @@ -194,10 +226,11 @@ app.registerExtension({ if (api._mjrPendingFiles.length === 0) return; api._mjrProcessingQueue = true; + api._mjrLastQueueProcess = Date.now(); - // Grab current batch - const batch = [...api._mjrPendingFiles]; - api._mjrPendingFiles = []; + // Grab current batch (limited size to avoid blocking UI) + const batchSize = Math.min(api._mjrPendingFiles.length, PROCESS_QUEUE_BATCH_MAX); + const batch = api._mjrPendingFiles.splice(0, batchSize); try { // 1. Index the files (backend) @@ -247,11 +280,14 @@ app.registerExtension({ const result = await get(url); if (result?.ok && Array.isArray(result.data?.assets)) { + // Batch upserts to avoid multiple setItems calls for (const asset of result.data.assets) { try { upsertAsset(latestGrid, asset); } catch {} } + // Flush the batch before scroll operations + flushUpsertBatch(latestGrid); } // Auto-scroll logic @@ -268,13 +304,28 @@ app.registerExtension({ } finally { api._mjrProcessingQueue = false; - // If new files arrived while we were working, go again immediately + // If new files arrived while we were working, schedule next batch with debounce if (api._mjrPendingFiles.length > 0) { - // Small yield to UI thread before looping - setTimeout(processFileQueue, 16); + // Yield to UI thread with debounce to prevent tight loops + if (api._mjrProcessQueueTimer) clearTimeout(api._mjrProcessQueueTimer); + api._mjrProcessQueueTimer = setTimeout(() => { + api._mjrProcessQueueTimer = null; + processFileQueue(); + }, PROCESS_QUEUE_DEBOUNCE_MS); } } }; + + // Debounced queue trigger + const scheduleProcessQueue = () => { + if (api._mjrProcessQueueTimer) return; // Already scheduled + if (api._mjrProcessingQueue) return; // Already processing + + api._mjrProcessQueueTimer = setTimeout(() => { + api._mjrProcessQueueTimer = null; + processFileQueue(); + }, PROCESS_QUEUE_DEBOUNCE_MS); + }; api._mjrExecutedHandler = async (event) => { try { @@ -286,12 +337,8 @@ app.registerExtension({ const promptId = detail?.prompt_id; const grid = getActiveGridContainer(); - if (!output) return; // Even if grid is hidden, we might want to buffer? No, save resources. + if (!output) return; - // Note: Image Feed logic doesn't require grid, but we need it for `upsertAsset`. - // If grid is not present, we still index? Yes, useful for consistency. - // But `processFileQueue` checks for grid before fetching. - const hasMedia = output.images || output.gifs || output.videos; if (!hasMedia) return; @@ -313,11 +360,16 @@ app.registerExtension({ } } - // Add to batch + // Add to batch with size limit api._mjrPendingFiles.push(...newFiles); + + // Enforce queue size limit (drop oldest if overflow) + while (api._mjrPendingFiles.length > PENDING_FILES_MAX) { + api._mjrPendingFiles.shift(); + } - // Trigger processing (Debounce removed: Queue Pattern used instead) - processFileQueue(); + // Trigger processing with debounce + scheduleProcessQueue(); }; // Bind to multiple event names across ComfyUI variants. diff --git a/js/features/grid/GridView.js b/js/features/grid/GridView.js index 15a07a93..be355153 100644 --- a/js/features/grid/GridView.js +++ b/js/features/grid/GridView.js @@ -47,6 +47,13 @@ let rtHydrateSeenPruneBudget = 0; // RT_HYDRATE_STATE remains as the per-grid state map const RT_HYDRATE_STATE = new WeakMap(); // gridContainer -> { queue, inflight, seen, active, lastPruneAt } +// ───────────────────────────────────────────────────────────────────────────── +// Upsert Batching - Accumulate upserts and flush once for performance +// ───────────────────────────────────────────────────────────────────────────── +const UPSERT_BATCH_DEBOUNCE_MS = 50; // Debounce window for batch flush +const UPSERT_BATCH_MAX_SIZE = 50; // Max items before forced flush +const UPSERT_BATCH_STATE = new WeakMap(); // gridContainer -> { pending: Map, timer: number|null } + // Temporary debug logging for grid + infinite scroll. // Enable at runtime from the browser console: // globalThis._mjrDebugGrid = true @@ -1713,61 +1720,125 @@ function findAssetElement(gridContainer, assetId) { return gridContainer.querySelector(`[data-mjr-asset-id="${CSS.escape ? CSS.escape(String(assetId)) : String(assetId)}"]`); } +/** + * Get or create upsert batch state for a grid + */ +function _getUpsertBatchState(gridContainer) { + let s = UPSERT_BATCH_STATE.get(gridContainer); + if (!s) { + s = { pending: new Map(), timer: null, flushing: false }; + UPSERT_BATCH_STATE.set(gridContainer, s); + } + return s; +} + +/** + * Flush all pending upserts in one batch (single setItems call) + */ +function _flushUpsertBatch(gridContainer) { + const batchState = UPSERT_BATCH_STATE.get(gridContainer); + if (!batchState || batchState.pending.size === 0) return; + if (batchState.flushing) return; // Prevent re-entrancy + + batchState.flushing = true; + if (batchState.timer) { + clearTimeout(batchState.timer); + batchState.timer = null; + } + + const state = getOrCreateState(gridContainer); + const vg = ensureVirtualGrid(gridContainer, state); + + try { + // Process all pending assets + let modified = false; + + for (const [assetId, asset] of batchState.pending.entries()) { + const key = assetKey(asset); + const existingIndex = state.assets.findIndex(a => String(a.id) === assetId); + + if (existingIndex > -1) { + // Update existing + const existingAsset = state.assets[existingIndex]; + const collision = existingAsset._mjrNameCollision; + const collisionCount = existingAsset._mjrNameCollisionCount; + + Object.assign(existingAsset, asset); + + if (collision && !asset._mjrNameCollision) { + existingAsset._mjrNameCollision = true; + existingAsset._mjrNameCollisionCount = collisionCount; + } + state.assets[existingIndex] = { ...existingAsset }; + modified = true; + } else { + if (!state.seenKeys.has(key)) { + const sortKey = gridContainer.dataset.mjrSort || "mtime_desc"; + const insertPos = findInsertPosition(state.assets, asset, sortKey); + + state.seenKeys.add(key); + + if (insertPos === -1) { + state.assets.push(asset); + } else { + state.assets.splice(insertPos, 0, asset); + } + modified = true; + } + } + } + + // Single setItems call for all changes + if (modified && vg) { + vg.setItems(state.assets); + } + } catch (err) { + console.warn("📂 Majoor [⚠️] Upsert batch flush error:", err); + } finally { + batchState.pending.clear(); + batchState.flushing = false; + } +} + /** * Update or insert a single asset in the grid (Live Update). + * Uses batching to avoid multiple setItems calls in rapid succession. * @param {HTMLElement} gridContainer * @param {Object} asset The new or updated asset data - * @returns {boolean} True if state was modified + * @returns {boolean} True if asset was queued for update */ export function upsertAsset(gridContainer, asset) { if (!asset || !asset.id) return false; const state = getOrCreateState(gridContainer); const assetId = String(asset.id); - const key = assetKey(asset); - + const vg = ensureVirtualGrid(gridContainer, state); if (!vg) return false; - // Check if asset already exists in DATA - const existingIndex = state.assets.findIndex(a => String(a.id) === assetId); + // Add to batch + const batchState = _getUpsertBatchState(gridContainer); + batchState.pending.set(assetId, asset); - if (existingIndex > -1) { - // Update existing - const existingAsset = state.assets[existingIndex]; - - // Preserve computed properties if not in new asset - const collision = existingAsset._mjrNameCollision; - const collisionCount = existingAsset._mjrNameCollisionCount; - - Object.assign(existingAsset, asset); - - if (collision && !asset._mjrNameCollision) { - existingAsset._mjrNameCollision = true; - existingAsset._mjrNameCollisionCount = collisionCount; - } - - // Force virtual grid update by creating new identity - state.assets[existingIndex] = { ...existingAsset }; - vg.setItems(state.assets); - return true; - } else { - if (state.seenKeys.has(key)) return false; - - const sortKey = gridContainer.dataset.mjrSort || "mtime_desc"; - const insertPos = findInsertPosition(state.assets, asset, sortKey); - - state.seenKeys.add(key); - - if (insertPos === -1) { - state.assets.push(asset); - } else { - state.assets.splice(insertPos, 0, asset); - } - - vg.setItems(state.assets); - return true; + // Schedule flush with debounce, or flush immediately if batch is large + if (batchState.pending.size >= UPSERT_BATCH_MAX_SIZE) { + _flushUpsertBatch(gridContainer); + } else if (!batchState.timer && !batchState.flushing) { + batchState.timer = setTimeout(() => { + batchState.timer = null; + _flushUpsertBatch(gridContainer); + }, UPSERT_BATCH_DEBOUNCE_MS); } + + return true; +} + +/** + * Force flush any pending upsert batch (call before operations that need up-to-date grid) + * @param {HTMLElement} gridContainer + */ +export function flushUpsertBatch(gridContainer) { + _flushUpsertBatch(gridContainer); } /** diff --git a/js/features/panel/AssetsManagerPanel.js b/js/features/panel/AssetsManagerPanel.js index a58d8ef4..83f7383f 100644 --- a/js/features/panel/AssetsManagerPanel.js +++ b/js/features/panel/AssetsManagerPanel.js @@ -36,7 +36,18 @@ import { bindGridContextMenu } from "../contextmenu/GridContextMenu.js"; let _activeGridContainer = null; export function getActiveGridContainer() { - return _activeGridContainer; + // First try the cached reference + if (_activeGridContainer && _activeGridContainer.isConnected) { + return _activeGridContainer; + } + // Fallback: try to find it in the DOM by ID or class + try { + const grid = document.getElementById("mjr-assets-grid") || document.querySelector(".mjr-grid"); + if (grid && grid.isConnected) { + return grid; + } + } catch {} + return null; } export async function renderAssetsManager(container, { useComfyThemeUI = true } = {}) {