diff --git a/js/queue.js b/js/queue.js index 49d8354..39486be 100644 --- a/js/queue.js +++ b/js/queue.js @@ -1,7 +1,7 @@ import { app } from "/scripts/app.js"; import { api } from "/scripts/api.js"; import { State, promptStates, pruneHistory, cardElements, saveStatesToLocalStorage } from "./state.js"; -import { findImagesInOutputs, findTextsInOutputs } from "./utils.js"; +import { findImagesInOutputs, findTextsInOutputs, parseWorkflow, getPrimaryOutputImages } from "./utils.js"; export let renderDOMFn = () => {}; export let updateSidebarBadgeFn = () => {}; @@ -13,6 +13,7 @@ export function setUIDependencies(renderFn, badgeFn) { export async function syncQueue() { try { const q = await api.getQueue(); + const runningList = q.Running || q.queue_running || []; const pendingList = q.Pending || q.queue_pending || []; const pendingIds = new Set(); @@ -26,7 +27,7 @@ export async function syncQueue() { pid = p.prompt_id || p.id || p.uuid; seq = typeof p.number === 'number' ? p.number : (typeof p.prompt_number === 'number' ? p.prompt_number : idx); } - return { pid, seq, original: p }; + return { pid: pid ? String(pid) : null, seq, original: p }; }); normalizedPending.sort((a, b) => a.seq - b.seq); @@ -65,10 +66,11 @@ export async function syncQueue() { } const concludeRun = async (pid, statusStr) => { - if (!pid || !promptStates.has(pid)) return; - if (State.currentlyActivePromptId === pid) State.currentlyActivePromptId = null; + const key = String(pid); + if (!key || !promptStates.has(key)) return; + if (State.currentlyActivePromptId === key) State.currentlyActivePromptId = null; - const st = promptStates.get(pid); + const st = promptStates.get(key); if (st._previewBlobUrl) { try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){} @@ -85,19 +87,28 @@ const concludeRun = async (pid, statusStr) => { st.endTime = Date.now(); if (st.startTime) st.duration = (st.endTime - st.startTime) / 1000; - try { - const res = await fetch(`/history/${pid}`); - const hItem = await res.json(); - if (hItem && hItem[pid]) { - if (!st.workflow) st.workflow = hItem[pid].extra_data?.extra_pnginfo?.workflow || null; - st.nodeOutputs = hItem[pid].outputs; - if (st.images.length === 0) st.images = findImagesInOutputs(hItem[pid].outputs, st.workflow); - st.texts = findTextsInOutputs(hItem[pid].outputs, st.workflow); - } - } catch (err) {} + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch(`/history/${key}`); + const hItem = await res.json(); + if (hItem && hItem[key]) { + const rawWf = hItem[key].extra_data?.extra_pnginfo?.workflow || hItem[key].prompt?.[3]?.extra_pnginfo?.workflow || null; + st.workflow = parseWorkflow(rawWf) || st.workflow; + st.nodeOutputs = hItem[key].outputs; + + const primaryImgs = getPrimaryOutputImages(hItem[key].outputs, st.workflow); + if (primaryImgs.length > 0) st.images = primaryImgs; + + const fetchedTexts = findTextsInOutputs(hItem[key].outputs, st.workflow); + if (fetchedTexts.length > 0) st.texts = fetchedTexts; + break; + } + } catch (err) {} + await new Promise(r => setTimeout(r, 200)); + } pruneHistory(app); - saveStatesToLocalStorage(); // Persist history state only when a job concludes + saveStatesToLocalStorage(); syncQueue(); }; @@ -119,7 +130,7 @@ export function setupApiListeners() { } } - const pid = e.detail.prompt_id; + const pid = String(e.detail.prompt_id); State.currentlyActivePromptId = pid; const activeWorkspaceWorkflow = app.graph.serialize(); @@ -140,12 +151,11 @@ export function setupApiListeners() { }); api.addEventListener("progress", (e) => { - const pid = e.detail.prompt_id; + const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null; if (pid && promptStates.has(pid)) { const st = promptStates.get(pid); st.progress = Math.round((e.detail.value / e.detail.max) * 100); - // Direct fast DOM update for the progress bar without triggering a full history re-sort const cardObj = cardElements.get(pid); if (cardObj && cardObj.progressBar) { cardObj.progressBar.style.width = `${st.progress}%`; @@ -181,7 +191,6 @@ export function setupApiListeners() { const st = activeTasks[0]; const newBlobUrl = URL.createObjectURL(e.detail); - // Retain old blob URL so ui.js can revoke it ONLY AFTER the image element finishes loading st._oldPreviewBlobUrl = st._previewBlobUrl; st._previewBlobUrl = newBlobUrl; st.images = [{ url: newBlobUrl }]; @@ -190,15 +199,16 @@ export function setupApiListeners() { }); api.addEventListener("executed", (e) => { - if (promptStates.has(e.detail.prompt_id)) { - const st = promptStates.get(e.detail.prompt_id); - const finalImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, st.workflow); - if (finalImgs.length > 0) { - st.images = finalImgs; + const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null; + if (pid && promptStates.has(pid)) { + const st = promptStates.get(pid); + const nodeImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, st.workflow); + if (nodeImgs.length > 0) { + st.images = nodeImgs; } - const finalTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow); - if (finalTexts.length > 0) { - st.texts = finalTexts; + const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow); + if (nodeTexts.length > 0) { + st.texts = nodeTexts; } if (!st.nodeOutputs) st.nodeOutputs = {}; @@ -217,42 +227,63 @@ export function setupApiListeners() { } export async function initSessionAndHistory() { - let backendSessionId = null; try { - const res = await fetch("/classic-sidebar/session"); - const data = await res.json(); - backendSessionId = data.session_id; - } catch (err) { - console.warn("Comfy Sidebar: Custom session endpoint not found. Queue persistence will fall back to cache."); - } + const historyData = await api.getHistory(); + if (!historyData) return; - const storedSessionId = localStorage.getItem("comfy_sidebar_backend_session_id"); - if (backendSessionId && backendSessionId !== storedSessionId) { - promptStates.clear(); - cardElements.clear(); - localStorage.removeItem("comfy_sidebar_prompt_states"); - if (backendSessionId) localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId); - } else if (!storedSessionId && backendSessionId) { - localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId); - } + const rawItems = []; + if (Array.isArray(historyData.History)) { + historyData.History.forEach(item => { + if (item && item.prompt_id) rawItems.push({ pid: String(item.prompt_id), data: item }); + }); + } else if (typeof historyData === 'object') { + Object.keys(historyData).forEach(pidKey => { + const item = historyData[pidKey]; + rawItems.push({ pid: String(pidKey), data: Array.isArray(item) ? { prompt: item[0], outputs: item[1], status: item[2] } : item }); + }); + } - const historyData = await api.getHistory(); - const ids = Object.keys(historyData).sort((a,b) => Number(a)-Number(b)); - ids.forEach(id => { - if (promptStates.has(id)) return; - const workflow = historyData[id].extra_data?.extra_pnginfo?.workflow || null; - const images = findImagesInOutputs(historyData[id].outputs, workflow); - const texts = findTextsInOutputs(historyData[id].outputs, workflow); - if (images.length === 0 && texts.length === 0) return; - - State.globalOrderCounter++; - promptStates.set(id, { - pid: id, status: "completed", images, texts, - nodeOutputs: historyData[id].outputs, - workflow: workflow, - progressText: "", timestamp: State.globalOrderCounter, rendered: true + rawItems.sort((a, b) => { + const numA = a.data.prompt?.[0] ?? 0; + const numB = b.data.prompt?.[0] ?? 0; + return numA - numB; }); - }); - pruneHistory(app); - await syncQueue(); + + rawItems.forEach(({ pid, data }) => { + if (promptStates.has(pid)) return; + + const extraData = data.extra_data || data.prompt?.[3] || {}; + const workflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow); + const outputs = data.outputs || {}; + + const images = getPrimaryOutputImages(outputs, workflow); + const texts = findTextsInOutputs(outputs, workflow); + + const statusStr = data.status?.status_str || "completed"; + const status = (statusStr === "success" || statusStr === "completed") ? "completed" : + (statusStr === "error" ? "error" : "cancelled"); + + // Filter out empty records from history so dummy "No Outputs" cards don't pollute queue + if (images.length === 0 && texts.length === 0 && status === "completed") return; + + State.globalOrderCounter++; + promptStates.set(pid, { + pid: pid, + status: status, + images: images, + texts: texts, + nodeOutputs: outputs, + workflow: workflow, + progressText: "", + timestamp: State.globalOrderCounter, + rendered: true + }); + }); + + pruneHistory(app); + saveStatesToLocalStorage(); + await syncQueue(); + } catch (err) { + console.error("Comfy Sidebar: Failed to initialize history from server API", err); + } } \ No newline at end of file diff --git a/js/state.js b/js/state.js index 212b8f7..c9015db 100644 --- a/js/state.js +++ b/js/state.js @@ -12,11 +12,9 @@ export const State = { activeSubmenuBatchImages: null // Active batch images explorer pointer }; -/** - * Safely removes a prompt state from memory and revokes any active preview blob URLs. - */ export function deletePromptState(pid) { - const state = promptStates.get(pid); + const key = String(pid); + const state = promptStates.get(key); if (state) { if (state._previewBlobUrl) { try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {} @@ -25,13 +23,10 @@ export function deletePromptState(pid) { try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {} } } - promptStates.delete(pid); - cardElements.delete(pid); + promptStates.delete(key); + cardElements.delete(key); } -/** - * Prunes completed/cancelled history when total items exceed user setting. - */ export function pruneHistory(app) { const maxItems = app.ui.settings.getSettingValue("Comfy.Queue.MaxHistoryItems") ?? 64; const tasks = Array.from(promptStates.entries()) @@ -48,10 +43,6 @@ export function pruneHistory(app) { } } -/** - * Saves all prompt states (including workflows, text outputs, video runs, and failed tasks) to localStorage. - * Call ONLY on job completion, cancellation, error, or card deletion. - */ export function saveStatesToLocalStorage() { try { let serializable = []; @@ -61,13 +52,17 @@ export function saveStatesToLocalStorage() { return img; }).filter(Boolean); + // Include workflow JSON ONLY for image-less / unfinished / failed tasks + // so users can load or save their workflows even after browser reloads! + const includeWorkflow = cleanedImages.length === 0 || state.status !== "completed"; + serializable.push({ - pid: state.pid, + pid: String(state.pid), status: state.status, images: cleanedImages, texts: state.texts || [], nodeOutputs: state.nodeOutputs, - workflow: state.workflow, + workflow: includeWorkflow ? state.workflow : null, progress: state.progress || 0, queueNumber: state.queueNumber, progressText: state.progressText || "", @@ -80,6 +75,11 @@ export function saveStatesToLocalStorage() { }); } + if (serializable.length === 0) { + localStorage.removeItem("comfy_sidebar_prompt_states"); + return; + } + serializable.sort((a, b) => a.timestamp - b.timestamp); while (serializable.length > 0) { @@ -90,32 +90,25 @@ export function saveStatesToLocalStorage() { if (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014) { serializable.shift(); } else { - console.error("Comfy Sidebar: Failed to save state to localStorage", e); break; } } } - } catch (e) { - console.error("Comfy Sidebar: Error preparing localStorage data", e); - } + } catch (e) {} } -/** - * Loads cached card states on extension initialization. - */ export function loadStatesFromLocalStorage() { try { const data = localStorage.getItem("comfy_sidebar_prompt_states"); if (data) { const list = JSON.parse(data); list.forEach(state => { - promptStates.set(state.pid, state); + const key = String(state.pid); + promptStates.set(key, state); if (state.timestamp > State.globalOrderCounter) State.globalOrderCounter = state.timestamp; }); } - } catch (e) { - console.error("Comfy Sidebar: Failed to load state from localStorage", e); - } + } catch (e) {} } loadStatesFromLocalStorage(); \ No newline at end of file diff --git a/js/ui.js b/js/ui.js index 824d4b0..4fb6d10 100644 --- a/js/ui.js +++ b/js/ui.js @@ -324,7 +324,6 @@ export function setupSidebarUI() { } }).observe(State.sidebarContainer); - // Light 250ms active timer tick setInterval(() => { if (State.currentlyActivePromptId === null) return; for (const [pid, state] of promptStates.entries()) { @@ -588,7 +587,6 @@ function renderCardImages(cardObj, state, keepAspect) { } else { mediaEl.onload = () => { applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight); - // Safely revoke old preview blob URL ONLY after new frame finishes rendering! if (state._oldPreviewBlobUrl) { try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch(e){} delete state._oldPreviewBlobUrl; diff --git a/js/utils.js b/js/utils.js index 9b09037..44b5c81 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,44 +1,65 @@ import { app } from "/scripts/app.js"; export const isVideoFormat = (url) => { - const s = url.toLowerCase(); + if (!url) return false; + const s = String(url).toLowerCase(); return s.includes(".mp4") || s.includes(".webm"); }; -function isNodeIgnored(nodeId, workflow) { - if (!workflow || !workflow.nodes) { - // Fallback to active graph if workflow is missing (e.g. during live execution) - const liveNode = app.graph?.getNodeById(Number(nodeId)); - if (liveNode) { - return !!(liveNode.properties && liveNode.properties.ignoreInQueue); - } +export function parseWorkflow(workflow) { + if (!workflow) return null; + if (typeof workflow === "object") return workflow; + if (typeof workflow === "string") { + try { return JSON.parse(workflow); } catch (e) { return null; } + } + return null; +} + +function isNodeIgnored(nodeId, rawWorkflow) { + const workflow = parseWorkflow(rawWorkflow); + if (!workflow || !Array.isArray(workflow.nodes)) { return false; } const node = workflow.nodes.find(n => String(n.id) === String(nodeId)); return !!(node && node.properties && node.properties.ignoreInQueue); } -// Robust media output scanner supporting both arrays and single objects (gifs, mp4s, etc.) -export function findImagesInOutputs(outputs, workflow) { +export function findImagesInOutputs(outputs, rawWorkflow) { const list = []; if (!outputs) return list; - for (const nodeId in outputs) { - if (isNodeIgnored(nodeId, workflow)) continue; - for (const key in outputs[nodeId]) { - const val = outputs[nodeId][key]; - if (Array.isArray(val)) { - val.forEach(item => { - if (item && typeof item === 'object' && item.filename) list.push(item); - }); - } else if (val && typeof val === 'object' && val.filename) { - list.push(val); + + const scan = (obj) => { + if (!obj) return; + if (Array.isArray(obj)) { + obj.forEach(item => scan(item)); + } else if (typeof obj === "object") { + if (obj.filename || obj.name) { + const fname = obj.filename || obj.name; + if (typeof fname === "string" && (fname.includes(".") || obj.type)) { + list.push({ + filename: fname, + subfolder: obj.subfolder || "", + type: obj.type || "output" + }); + return; + } + } + for (const key in obj) { + scan(obj[key]); } } + }; + + const workflow = parseWorkflow(rawWorkflow); + for (const nodeId in outputs) { + if (isNodeIgnored(nodeId, workflow)) continue; + scan(outputs[nodeId]); } return list; } -export function findTextsInOutputs(outputs, workflow) { +export function findTextsInOutputs(outputs, rawWorkflow) { + const workflow = parseWorkflow(rawWorkflow); const list = []; if (!outputs) return list; for (const nodeId in outputs) { @@ -65,8 +86,8 @@ export function findTextsInOutputs(outputs, workflow) { return list; } -// Organizes raw outputs dictionaries into lists of individual node-level output blocks (ignoring text-only outputs) -export function getRunOutputs(nodeOutputs, workflow) { +export function getRunOutputs(nodeOutputs, rawWorkflow) { + const workflow = parseWorkflow(rawWorkflow); const list = []; if (!nodeOutputs) return list; for (const nodeId in nodeOutputs) { @@ -78,6 +99,13 @@ export function getRunOutputs(nodeOutputs, workflow) { return list; } +export function getPrimaryOutputImages(nodeOutputs, rawWorkflow) { + if (!nodeOutputs) return []; + const runOutputs = getRunOutputs(nodeOutputs, rawWorkflow); + if (runOutputs.length === 0) return []; + return runOutputs[runOutputs.length - 1].images || []; +} + export function matchesFilter(state, query) { if (!query) return true; const q = query.toLowerCase(); @@ -87,8 +115,9 @@ export function matchesFilter(state, query) { if (state.images && state.images.some(img => (img.filename || "").toLowerCase().includes(q))) return true; if (state.activeNodeName && state.activeNodeName.toLowerCase().includes(q)) return true; - if (state.workflow && Array.isArray(state.workflow.nodes)) { - for (const node of state.workflow.nodes) { + const workflow = parseWorkflow(state.workflow); + if (workflow && Array.isArray(workflow.nodes)) { + for (const node of workflow.nodes) { if (node.title && node.title.toLowerCase().includes(q)) return true; if (node.type && node.type.toLowerCase().includes(q)) return true; if (Array.isArray(node.widgets)) { diff --git a/pyproject.toml b/pyproject.toml index 573dbdb..659c8ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfy-sidebar" description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI." -version = "1.2.9" +version = "1.2.10" license = { file = "LICENSE" } dependencies = []