Files
KOFiblto-ComfyUI-SaturnNodes/web/batch_queue.js
T

426 lines
14 KiB
JavaScript

import { app } from "/scripts/app.js";
import { api } from "/scripts/api.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 = "leafflow_batch_queue_meta";
const BATCH_COUNTER_KEY = "leafflow_batch_counter";
const SETTING_ID = "LeafFlow.BatchQueue.Enabled";
// 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) {
batchCounter = parseInt(savedCounter, 10) || 0;
}
} catch (e) {
console.warn("[LeafFlow BatchQueue] Failed to load batch storage:", e);
}
}
function saveStorage() {
try {
// Prune entries to last 250 items to keep localStorage small
const allEntries = Array.from(batchRegistry.values());
const pruned = allEntries.slice(-250);
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 = "leafflow-batch-queue-styles";
if (document.getElementById(styleId)) return;
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
/* LeafFlow 1D Git-Graph Batch Line Container */
.leafflow-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;
}
.leafflow-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);
}
// 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(".leafflow-batch-line-wrap");
if (!wrap) {
wrap = document.createElement("div");
wrap.className = "leafflow-batch-line-wrap";
element.appendChild(wrap);
}
const dPath = buildSvgPath(segmentType);
wrap.innerHTML = `
<svg class="leafflow-batch-line-svg" viewBox="0 0 10 48" preserveAspectRatio="none">
<path d="${dPath}" stroke="${color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
`;
}
function removeBatchLineFromElement(element) {
if (!element) return;
const wrap = element.querySelector(".leafflow-batch-line-wrap");
if (wrap) {
wrap.remove();
}
}
// Check if LeafFlow Batch Queue is enabled in settings
function isBatchQueueEnabled() {
try {
if (app.extensionManager?.setting?.get) {
return app.extensionManager.setting.get(SETTING_ID) !== false;
}
} catch (e) {}
return true;
}
// Sync with server if PersistentQueue is available
async function syncBatchToServer(promptId, batchInfo) {
try {
if (!api || !api.fetchApi) return;
await api.fetchApi("/leafflow/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("/leafflow/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) {
batchRegistry.set(String(pid), info);
}
}
saveStorage();
}
}
} catch (e) {}
}
// Refresh visual lines in DOM for both v1 and v2
async function updateQueueBatchVisuals() {
if (!isBatchQueueEnabled()) {
document.querySelectorAll(".leafflow-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
const visiblePromptIds = elements.map(el => el.getAttribute("data-job-id")).filter(Boolean);
// Build map of promptId -> element for visible items
const elMap = new Map();
for (const el of elements) {
const pid = el.getAttribute("data-job-id");
if (pid) elMap.set(pid, el);
}
// 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 = info.color || BATCH_COLORS[0];
// Find all visible items of this batch in top-to-bottom visual order
const occurrencesOnScreen = visiblePromptIds.filter(p => {
const b = batchRegistry.get(p);
return b && b.batchId === batchId;
});
if (occurrencesOnScreen.length <= 1) {
// Only 1 item of this batch currently visible on screen
if ((info.batchCount || 1) === 1) {
renderBatchLineOnElement(el, "single", color);
} else {
// Batch originally had multiple items, but only 1 is currently visible/unexecuted
if (info.itemIndex === 0) {
renderBatchLineOnElement(el, "first", color);
} else if (info.itemIndex === (info.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);
}
}
}
// Hook queuePrompt to track batch execution
function setupQueueHooks() {
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);
activeBatchContext = getNextBatch(count);
saveStorage();
return origQueuePrompt(number, batchCount, queueNodeIds);
};
}
// 2. Hook api.queuePrompt to record every generated prompt_id
if (api && typeof api.queuePrompt === "function") {
const origApiQueuePrompt = api.queuePrompt.bind(api);
api.queuePrompt = async function(number, prompt, targets) {
const res = await origApiQueuePrompt(number, prompt, targets);
if (res && res.prompt_id) {
const pid = String(res.prompt_id);
if (!activeBatchContext) {
activeBatchContext = getNextBatch(1);
}
const itemIndex = activeBatchContext.prompts.length;
const batchInfo = {
prompt_id: pid,
batchId: activeBatchContext.batchId,
batchNumber: activeBatchContext.batchNumber,
batchCount: activeBatchContext.batchCount,
itemIndex: itemIndex,
color: activeBatchContext.color,
colorIndex: activeBatchContext.colorIndex,
isQueueFront: number < 0,
queuedAt: Date.now()
};
activeBatchContext.prompts.push(pid);
batchRegistry.set(pid, batchInfo);
saveStorage();
syncBatchToServer(pid, batchInfo);
// If this batch completed its expected prompt count, clear context
if (activeBatchContext.prompts.length >= activeBatchContext.batchCount) {
activeBatchContext = null;
}
setTimeout(updateQueueBatchVisuals, 100);
}
return res;
};
}
}
app.registerExtension({
name: "ComfyUI.LeafFlow.BatchQueue",
async setup() {
injectBatchStyles();
setupQueueHooks();
loadBatchFromServer();
// Listen for queue changes
if (api && api.addEventListener) {
api.addEventListener("status", () => {
setTimeout(updateQueueBatchVisuals, 100);
});
api.addEventListener("execution_start", () => {
setTimeout(updateQueueBatchVisuals, 80);
});
api.addEventListener("executing", () => {
setTimeout(updateQueueBatchVisuals, 150);
});
}
// Set up MutationObserver on body to detect queue sidebar rendering
let debounceTimer = null;
const observer = new MutationObserver((mutations) => {
let shouldUpdate = false;
for (const m of mutations) {
if (m.target && m.target.nodeType === 1) {
if (m.target.closest?.('[data-testid="job-assets-list"], .comfy-table') ||
m.target.matches?.('[data-testid="job-assets-list"], [data-job-id]')) {
shouldUpdate = true;
break;
}
}
}
if (shouldUpdate) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(updateQueueBatchVisuals, 60);
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Initial check after load
setTimeout(updateQueueBatchVisuals, 800);
setTimeout(updateQueueBatchVisuals, 2000);
}
});
export { BATCH_COLORS, buildSvgPath };