feat: collect and display distinct prompts for multi-output workflows in the UI

This commit is contained in:
Ewald
2026-02-05 00:11:25 +01:00
parent 4d9a4b5fcc
commit 51ecf0db61
6 changed files with 410 additions and 58 deletions
+154 -2
View File
@@ -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:
@@ -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;
@@ -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) => {
+67 -15
View File
@@ -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.
+111 -40
View File
@@ -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<id, asset>, 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);
}
/**
+12 -1
View File
@@ -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 } = {}) {