import { app } from "/scripts/app.js"; import { api } from "/scripts/api.js"; import { PreviewManager } from "./js/preview_manager.js"; import { resolveLiveElement } from "./js/live_dom.js"; // Global CSS injection to guarantee zero-expansion in Vue Node 2.0 and LiteGraph if (typeof document !== "undefined") { const styleId = "saturnnodes-live-preview-styles"; if (!document.getElementById(styleId)) { const style = document.createElement("style"); style.id = styleId; style.textContent = ` .saturnnodes-live-preview-container { position: absolute !important; inset: 0 !important; top: 70px !important; bottom: 40px !important; left: 8px !important; right: 8px !important; width: auto !important; height: auto !important; min-width: 0 !important; min-height: 0 !important; max-width: none !important; max-height: none !important; display: flex !important; align-items: center !important; justify-content: center !important; overflow: hidden !important; box-sizing: border-box !important; pointer-events: none !important; } .saturnnodes-live-preview-img { position: absolute !important; top: 0 !important; left: 0 !important; width: 100% !important; height: 100% !important; max-width: 100% !important; max-height: 100% !important; min-width: 0 !important; min-height: 0 !important; object-fit: contain !important; object-position: center !important; display: none; pointer-events: none !important; } [node-type="PreviewLatentLive"] { min-height: 0 !important; min-width: 0 !important; } [node-type="PreviewLatentLive"] .saturnnodes-live-preview-container { position: absolute !important; } `; document.head.appendChild(style); } } function isSamplerNode(type = "", title = "") { const lowerType = String(type).toLowerCase(); const lowerTitle = String(title).toLowerCase(); return lowerType.includes("sampler") || lowerType.includes("ksampler") || lowerType.includes("sample") || lowerType.includes("denoise") || lowerTitle.includes("sampler") || lowerTitle.includes("ksampler") || lowerTitle.includes("sample") || lowerTitle.includes("denoise"); } function getKSamplerNodes() { const samplers = []; const visitedNodes = new Set(); const checkNode = (node, prefix = "") => { if (!node) return; const idStr = String(node.id ?? ""); if (!idStr || visitedNodes.has(idStr)) return; const type = node.type || node.comfyClass || ""; const title = node.title || node.type || `Node ${idStr}`; if (isSamplerNode(type, title)) { visitedNodes.add(idStr); const displayTitle = prefix ? `${prefix} > ${title}` : title; samplers.push({ id: idStr, title: displayTitle }); } // Deep subgraph / group exploration const sub = node.subgraph || node.inner_graph || (typeof node.getInnerGraph === "function" ? node.getInnerGraph() : null) || node.sub_nodes || node.nodes; if (sub) { traverseGraph(sub, title); } }; const traverseGraph = (graph, prefix = "") => { if (!graph) return; let nodesList = []; if (Array.isArray(graph._nodes)) { nodesList = graph._nodes; } else if (Array.isArray(graph.nodes)) { nodesList = graph.nodes; } else if (graph._nodes_by_id) { nodesList = Object.values(graph._nodes_by_id); } else if (typeof graph.values === "function") { nodesList = Array.from(graph.values()); } for (const node of nodesList) { checkNode(node, prefix); } }; if (app) { if (app.graph) traverseGraph(app.graph); if (app.rootGraph && app.rootGraph !== app.graph) traverseGraph(app.rootGraph); if (app.canvas?.graph && app.canvas.graph !== app.graph) traverseGraph(app.canvas.graph); } // Comprehensive DOM search for any rendered nodes in canvas (Vue Node 2.0 and LiteGraph) if (typeof document !== "undefined") { document.querySelectorAll('[data-node-id], [data-widgets-grid-node-id], .lg-node, [node-id]').forEach(el => { const id = el.dataset.nodeId || el.dataset.widgetsGridNodeId || el.getAttribute('data-node-id') || el.getAttribute('node-id'); if (!id || visitedNodes.has(String(id))) return; const headerEl = el.querySelector('[data-testid^="node-header-"], [data-testid="node-title"], .lg-node-header, .node-title'); const text = (headerEl ? headerEl.innerText : el.innerText) || ""; if (isSamplerNode("", text)) { visitedNodes.add(String(id)); const titleMatch = text.match(/KSampler[^\n]*/i) || text.match(/Sampler[^\n]*/i) || text.match(/[^\n]+/); let title = titleMatch ? titleMatch[0].trim() : `Sampler (${id})`; if (title.length > 45) title = title.substring(0, 42) + "..."; samplers.push({ id: String(id), title }); } }); } return samplers; } function updateAllPreviewNodeDropdowns() { if (!app || !app.graph) return; const previewNodes = []; const collectPreviewNodes = (g) => { if (!g) return; const list = g._nodes || g.nodes || (g._nodes_by_id ? Object.values(g._nodes_by_id) : []); if (Array.isArray(list)) { for (const n of list) { if (n && n.type === "PreviewLatentLive") previewNodes.push(n); if (n && n.subgraph) collectPreviewNodes(n.subgraph); } } }; collectPreviewNodes(app.graph); if (app.rootGraph && app.rootGraph !== app.graph) collectPreviewNodes(app.rootGraph); const currentNodes = getKSamplerNodes(); const currentValues = ["Auto", ...currentNodes.map(n => `${n.title} (${n.id})`)]; for (const node of previewNodes) { const widget = node.widgets?.find(w => w.name === "Source"); if (widget) { const currentVal = widget.value; widget.options = widget.options || {}; widget.options.values = [...currentValues]; if (currentVal && currentVal !== "Auto" && !currentValues.includes(currentVal)) { widget.options.values.push(currentVal); } if (typeof node.setDirtyCanvas === "function") { node.setDirtyCanvas(true, true); } } } } app.registerExtension({ name: "ComfyUI.SaturnNodes.PreviewLatentLive", async setup() { PreviewManager.init(app, api); api.addEventListener("status", () => { updateAllPreviewNodeDropdowns(); }); if (typeof document !== "undefined") { document.addEventListener("pointerdown", (e) => { if (e.target && (e.target.closest?.('[data-testid="widget-select-default-trigger"]') || e.target.closest?.('.lg-node') || e.target.closest?.('[data-node-id]'))) { updateAllPreviewNodeDropdowns(); } }, { passive: true }); } }, async loadedGraph() { setTimeout(updateAllPreviewNodeDropdowns, 200); setTimeout(updateAllPreviewNodeDropdowns, 1000); }, async nodeCreated(node) { if (node && node.type === "PreviewLatentLive") { setTimeout(updateAllPreviewNodeDropdowns, 100); } }, async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name === "PreviewLatentLive") { const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function() { if (onNodeCreated) { onNodeCreated.apply(this, arguments); } let widget = this.widgets?.find(w => w.name === "Source"); if (!widget) { widget = this.addWidget("combo", "Source", "Auto", (value) => {}, { values: ["Auto"] }); } widget.callback = function() { updateAllPreviewNodeDropdowns(); }; this.size = [200, 200]; this.min_size = [80, 80]; this.resizable = true; // Disallow automatic image aspect ratio resizing this.setSizeForImage = function() {}; Object.defineProperty(this, "aspect_ratio", { get() { return false; }, set(v) {}, configurable: true }); this.imgs = []; this.imageIndex = 0; const viewContainer = document.createElement("div"); viewContainer.className = "saturnnodes-live-preview-container"; viewContainer.style.position = "absolute"; viewContainer.style.inset = "0"; viewContainer.style.top = "70px"; viewContainer.style.bottom = "40px"; viewContainer.style.left = "8px"; viewContainer.style.right = "8px"; viewContainer.style.display = "flex"; viewContainer.style.alignItems = "center"; viewContainer.style.justifyContent = "center"; viewContainer.style.backgroundColor = "transparent"; viewContainer.style.color = "#888"; viewContainer.style.fontFamily = "Inter, sans-serif"; viewContainer.style.fontSize = "13px"; viewContainer.style.overflow = "hidden"; viewContainer.style.boxSizing = "border-box"; viewContainer.style.pointerEvents = "none"; const fallbackText = document.createElement("div"); fallbackText.className = "saturnnodes-live-preview-fallback"; fallbackText.innerText = "Waiting for live preview..."; viewContainer.appendChild(fallbackText); const imgElement = document.createElement("img"); imgElement.className = "saturnnodes-live-preview-img"; imgElement.style.position = "absolute"; imgElement.style.top = "0"; imgElement.style.left = "0"; imgElement.style.width = "100%"; imgElement.style.height = "100%"; imgElement.style.maxWidth = "100%"; imgElement.style.maxHeight = "100%"; imgElement.style.minWidth = "0"; imgElement.style.minHeight = "0"; imgElement.style.objectFit = "contain"; imgElement.style.objectPosition = "center"; imgElement.style.display = "none"; imgElement.style.pointerEvents = "none"; viewContainer.appendChild(imgElement); this.previewImgElement = imgElement; this.previewFallbackText = fallbackText; this.addDOMWidget("preview_display", "HTML", viewContainer, { getValue() { return ""; }, setValue(val) {}, serialize: false, computeSize() { return [0, 0]; } }); PreviewManager.registerNode(this); setTimeout(updateAllPreviewNodeDropdowns, 100); }; nodeType.prototype.computeSize = function() { return [80, 80]; }; nodeType.prototype.setSizeForImage = function() {}; nodeType.prototype.onResize = function(size) { if (size) { this.size[0] = Math.max(80, size[0]); this.size[1] = Math.max(80, size[1]); } }; nodeType.prototype.onDrawBackground = function(ctx) { if (this.flags.collapsed) return; if (this.imgs && this.imgs.length) { const img = this.imgs[this.imageIndex || 0]; if (!img || !img.complete || (img.naturalWidth === 0 && img.width === 0)) return; const margin = 8; const topY = 50; const maxW = Math.max(10, this.size[0] - margin * 2); const maxH = Math.max(10, this.size[1] - topY - margin); if (maxW <= 0 || maxH <= 0) return; const w = img.naturalWidth || img.width; const h = img.naturalHeight || img.height; const imgAspect = w / h; let drawW = maxW; let drawH = maxW / imgAspect; if (drawH > maxH) { drawH = maxH; drawW = maxH * imgAspect; } const drawX = margin + (maxW - drawW) / 2; const drawY = topY + (maxH - drawH) / 2; ctx.drawImage(img, drawX, drawY, drawW, drawH); } }; const onRemoved = nodeType.prototype.onRemoved; nodeType.prototype.onRemoved = function() { if (onRemoved) { onRemoved.apply(this, arguments); } PreviewManager.unregisterNode(this); }; nodeType.prototype.onNewPreview = function(img, samplerId) { updateAllPreviewNodeDropdowns(); const sourceWidget = this.widgets?.find(w => w.name === "Source"); const selectedSource = sourceWidget ? sourceWidget.value : "Auto"; if (selectedSource !== "Auto") { const match = selectedSource.match(/\(([^)]+)\)$/); if (match) { const targetId = match[1]; if (samplerId && String(samplerId) !== String(targetId)) { return; } } } this.imgs = [img]; this.imageIndex = 0; if (typeof document !== "undefined") { const domImgs = document.querySelectorAll(`[data-node-id="${this.id}"] .saturnnodes-live-preview-img, [data-widgets-grid-node-id="${this.id}"] .saturnnodes-live-preview-img`); if (domImgs.length > 0) { domImgs.forEach(imgEl => { imgEl.src = img.src; imgEl.style.display = "block"; const fallback = imgEl.parentElement?.querySelector(".saturnnodes-live-preview-fallback"); if (fallback) fallback.style.display = "none"; }); } } if (this.previewImgElement && this.previewFallbackText && img && img.src) { // The Vue node renderer can remount the DOM widget while the // node object keeps its cached element references. Resolve the // element that is actually mounted for this node before writing. const liveImg = resolveLiveElement(this.previewImgElement, () => { if (this.id === undefined || this.id === null) return null; return document.querySelector( `[data-node-id="${this.id}"] .saturnnodes-live-preview-img, ` + `[data-widgets-grid-node-id="${this.id}"] .saturnnodes-live-preview-img` ); }); if (liveImg) this.previewImgElement = liveImg; const liveFallback = resolveLiveElement(this.previewFallbackText, () => liveImg?.parentElement?.querySelector?.(".saturnnodes-live-preview-fallback") ); if (liveFallback) this.previewFallbackText = liveFallback; if (liveFallback) liveFallback.style.display = "none"; if (liveImg) { liveImg.src = img.src; liveImg.style.display = "block"; } } if (app.graph && typeof app.graph.setDirtyCanvas === "function") { app.graph.setDirtyCanvas(true, true); } }; } } });