feat: collect and display distinct prompts for multi-output workflows in the UI
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
Reference in New Issue
Block a user