import { app } from "/scripts/app.js"; import { api } from "/scripts/api.js"; import { authenticatedFetch } from "./js/auth_helper.js"; // 36 Curated Accessible, High-Contrast Cycling Colors for Batch Visual Grouping const BATCH_COLORS = [ "#10b981", // 1. Emerald "#3b82f6", // 2. Blue "#8b5cf6", // 3. Purple "#ec4899", // 4. Pink "#f59e0b", // 5. Amber "#06b6d4", // 6. Cyan "#ef4444", // 7. Red "#14b8a6", // 8. Teal "#6366f1", // 9. Indigo "#f97316", // 10. Orange "#84cc16", // 11. Lime "#d946ef", // 12. Fuchsia "#0ea5e9", // 13. Sky "#a855f7", // 14. Violet "#e11d48", // 15. Rose "#22c55e", // 16. Green "#eab308", // 17. Yellow "#64748b", // 18. Slate "#2dd4bf", // 19. Turquoise "#fb7185", // 20. Coral "#38bdf8", // 21. Light Blue "#c084fc", // 22. Lavender "#f43f5e", // 23. Ruby "#4ade80", // 24. Mint "#fbbf24", // 25. Gold "#818cf8", // 26. Periwinkle "#fb923c", // 27. Tangerine "#a3e635", // 28. Chartreuse "#f472b6", // 29. Hot Pink "#22d3ee", // 30. Aqua "#e879f9", // 31. Orchid "#a7f3d0", // 32. Seafoam "#fde047", // 33. Lemon "#fda4af", // 34. Blush "#93c5fd", // 35. Cornflower "#c4b5fd" // 36. Lilac ]; const STORAGE_KEY = "saturnnodes_batch_queue_meta"; const BATCH_COUNTER_KEY = "saturnnodes_batch_counter"; const SETTING_ID = "SaturnNodes.šŸŽØ Batch Queue.01_ShowBatchLines"; const SETTING_SNAPSHOT_GUARD = "SaturnNodes.šŸŽØ Batch Queue.02_SnapshotGuard"; // In-memory registry mapping prompt_id -> batch metadata let batchRegistry = new Map(); let activeBatchContext = null; let batchCounter = 0; function loadStorage() { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { for (const item of parsed) { if (item && item.prompt_id) { batchRegistry.set(String(item.prompt_id), item); } } } } const savedCounter = localStorage.getItem(BATCH_COUNTER_KEY); if (savedCounter) { // A negative or corrupted counter would index outside BATCH_COLORS // and render an undefined stroke color. batchCounter = Math.max(0, parseInt(savedCounter, 10) || 0); } } catch (e) { console.warn("[SaturnNodes BatchQueue] Failed to load batch storage:", e); } } function saveStorage() { try { // Prune entries to the 250 most recently queued items. Map insertion // order is not a reliable recency order when metadata for an existing // prompt is updated in place. const allEntries = Array.from(batchRegistry.values()); const pruned = allEntries .map((entry, index) => ({ entry, index })) .sort((a, b) => { const aTime = Number(a.entry && a.entry.queuedAt) || 0; const bTime = Number(b.entry && b.entry.queuedAt) || 0; return aTime - bTime || a.index - b.index; }) .slice(-250) .map(item => item.entry); localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned)); localStorage.setItem(BATCH_COUNTER_KEY, String(batchCounter)); } catch (e) { // Storage full or private mode } } function getNextBatch(batchCount = 1) { batchCounter++; const colorIndex = (batchCounter - 1) % BATCH_COLORS.length; const color = BATCH_COLORS[colorIndex]; const batchId = `batch_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`; const batch = { batchId, batchNumber: batchCounter, batchCount: Math.max(1, batchCount), color, colorIndex, queuedAt: Date.now(), prompts: [] }; return batch; } // Inject CSS for the 1D git-graph style line function injectBatchStyles() { if (typeof document === "undefined") return; const styleId = "saturnnodes-batch-queue-styles"; if (document.getElementById(styleId)) return; const style = document.createElement("style"); style.id = styleId; style.textContent = ` /* SaturnNodes 1D Git-Graph Batch Line Container */ .saturnnodes-batch-line-wrap { position: absolute; left: -9px; top: 0; bottom: 0; width: 9px; pointer-events: none; z-index: 50; display: flex; align-items: stretch; justify-content: center; overflow: visible; } .saturnnodes-batch-line-svg { width: 100%; height: 100%; display: block; overflow: visible; } /* Ensure parent card has relative positioning and allows line in left margin */ [data-testid="job-assets-list"] [data-job-id] { position: relative !important; overflow: visible !important; } /* Support for classic frontend v1 queue table rows */ .comfy-table tr[data-job-id], .comfy-queue-item { position: relative !important; overflow: visible !important; } `; document.head.appendChild(style); } // Batch metadata comes from localStorage and the server, so never trust its // color string as HTML/SVG markup. Only complete 3/4/6/8-digit hex colors are // accepted (5 and 7-digit values are not valid CSS colors). const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; function normalizeBatchColor(color, fallback = BATCH_COLORS[0]) { if (typeof color === "string") { const trimmed = color.trim(); if (HEX_COLOR_PATTERN.test(trimmed)) return trimmed; } return fallback; } function normalizeSegmentType(segmentType) { return ["single", "first", "middle", "last"].includes(segmentType) ? segmentType : "single"; } // Build 1D SVG line path according to segment type: // 'single': bracket (rounded top & bottom curving right to card) // 'first': rounded top curving right, straight all the way down // 'middle': straight line top to bottom // 'last': straight from top, rounded bottom curving right to card function buildSvgPath(segmentType) { // Coordinate system: width=10, height=48. Curve bends toward the card (x=9) switch (segmentType) { case "single": return "M 9,6 Q 2,6 2,16 L 2,32 Q 2,42 9,42"; case "first": return "M 9,6 Q 2,6 2,16 L 2,48"; case "middle": return "M 2,0 L 2,48"; case "last": return "M 2,0 L 2,32 Q 2,42 9,42"; default: return "M 9,6 Q 2,6 2,16 L 2,32 Q 2,42 9,42"; } } function renderBatchLineOnElement(element, segmentType, color) { if (!element) return; let wrap = element.querySelector(".saturnnodes-batch-line-wrap"); if (!wrap) { wrap = document.createElement("div"); wrap.className = "saturnnodes-batch-line-wrap"; element.appendChild(wrap); } const safeSegment = normalizeSegmentType(segmentType); const safeColor = normalizeBatchColor(color); // Avoid rewriting the SVG on every status/executing refresh when the // visible segment and batch color have not changed. const signature = `${safeSegment}|${safeColor}`; if (wrap.dataset.saturnnodesBatchSignature === signature && wrap.innerHTML) return; wrap.dataset.saturnnodesBatchSignature = signature; const dPath = buildSvgPath(safeSegment); wrap.innerHTML = ` `; } function removeBatchLineFromElement(element) { if (!element) return; const wrap = element.querySelector(".saturnnodes-batch-line-wrap"); if (wrap) { wrap.remove(); } } // Check if SaturnNodes Batch Queue is enabled in settings function isBatchQueueEnabled() { try { if (app.extensionManager?.setting?.get) { const val = app.extensionManager.setting.get(SETTING_ID); if (val !== undefined) return val !== false; } if (app.ui?.settings?.get) { const val = app.ui.settings.get(SETTING_ID); if (val !== undefined) return val !== false; } } catch (e) {} return true; } function isSnapshotGuardEnabled() { try { if (app.extensionManager?.setting?.get) { const val = app.extensionManager.setting.get(SETTING_SNAPSHOT_GUARD); if (val !== undefined) return val !== false; } if (app.ui?.settings?.get) { const val = app.ui.settings.get(SETTING_SNAPSHOT_GUARD); if (val !== undefined) return val !== false; } } catch (e) {} return true; } function isSeedInput(key, val, nodeInputs) { const k = String(key).toLowerCase(); // 1. Direct seed properties (seed, noise_seed, sampler_seed, seed_num, random_seed, etc.) if (k.includes("seed")) { return typeof val === "number" || (typeof val === "string" && /^-?\d+$/.test(val.trim())); } // 2. Dynamic random integer/number properties (random_value, random_int, etc.) if (k.includes("random") && typeof val === "number") { return true; } // 3. ComfyUI PrimitiveNode controlling a seed widget (contains control_after_generate or control_mode) if (key === "value" && nodeInputs && (nodeInputs.control_after_generate !== undefined || nodeInputs.control_mode !== undefined)) { return typeof val === "number" || (typeof val === "string" && /^-?\d+$/.test(String(val).trim())); } return false; } function mergeDynamicSeeds(snapPrompt, livePrompt) { if (!snapPrompt) return livePrompt; try { const cloned = JSON.parse(JSON.stringify(snapPrompt)); if (livePrompt && livePrompt.output && cloned.output) { for (const [nodeId, liveNode] of Object.entries(livePrompt.output)) { const snapNode = cloned.output[nodeId]; if (snapNode && snapNode.inputs && liveNode.inputs) { for (const [key, val] of Object.entries(liveNode.inputs)) { if (isSeedInput(key, val, liveNode.inputs)) { snapNode.inputs[key] = val; } } } } } // Also sync seed values in workflow metadata so saved PNG metadata matches generated seeds if (livePrompt && livePrompt.workflow && Array.isArray(livePrompt.workflow.nodes) && cloned.workflow && Array.isArray(cloned.workflow.nodes)) { const liveWorkflowMap = new Map(); for (const n of livePrompt.workflow.nodes) { if (n && n.id !== undefined) liveWorkflowMap.set(String(n.id), n); } for (const snapNode of cloned.workflow.nodes) { if (!snapNode || snapNode.id === undefined) continue; const liveWfNode = liveWorkflowMap.get(String(snapNode.id)); if (liveWfNode && Array.isArray(liveWfNode.widgets_values) && Array.isArray(snapNode.widgets_values)) { const graphNode = app.graph?.getNodeById?.(snapNode.id) || app.graph?.getNodeById?.(Number(snapNode.id)); for (let i = 0; i < liveWfNode.widgets_values.length; i++) { const liveVal = liveWfNode.widgets_values[i]; const snapVal = snapNode.widgets_values[i]; if (typeof liveVal === "number" && typeof snapVal === "number" && liveVal !== snapVal) { const widgetName = graphNode?.widgets?.[i]?.name; if (widgetName) { if (isSeedInput(widgetName, liveVal)) { snapNode.widgets_values[i] = liveVal; } } else { const apiInputs = cloned.output?.[String(snapNode.id)]?.inputs; if (apiInputs) { for (const [k, v] of Object.entries(apiInputs)) { if (v === liveVal && isSeedInput(k, v)) { snapNode.widgets_values[i] = liveVal; break; } } } } } } } } } return cloned; } catch (e) { console.warn("[SaturnNodes BatchQueue] Error merging dynamic seeds into snapshot:", e); return snapPrompt; } } // Sync with server if PersistentQueue is available async function syncBatchToServer(promptId, batchInfo) { try { await authenticatedFetch("/saturnnodes/batch_queue/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt_id: promptId, batch_info: batchInfo }) }).catch(() => null); } catch (e) { // Silently ignore if server endpoint is not present } } async function loadBatchFromServer() { try { if (!api || !api.fetchApi) return; const res = await api.fetchApi("/saturnnodes/batch_queue/data", { cache: "no-store" }).catch(() => null); if (res && res.ok) { const data = await res.json(); if (data && typeof data === "object") { for (const [pid, info] of Object.entries(data)) { if (!pid || !info || typeof info !== "object") continue; const key = String(pid); const existing = batchRegistry.get(key); // Never let an older server snapshot overwrite newer local // metadata for the same prompt. if (existing && (Number(existing.queuedAt) || 0) > (Number(info.queuedAt) || 0)) { continue; } batchRegistry.set(key, info); } saveStorage(); } } } catch (e) {} } // Queue cards are virtualized/reused by the Vue queue. A card can keep the // same DOM element when it moves from pending -> running or when the next batch // starts, so the old batch line must be repainted after Vue has updated its // data-job-id. A single delayed update can race the async /jobs refresh; poll // briefly after each queue event and let the MutationObserver below catch the // actual attribute update as well. let batchVisualRefreshToken = 0; function scheduleBatchVisualRefresh(durationMs = 1200) { const token = ++batchVisualRefreshToken; const deadline = Date.now() + durationMs; const refresh = () => { if (token !== batchVisualRefreshToken) return; updateQueueBatchVisuals(); if (Date.now() < deadline) { setTimeout(refresh, 200); } }; refresh(); } // Refresh visual lines in DOM for both v1 and v2 async function updateQueueBatchVisuals() { if (!isBatchQueueEnabled()) { document.querySelectorAll(".saturnnodes-batch-line-wrap").forEach(el => el.remove()); return; } // Find all visible queue item elements in DOM (supports v2 job cards and v1 rows) const elements = Array.from(document.querySelectorAll('[data-testid="job-assets-list"] [data-job-id], .comfy-table tr[data-job-id]')); if (!elements.length) return; // Elements are strictly in visual screen order from top to bottom. // Dedupe by prompt id: a duplicate row would otherwise skew the batch // grouping counts and make the line segments disagree with the list. const visiblePromptIds = []; const elMap = new Map(); const duplicateElements = []; for (const el of elements) { const pid = el.getAttribute("data-job-id"); if (!pid) continue; if (elMap.has(pid)) { // A duplicate row must not keep a batch line painted by an earlier // render either; only the first visible occurrence is authoritative. duplicateElements.push(el); continue; } visiblePromptIds.push(pid); elMap.set(pid, el); } if (!visiblePromptIds.length) return; for (const el of duplicateElements) { removeBatchLineFromElement(el); } // Group visible prompts by batch once (O(n)) instead of filtering the whole // visible list for every rendered card (O(n²)). const batchGroups = new Map(); for (const pid of visiblePromptIds) { const info = batchRegistry.get(pid); if (!info || !info.batchId) continue; if (!batchGroups.has(info.batchId)) batchGroups.set(info.batchId, []); batchGroups.get(info.batchId).push(pid); } // For every prompt on screen, calculate segment type based on visual screen order: // - Top-most card on screen for Batch A curves at the top (ā”Œ) // - Bottom-most card on screen for Batch A curves at the bottom (ā””) // - 1-item Batch curves at both top and bottom (() // - Anything in between connects with straight lines (│) for (const pid of visiblePromptIds) { const el = elMap.get(pid); if (!el) continue; const info = batchRegistry.get(pid); if (!info || !info.batchId) { removeBatchLineFromElement(el); continue; } const batchId = info.batchId; const color = normalizeBatchColor(info.color); const batchCount = Math.max(1, Number(info.batchCount) || 1); const rawItemIndex = Number.isInteger(info.itemIndex) ? info.itemIndex : 0; // Corrupt metadata must not produce a "middle" segment for an index // outside the batch (e.g. -1 or 99 in a 3-item batch). const itemIndex = Math.min(Math.max(0, rawItemIndex), batchCount - 1); // All visible items of this batch in top-to-bottom visual order const occurrencesOnScreen = batchGroups.get(batchId) || []; if (occurrencesOnScreen.length <= 1) { // Only 1 item of this batch currently visible on screen if (batchCount === 1) { renderBatchLineOnElement(el, "single", color); } else { // Batch originally had multiple items, but only 1 is currently visible/unexecuted if (itemIndex === 0) { renderBatchLineOnElement(el, "first", color); } else if (itemIndex === (batchCount - 1)) { renderBatchLineOnElement(el, "last", color); } else { renderBatchLineOnElement(el, "middle", color); } } continue; } const topPidOnScreen = occurrencesOnScreen[0]; const bottomPidOnScreen = occurrencesOnScreen[occurrencesOnScreen.length - 1]; if (pid === topPidOnScreen) { // Top card on screen -> curves at top (ā”Œ) renderBatchLineOnElement(el, "first", color); } else if (pid === bottomPidOnScreen) { // Bottom card on screen -> curves at bottom (ā””) renderBatchLineOnElement(el, "last", color); } else { // Middle cards on screen -> straight line (│) renderBatchLineOnElement(el, "middle", color); } } } let batchSubmissionQueue = Promise.resolve(); // Number of queue actions whose submission turn has not settled yet. Used to // detect that ComfyUI will serialize this prompt later instead of immediately. let pendingSubmissions = 0; function capturePromptAtClick() { // Serialize the graph now, while the canvas still shows what the user // clicked. ComfyUI's own loop calls graphToPrompt() much later when another // batch is still being submitted, so without this the text and LoRA // selection edited in the meantime bleed into the queued prompt. try { if (app && typeof app.graphToPrompt === "function") { return Promise.resolve(app.graphToPrompt()).catch(e => { console.warn("[SaturnNodes BatchQueue] Click-time prompt snapshot failed:", e); return null; }); } } catch (e) { console.warn("[SaturnNodes BatchQueue] Click-time prompt snapshot failed:", e); } return null; } // Hook queuePrompt to track batch execution and guard multi-item batches function setupQueueHooks() { if (setupQueueHooks._installed) return; setupQueueHooks._installed = true; loadStorage(); // 1. Hook app.queuePrompt to know when a batch is initiated if (app && typeof app.queuePrompt === "function") { const origQueuePrompt = app.queuePrompt.bind(app); app.queuePrompt = async function(number, batchCount = 1, queueNodeIds) { const count = Math.max(1, parseInt(batchCount, 10) || 1); // Only ComfyUI's own processing loop or this submission queue can // delay serialization. When idle, ComfyUI snapshots immediately, so // skipping the extra graphToPrompt() keeps single clicks cheap. const shouldCapture = isSnapshotGuardEnabled() && (pendingSubmissions > 0 || (app && app.processingQueue === true)); const clickPromptPromise = shouldCapture ? capturePromptAtClick() : null; pendingSubmissions++; // Chain onto batchSubmissionQueue to ensure sequential batch submissions const turn = batchSubmissionQueue.then(async () => { const batch = getNextBatch(count); if (clickPromptPromise) { batch.clickPromptPromise = clickPromptPromise; } activeBatchContext = batch; saveStorage(); try { return await origQueuePrompt(number, batchCount, queueNodeIds); } finally { activeBatchContext = null; } }); batchSubmissionQueue = turn.catch(() => {}); turn.then( () => { pendingSubmissions = Math.max(0, pendingSubmissions - 1); }, () => { pendingSubmissions = Math.max(0, pendingSubmissions - 1); } ); return turn; }; } // 2. Hook api.queuePrompt to record every generated prompt_id & enforce snapshot guard if (api && typeof api.queuePrompt === "function") { const origApiQueuePrompt = api.queuePrompt.bind(api); api.queuePrompt = async function(number, prompt, targets) { let promptToSend = prompt; // Reserve the item index before the request is sent; concurrent // submissions must not race on the post-response queue length. if (!activeBatchContext) { activeBatchContext = getNextBatch(1); } // Capture this request's context. A concurrent request may finish // the batch and clear the global reference while we are awaiting // the server. const batchContext = activeBatchContext; if (!Number.isInteger(batchContext.nextItemIndex)) { batchContext.nextItemIndex = 0; } if (!Number.isInteger(batchContext.inFlight)) { batchContext.inFlight = 0; } const itemIndex = batchContext.nextItemIndex++; batchContext.inFlight++; // Batch Graph Snapshot Guard: // Freeze the prompt the user clicked instead of the live canvas. // Multi-item batches additionally reuse that snapshot for every // later item so canvas edits cannot bleed into the rest of the batch. if (isSnapshotGuardEnabled()) { let clickPrompt = null; if (itemIndex === 0 && batchContext.clickPromptPromise) { try { clickPrompt = await batchContext.clickPromptPromise; } catch (e) { clickPrompt = null; } batchContext.clickPromptPromise = null; } if (itemIndex === 0) { if (clickPrompt && clickPrompt.output) { try { batchContext.snapshotPrompt = JSON.parse(JSON.stringify(clickPrompt)); } catch (e) { console.warn("[SaturnNodes BatchQueue] Failed to snapshot prompt:", e); } // Keep randomized seeds from the live prompt so the // generated image matches the seed the UI queued. promptToSend = mergeDynamicSeeds(batchContext.snapshotPrompt, prompt); } else if (batchContext.batchCount > 1) { try { batchContext.snapshotPrompt = JSON.parse(JSON.stringify(prompt)); } catch (e) { console.warn("[SaturnNodes BatchQueue] Failed to snapshot prompt:", e); } } } else if (batchContext.snapshotPrompt) { promptToSend = mergeDynamicSeeds(batchContext.snapshotPrompt, prompt); } } let res; try { res = await origApiQueuePrompt(number, promptToSend, targets); } catch (e) { batchContext.inFlight--; if (activeBatchContext === batchContext && batchContext.nextItemIndex >= batchContext.batchCount && batchContext.inFlight === 0) { activeBatchContext = null; } throw e; } batchContext.inFlight--; if (res && res.prompt_id) { const pid = String(res.prompt_id); const batchInfo = { prompt_id: pid, batchId: batchContext.batchId, batchNumber: batchContext.batchNumber, batchCount: batchContext.batchCount, itemIndex: itemIndex, color: batchContext.color, colorIndex: batchContext.colorIndex, isQueueFront: number < 0, queuedAt: Date.now() }; batchContext.prompts.push(pid); batchRegistry.set(pid, batchInfo); saveStorage(); syncBatchToServer(pid, batchInfo); // If this batch completed its expected prompt count, clear context if (activeBatchContext === batchContext && batchContext.nextItemIndex >= batchContext.batchCount && batchContext.inFlight === 0) { activeBatchContext = null; } scheduleBatchVisualRefresh(600); } return res; }; } } app.registerExtension({ name: "ComfyUI.SaturnNodes.BatchQueue", async setup() { injectBatchStyles(); setupQueueHooks(); loadBatchFromServer().then(() => scheduleBatchVisualRefresh(1200)); // Listen for queue changes if (api && api.addEventListener) { const refreshAfterQueueEvent = () => scheduleBatchVisualRefresh(); api.addEventListener("status", refreshAfterQueueEvent); api.addEventListener("execution_start", refreshAfterQueueEvent); api.addEventListener("executing", refreshAfterQueueEvent); api.addEventListener("execution_success", refreshAfterQueueEvent); api.addEventListener("execution_error", refreshAfterQueueEvent); api.addEventListener("execution_interrupted", refreshAfterQueueEvent); } // Set up MutationObserver on body to detect queue sidebar rendering. // `attributes` is essential: Vue reuses a running card and only updates // its data-job-id when the next batch item starts. Without watching that // attribute, the old batch line can remain painted on the reused card. // `addedNodes` is inspected because a legacy .comfy-table can be // inserted with its rows already built, producing no later mutation // inside the table. const QUEUE_MUTATION_SELECTOR = '[data-testid="job-assets-list"], .comfy-table, [data-job-id]'; const mutationTouchesQueue = (m) => { if (m.target && m.target.nodeType === 1) { if (m.target.closest?.(QUEUE_MUTATION_SELECTOR) || m.target.matches?.(QUEUE_MUTATION_SELECTOR)) { return true; } } for (const node of m.addedNodes || []) { if (node.nodeType !== 1) continue; if (node.matches?.(QUEUE_MUTATION_SELECTOR) || node.querySelector?.(QUEUE_MUTATION_SELECTOR)) { return true; } } return false; }; let debounceTimer = null; const observer = new MutationObserver((mutations) => { let shouldUpdate = false; for (const m of mutations) { if (mutationTouchesQueue(m)) { shouldUpdate = true; break; } } if (shouldUpdate) { clearTimeout(debounceTimer); debounceTimer = setTimeout(updateQueueBatchVisuals, 60); } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-job-id"], }); // Initial checks after load, including the async /jobs hydration. scheduleBatchVisualRefresh(2500); } }); export { BATCH_COLORS, buildSvgPath };