From 600d2e99631144bf5afd134eebf0cbd1ce3f24eb Mon Sep 17 00:00:00 2001 From: Ken Simpson Date: Fri, 6 Nov 2026 14:56:13 -0700 Subject: [PATCH] Progressive preview: final grid display, image viewer, copy and save (#10) * Make the progressive grid the preview's final display, with an image viewer The slot grid now stays up after the run: the final executed images fill it and hideOutputImages stops ComfyUI from drawing a second native preview below. Clicking a slot enlarges it to fill the node, with prev/next buttons, wrap-around arrow-key navigation, and Esc or click to return. Outputs restored without an executed event are shown in the grid too. Co-Authored-By: Claude Opus 5.5 * Let users open, copy, and save images from the progressive preview Expose the slot images through node.imgs, with imageIndex following the enlarged or last-hovered slot, so ComfyUI's own Open/Copy/Save Image menu entries work as they do on PreviewImage. Replace this node's onDrawBackground so the classic canvas does not redraw the native preview, and add open, save, and copy buttons to the enlarged viewer. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- README.md | 4 +- tests/progressive_preview_frontend.test.mjs | 216 +++++++-- web/progressive_preview.js | 503 ++++++++++++++++++-- 3 files changed, 651 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index ca076f5..19682d1 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,9 @@ Mapped progress is summarized on the global status pill and a representative nod - When a mapped component starts, the node shows one empty slot per item. - As each remote item finishes, its images are streamed straight to the host and fill that item's slot. Slots fill in completion order, not item order. This does not wait for ComfyUI's scheduler, which only hands a node its input after every item has finished. -- When ComfyUI finally runs the node, it reuses the images it already received instead of downloading them again, and the node switches to the normal ComfyUI image preview. +- When ComfyUI finally runs the node, it reuses the images it already received instead of downloading them again. The final images fill the same grid, which remains the node's only display; ComfyUI's own image preview is not drawn below it. The images still appear in the queue and history. +- Click any slot to enlarge it to fill the node. Use the ‹ › buttons or the ← → keys to move between items; navigation wraps around. Press Esc, click the image, or use ✕ to return to the grid. An enlarged slot that is still waiting shows its image as soon as it arrives. +- Copy and save work as on `PreviewImage`. The node's right-click menu offers **Open Image**, **Copy Image** and **Save Image** for the enlarged image, or for the last slot under the pointer in the grid. The enlarged view also has ↗ open, ⤓ save and ⧉ copy buttons. Copying uses the browser clipboard, which needs a secure context (`localhost` or HTTPS). Use it in place of `PreviewImage` when the same remote output also feeds later remote nodes. That output stays in remote session storage, and the progressive node handles the session references itself, so no separate local download step is added. Only a slot that was never streamed is downloaded at the end. On SSH and Vast.ai back-ends, the host cannot read remote session storage, so the final value is delivered through ComfyUI as usual. Streamed slots still fill in progressively there. diff --git a/tests/progressive_preview_frontend.test.mjs b/tests/progressive_preview_frontend.test.mjs index 991d104..980edd7 100644 --- a/tests/progressive_preview_frontend.test.mjs +++ b/tests/progressive_preview_frontend.test.mjs @@ -17,6 +17,8 @@ class FakeElement { this.textContent = ""; this.title = ""; this.src = ""; + this.listeners = new Map(); + this.focused = false; } appendChild(child) { @@ -27,13 +29,72 @@ class FakeElement { replaceChildren(...children) { this.children = [...children]; } + + addEventListener(name, handler) { + const handlers = this.listeners.get(name) ?? []; + handlers.push(handler); + this.listeners.set(name, handlers); + } + + dispatch(name, event = {}) { + let stopped = false; + let prevented = false; + const fullEvent = { + target: this, + stopPropagation: () => { + stopped = true; + }, + preventDefault: () => { + prevented = true; + }, + ...event, + }; + for (const handler of this.listeners.get(name) ?? []) { + handler(fullEvent); + } + return { stopped, prevented }; + } + + focus() { + this.focused = true; + } } +const createdElements = []; globalThis.document = { head: new FakeElement("head"), - createElement: (tagName) => new FakeElement(tagName), + body: new FakeElement("body"), + createElement: (tagName) => { + const element = new FakeElement(tagName); + createdElements.push(element); + element.click = () => { + element.clicked = true; + }; + element.remove = () => { + element.removed = true; + }; + return element; + }, getElementById: () => null, }; +globalThis.Image = class Image { + constructor() { + this.src = ""; + } +}; +const openedUrls = []; +globalThis.window = { open: (url) => openedUrls.push(url) }; +const clipboardWrites = []; +globalThis.ClipboardItem = class ClipboardItem { + constructor(items) { + this.items = items; + } +}; +Object.defineProperty(globalThis, "navigator", { + value: { clipboard: { write: async (items) => clipboardWrites.push(items) } }, + configurable: true, +}); +globalThis.fetch = async (url) => ({ blob: async () => ({ type: "image/png", url }) }); const listeners = new Map(); const extensions = []; @@ -42,6 +103,7 @@ globalThis.__progressiveAppStub = { registerExtension: (extension) => extensions.push(extension), rootGraph: { getNodeById: (id) => graphNodes.get(String(id)) ?? null }, graph: { setDirtyCanvas() {} }, + nodeOutputs: {}, }; globalThis.__progressiveApiStub = { addEventListener: (name, handler) => listeners.set(name, handler), @@ -56,14 +118,16 @@ runInThisContext( "const api = globalThis.__progressiveApiStub;", originalSource.replace(/^import .*?;\n/gm, ""), "globalThis.__progressiveExports = {", - " applyProgressivePreviewEvent, createSlotState, slotGridColumns, filledSlotCount,", - " progressivePreviewHeaderText, progressivePreviewImageUrl, resolveProgressivePreviewNode,", - " handleProgressivePreviewEvent, finishProgressivePreview, progressivePreviewPanels,", + " applyProgressivePreviewEvent, applyFinalPreviewImages, createSlotState, stepSlotIndex,", + " slotGridColumns, filledSlotCount, progressivePreviewHeaderText, progressivePreviewImageUrl,", + " resolveProgressivePreviewNode, handleProgressivePreviewEvent, finishProgressivePreview,", + " restoreStoredProgressivePreview, progressivePreviewPanels, runProgressivePreviewAction,", "};", ].join("\n"), { filename: sourcePath }, ); const preview = globalThis.__progressiveExports; +const viewUrl = (name) => `/api/view?filename=${name}&subfolder=&type=temp`; // Slot state: start lays out empty slots, slots fill out of order, a new prompt resets. const state = preview.createSlotState(); @@ -74,7 +138,6 @@ preview.applyProgressivePreviewEvent(state, { phase: "slot", prompt_id: "p1", slot_index: 2, slot_count: 4, images: [{ filename: "c.png" }], }); assert.equal(preview.filledSlotCount(state), 1); -assert.equal(preview.progressivePreviewHeaderText(state), "Remote preview · 1/4 received"); preview.applyProgressivePreviewEvent(state, { phase: "slot", prompt_id: "p2", slot_index: 0, images: [{ filename: "a.png" }], }); @@ -83,6 +146,16 @@ assert.equal(state.slotCount, 1); assert.equal(preview.progressivePreviewHeaderText(state), "Remote preview · all 1 received"); assert.equal(preview.applyProgressivePreviewEvent(state, { phase: "bogus", prompt_id: "p2" }), false); +// Final images replace the slots one image per slot; empty finals are ignored. +assert.equal(preview.applyFinalPreviewImages(state, []), false); +assert.equal(preview.applyFinalPreviewImages(state, [{ filename: "x.png" }, { filename: "y.png" }]), true); +assert.equal(state.slotCount, 2); +assert.equal(state.slots.get(1)[0].filename, "y.png"); + +// Navigation wraps in both directions. +assert.equal(preview.stepSlotIndex({ slotCount: 3 }, 2, 1), 0); +assert.equal(preview.stepSlotIndex({ slotCount: 3 }, 0, -1), 2); + // Layout and URLs. assert.equal(preview.slotGridColumns(0), 1); assert.equal(preview.slotGridColumns(4), 2); @@ -98,10 +171,10 @@ graphNodes.set("12", { id: 12, subgraph: { getNodeById: (id) => (String(id) === assert.equal(preview.resolveProgressivePreviewNode("12:18"), innerNode); assert.equal(preview.resolveProgressivePreviewNode("99"), null); -// End to end: events mount a panel with placeholders, fill it, and hand back on executed. +// End to end: events mount a panel with placeholders and fill it. const widgets = []; -const node = { - id: 7, +const makeNode = (id) => ({ + id, comfyClass: "RemoteProgressivePreview", size: [100, 100], imgs: ["stale"], @@ -114,55 +187,138 @@ const node = { this.size = size; }, setDirtyCanvas() {}, -}; +}); +const node = makeNode(7); graphNodes.set("7", node); for (const extension of extensions) { extension.setup?.(); } const handler = listeners.get("modal_progressive_preview"); -assert.equal(typeof handler, "function"); assert.equal(handler({ detail: { phase: "start", prompt_id: "p3", node: "7", slot_count: 3 } }), true); const panel = preview.progressivePreviewPanels.get(node); -assert.equal(widgets.length, 1); -assert.equal(panel.visible, true); +assert.equal(node.hideOutputImages, true); assert.equal(node.imgs, undefined); +assert.equal(node.imageIndex, null); +assert.equal(typeof node.onDrawBackground, "function"); +assert.equal(node.onDrawBackground(), undefined); +assert.equal(panel.visible, true); assert.equal(widgets[0].options.getMinHeight() > 0, true); assert.equal(panel.grid.children.length, 3); assert.equal(panel.grid.children.every((cell) => cell.className.includes("is-empty")), true); handler({ - detail: { phase: "slot", prompt_id: "p3", node: "7", slot_index: 1, slot_count: 3, images: [{ filename: "b.png", type: "temp" }, { filename: "b2.png", type: "temp" }] }, + detail: { + phase: "slot", prompt_id: "p3", node: "7", slot_index: 1, slot_count: 3, + images: [{ filename: "b.png", type: "temp" }, { filename: "b2.png", type: "temp" }], + }, }); const filledCell = panel.grid.children[1]; -assert.equal(filledCell.className.includes("is-empty"), false); -assert.equal(filledCell.children[0].src, "/api/view?filename=b.png&subfolder=&type=temp"); +assert.equal(filledCell.tagName, "button"); +assert.equal(filledCell.children[0].src, viewUrl("b.png")); assert.equal(filledCell.children[1].textContent, "×2"); assert.equal(panel.header.textContent, "Remote preview · 1/3 received"); +// ComfyUI's Open/Copy/Save Image menu reads node.imgs[node.imageIndex]. +assert.equal(node.imgs.length, 1); +assert.equal(node.imgs[0].src, viewUrl("b.png")); +assert.equal(node.imageIndex, 0); assert.equal(handler({ detail: { phase: "start", prompt_id: "p3", node: "99", slot_count: 3 } }), false); +// Clicking a slot enlarges it; buttons and keys navigate with wrap-around; Esc returns to the grid. +assert.equal(panel.viewer.root.style.display, "none"); +filledCell.dispatch("click"); +assert.equal(panel.viewerIndex, 1); +assert.equal(panel.viewer.root.style.display, ""); +assert.equal(panel.viewer.image.src, viewUrl("b.png")); +assert.equal(panel.viewer.counter.textContent, "2 / 3"); +assert.equal(panel.root.focused, true); +const rightKey = panel.root.dispatch("keydown", { key: "ArrowRight" }); +assert.deepEqual(rightKey, { stopped: true, prevented: true }); +assert.equal(panel.viewerIndex, 2); +assert.equal(panel.viewer.image.style.display, "none"); +assert.equal(panel.viewer.waiting.textContent, "Waiting for item 3…"); +panel.root.dispatch("keydown", { key: "ArrowRight" }); +assert.equal(panel.viewerIndex, 0); +panel.root.dispatch("keydown", { key: "ArrowLeft" }); +assert.equal(panel.viewerIndex, 2); +const prevButton = panel.viewer.root.children.find((child) => child.className.includes("is-prev")); +prevButton.dispatch("click"); +assert.equal(panel.viewerIndex, 1); +assert.equal(panel.root.dispatch("keydown", { key: "a" }).stopped, false); +// A slot that fills while it is enlarged appears without reopening. +panel.root.dispatch("keydown", { key: "ArrowRight" }); +handler({ detail: { phase: "slot", prompt_id: "p3", node: "7", slot_index: 2, slot_count: 3, images: [{ filename: "c.png" }] } }); +assert.equal(panel.viewer.image.src, viewUrl("c.png")); +assert.equal(panel.viewer.image.style.display, ""); +// The context menu targets the enlarged image. +assert.equal(node.imgs[node.imageIndex].src, viewUrl("c.png")); +// Viewer toolbar: open, save, and copy act on the enlarged image. +const actions = panel.viewer.actionButtons; +assert.equal(actions.save.disabled, false); +assert.equal(await preview.runProgressivePreviewAction(panel, "open", actions.open), true); +assert.deepEqual(openedUrls, [viewUrl("c.png")]); +assert.equal(await preview.runProgressivePreviewAction(panel, "save", actions.save), true); +const downloadLink = createdElements.filter((element) => element.tagName === "a").at(-1); +assert.equal(downloadLink.href, viewUrl("c.png")); +assert.equal(downloadLink.download, "c.png"); +assert.equal(downloadLink.clicked, true); +assert.equal(await preview.runProgressivePreviewAction(panel, "copy", actions.copy), true); +assert.equal(clipboardWrites.length, 1); +assert.equal(clipboardWrites[0][0].items["image/png"].url, viewUrl("c.png")); +assert.equal(actions.copy.textContent, "✓"); +panel.root.dispatch("keydown", { key: "ArrowLeft" }); +assert.equal(actions.save.disabled, false); +panel.root.dispatch("keydown", { key: "ArrowLeft" }); +assert.equal(actions.save.disabled, true); +assert.equal(await preview.runProgressivePreviewAction(panel, "save", actions.save), false); +panel.root.dispatch("keydown", { key: "Escape" }); +assert.equal(panel.viewerIndex, null); +// Hovering a grid slot makes it the context-menu target. +panel.grid.children[2].dispatch("pointerenter"); +assert.equal(node.imgs[node.imageIndex].src, viewUrl("c.png")); +panel.grid.children[1].dispatch("pointerenter"); +assert.equal(node.imgs[node.imageIndex].src, viewUrl("b.png")); +assert.equal(panel.viewer.root.style.display, "none"); +assert.equal(panel.root.dispatch("keydown", { key: "ArrowRight" }).stopped, false); +// Pointer gestures on interactive controls do not drag the node. +assert.equal(panel.root.dispatch("pointerdown", { target: { closest: () => filledCell } }).stopped, true); +assert.equal(panel.root.dispatch("pointerdown", { target: { closest: () => null } }).stopped, false); + +// The final executed event fills the grid, which stays the only display. const nodeType = { prototype: {} }; let originalCalled = false; nodeType.prototype.onExecuted = () => { originalCalled = true; }; await extensions[0].beforeRegisterNodeDef(nodeType, { name: "RemoteProgressivePreview" }); -nodeType.prototype.onExecuted.call(node, { images: [] }); +node.imgs = ["native"]; +nodeType.prototype.onExecuted.call(node, { + images: [{ filename: "f1.png", type: "temp" }, { filename: "f2.png", type: "temp" }, { filename: "f3.png", type: "temp" }], +}); assert.equal(originalCalled, true); -assert.equal(panel.visible, false); -assert.equal(panel.root.style.display, "none"); -assert.equal(widgets[0].options.getMinHeight(), 0); - -// Replayed events for the finished prompt (browser refocus) must not resurrect the panel. -assert.equal(handler({ detail: { phase: "start", prompt_id: "p3", node: "7", slot_count: 3 } }), false); -assert.equal( - handler({ detail: { phase: "slot", prompt_id: "p3", node: "7", slot_index: 0, images: [{ filename: "a.png" }] } }), - false, -); -assert.equal(panel.visible, false); - -// The next prompt shows the panel again. -assert.equal(handler({ detail: { phase: "start", prompt_id: "p4", node: "7", slot_count: 2 } }), true); +assert.equal(node.imgs.length, 3); +assert.equal(node.imgs[2].src, viewUrl("f3.png")); assert.equal(panel.visible, true); assert.equal(panel.root.style.display, ""); +assert.equal(panel.header.textContent, "Remote preview · all 3 received"); +assert.equal(panel.grid.children[0].children[0].src, viewUrl("f1.png")); + +// Replayed events for the finished prompt (browser refocus) must not overwrite the final images. +assert.equal(handler({ detail: { phase: "start", prompt_id: "p3", node: "7", slot_count: 3 } }), false); +assert.equal(panel.header.textContent, "Remote preview · all 3 received"); + +// The next prompt lays out fresh slots and closes any open viewer. +panel.grid.children[0].dispatch("click"); +assert.equal(handler({ detail: { phase: "start", prompt_id: "p4", node: "7", slot_count: 2 } }), true); +assert.equal(panel.viewerIndex, null); assert.equal(panel.grid.children.length, 2); +// Outputs restored without an executed event (page reload) still render in the panel. +const reloadedNode = makeNode(8); +graphNodes.set("8", reloadedNode); +globalThis.__progressiveAppStub.nodeOutputs["8"] = { images: [{ filename: "r.png", type: "temp" }] }; +await extensions[0].nodeCreated(reloadedNode); +preview.restoreStoredProgressivePreview(reloadedNode); +const reloadedPanel = preview.progressivePreviewPanels.get(reloadedNode); +assert.equal(reloadedPanel.visible, true); +assert.equal(reloadedPanel.grid.children[0].children[0].src, viewUrl("r.png")); + console.log("progressive preview frontend checks passed"); diff --git a/web/progressive_preview.js b/web/progressive_preview.js index 4fa4f62..8252001 100644 --- a/web/progressive_preview.js +++ b/web/progressive_preview.js @@ -14,9 +14,14 @@ const progressivePreviewPanels = new WeakMap(); /** * @typedef {{filename: string, subfolder?: string, type?: string}} PreviewImage * @typedef {{promptId: string|null, slotCount: number, slots: Map}} SlotState + * @typedef {{stage: HTMLElement, image: HTMLImageElement, waiting: HTMLElement, + * counter: HTMLElement, root: HTMLElement, + * actionButtons: Record}} ProgressivePreviewViewer * @typedef {{node: object, root: HTMLElement, header: HTMLElement, grid: HTMLElement, - * cells: HTMLElement[], state: SlotState, visible: boolean, widget: object|null, - * finishedPromptId: string|null}} ProgressivePreviewPanel + * cells: HTMLElement[], viewer: ProgressivePreviewViewer, state: SlotState, + * viewerIndex: number|null, focusSlotIndex: number|null, visible: boolean, + * widget: object|null, finishedPromptId: string|null, + * imageElements: Map}} ProgressivePreviewPanel */ /** @@ -59,6 +64,34 @@ function applyProgressivePreviewEvent(state, detail) { return true; } +/** + * Replace a slot state with the final images ComfyUI delivered, one image per slot. + * @param {SlotState} state + * @param {PreviewImage[]} images + * @returns {boolean} Whether there were images to show. + */ +function applyFinalPreviewImages(state, images) { + const finalImages = Array.isArray(images) ? images.filter((image) => image?.filename) : []; + if (finalImages.length === 0) { + return false; + } + state.slotCount = finalImages.length; + state.slots = new Map(finalImages.map((image, index) => [index, [image]])); + return true; +} + +/** + * Return the slot index reached by stepping through all slots with wrap-around. + * @param {SlotState} state + * @param {number} index + * @param {number} delta + * @returns {number} + */ +function stepSlotIndex(state, index, delta) { + const count = Math.max(1, state.slotCount); + return (((index + delta) % count) + count) % count; +} + /** * Return a near-square column count for a slot grid. * @param {number} slotCount @@ -170,15 +203,18 @@ function installProgressivePreviewStyles() { const style = document.createElement("style"); style.id = PROGRESSIVE_PREVIEW_STYLE_ID; style.textContent = ` - .comfy-progressive-preview { display: flex; flex-direction: column; gap: 6px; - width: 100%; height: 100%; box-sizing: border-box; padding: 4px; overflow: hidden; } + .comfy-progressive-preview { position: relative; display: flex; flex-direction: column; + gap: 6px; width: 100%; height: 100%; box-sizing: border-box; padding: 4px; + overflow: hidden; outline: none; } .comfy-progressive-preview[hidden] { display: none; } .comfy-progressive-preview-header { font-size: 11px; opacity: 0.75; color: var(--input-text, #ddd); white-space: nowrap; } - .comfy-progressive-preview-grid { display: grid; gap: 4px; flex: 1; min-height: 0; } + .comfy-progressive-preview-grid { display: grid; gap: 4px; flex: 1; min-height: 0; + overflow: auto; align-content: start; } .comfy-progressive-preview-cell { position: relative; aspect-ratio: 1; min-height: 0; - border-radius: 4px; overflow: hidden; background: var(--comfy-input-bg, #222); - display: flex; align-items: center; justify-content: center; } + padding: 0; border: 0; border-radius: 4px; overflow: hidden; cursor: zoom-in; + background: var(--comfy-input-bg, #222); display: flex; align-items: center; + justify-content: center; pointer-events: auto; touch-action: manipulation; } .comfy-progressive-preview-cell.is-empty { border: 1px dashed var(--border-color, #555); animation: comfy-progressive-preview-pulse 1.6s ease-in-out infinite; } .comfy-progressive-preview-cell img { width: 100%; height: 100%; object-fit: contain; } @@ -186,11 +222,141 @@ function installProgressivePreviewStyles() { font-size: 10px; color: var(--input-text, #ddd); opacity: 0.7; } .comfy-progressive-preview-badge { position: absolute; right: 4px; top: 3px; font-size: 10px; padding: 0 4px; border-radius: 6px; background: rgba(0, 0, 0, 0.6); color: #fff; } + .comfy-progressive-preview-viewer { position: absolute; inset: 0; display: flex; + align-items: center; justify-content: center; border-radius: 4px; + background: var(--comfy-menu-bg, #1a1a1a); } + .comfy-progressive-preview-viewer[hidden] { display: none; } + .comfy-progressive-preview-stage { position: absolute; inset: 0; display: flex; + align-items: center; justify-content: center; cursor: zoom-out; pointer-events: auto; } + .comfy-progressive-preview-stage img { max-width: 100%; max-height: 100%; object-fit: contain; } + .comfy-progressive-preview-waiting { font-size: 12px; opacity: 0.7; + color: var(--input-text, #ddd); } + .comfy-progressive-preview-nav, .comfy-progressive-preview-close { + position: absolute; display: flex; align-items: center; justify-content: center; + border: 0; border-radius: 50%; background: rgba(0, 0, 0, 0.55); color: #fff; + cursor: pointer; pointer-events: auto; touch-action: manipulation; opacity: 0.8; } + .comfy-progressive-preview-nav:hover, .comfy-progressive-preview-close:hover { opacity: 1; } + .comfy-progressive-preview-nav { top: 50%; width: 32px; height: 32px; margin-top: -16px; + font-size: 20px; } + .comfy-progressive-preview-nav.is-prev { left: 6px; } + .comfy-progressive-preview-nav.is-next { right: 6px; } + .comfy-progressive-preview-close { top: 6px; right: 6px; width: 24px; height: 24px; + font-size: 13px; } + .comfy-progressive-preview-actions { position: absolute; top: 6px; left: 6px; display: flex; + gap: 4px; } + .comfy-progressive-preview-action { width: 26px; height: 24px; border: 0; border-radius: 5px; + background: rgba(0, 0, 0, 0.55); color: #fff; cursor: pointer; font-size: 13px; + pointer-events: auto; touch-action: manipulation; opacity: 0.8; } + .comfy-progressive-preview-action:hover { opacity: 1; } + .comfy-progressive-preview-action:disabled { opacity: 0.3; cursor: default; } + .comfy-progressive-preview-counter { position: absolute; bottom: 6px; left: 50%; + transform: translateX(-50%); padding: 1px 8px; border-radius: 8px; font-size: 11px; + background: rgba(0, 0, 0, 0.55); color: #fff; } @keyframes comfy-progressive-preview-pulse { 50% { opacity: 0.45; } } `; document.head?.appendChild(style); } +/** + * Create an element with a class name and optional text. + * @param {string} tagName + * @param {string} className + * @param {string} [text] + * @returns {HTMLElement} + */ +function createPreviewElement(tagName, className, text) { + const element = document.createElement(tagName); + element.className = className; + if (text !== undefined) { + element.textContent = text; + } + if (tagName === "button") { + element.type = "button"; + } + return element; +} + +/** + * Stop pointer gestures on interactive controls from dragging the node. + * @param {HTMLElement} root + */ +function isolateProgressivePreviewPointerEvents(root) { + for (const eventName of ["pointerdown", "pointermove", "pointerup", "wheel"]) { + root.addEventListener(eventName, (event) => { + if (event.target?.closest?.("button, .comfy-progressive-preview-stage")) { + event.stopPropagation(); + } + }); + } +} + +/** + * Build the enlarged single-image viewer that overlays the slot grid. + * @param {ProgressivePreviewPanel} panel + * @returns {ProgressivePreviewViewer} + */ +function createProgressivePreviewViewer(panel) { + const root = createPreviewElement("div", "comfy-progressive-preview-viewer"); + root.hidden = true; + root.style.display = "none"; + const stage = createPreviewElement("div", "comfy-progressive-preview-stage"); + const image = createPreviewElement("img", ""); + const waiting = createPreviewElement("div", "comfy-progressive-preview-waiting"); + stage.appendChild(image); + stage.appendChild(waiting); + const prev = createPreviewElement("button", "comfy-progressive-preview-nav is-prev", "‹"); + const next = createPreviewElement("button", "comfy-progressive-preview-nav is-next", "›"); + const close = createPreviewElement("button", "comfy-progressive-preview-close", "✕"); + const counter = createPreviewElement("div", "comfy-progressive-preview-counter"); + const actions = createPreviewElement("div", "comfy-progressive-preview-actions"); + const actionButtons = { + open: createPreviewElement("button", "comfy-progressive-preview-action", "↗"), + save: createPreviewElement("button", "comfy-progressive-preview-action", "⤓"), + copy: createPreviewElement("button", "comfy-progressive-preview-action", "⧉"), + }; + actionButtons.open.title = "Open image in a new tab"; + actionButtons.save.title = "Save image"; + actionButtons.copy.title = "Copy image"; + for (const [name, button] of Object.entries(actionButtons)) { + actions.appendChild(button); + button.addEventListener("click", () => void runProgressivePreviewAction(panel, name, button)); + } + prev.title = "Previous image (←)"; + next.title = "Next image (→)"; + close.title = "Back to all images (Esc)"; + for (const child of [stage, prev, next, close, actions, counter]) { + root.appendChild(child); + } + stage.addEventListener("click", () => closeProgressivePreviewViewer(panel)); + close.addEventListener("click", () => closeProgressivePreviewViewer(panel)); + prev.addEventListener("click", () => stepProgressivePreviewViewer(panel, -1)); + next.addEventListener("click", () => stepProgressivePreviewViewer(panel, 1)); + return { root, stage, image, waiting, counter, actionButtons }; +} + +/** + * Handle keyboard navigation while the viewer is open. + * @param {ProgressivePreviewPanel} panel + * @param {KeyboardEvent} event + */ +function handleProgressivePreviewKeydown(panel, event) { + if (panel.viewerIndex === null) { + return; + } + const actions = { + ArrowLeft: () => stepProgressivePreviewViewer(panel, -1), + ArrowRight: () => stepProgressivePreviewViewer(panel, 1), + Escape: () => closeProgressivePreviewViewer(panel), + }; + const action = actions[event.key]; + if (!action) { + return; + } + event.preventDefault?.(); + event.stopPropagation?.(); + action(); +} + /** * Create the DOM panel and widget for one progressive preview node. * @param {object} node @@ -204,14 +370,13 @@ function ensureProgressivePreviewPanel(node) { if (typeof document === "undefined" || typeof node?.addDOMWidget !== "function") { return null; } - const root = document.createElement("div"); - root.className = "comfy-progressive-preview"; + suppressNativeImagePreview(node); + const root = createPreviewElement("div", "comfy-progressive-preview"); + root.tabIndex = 0; root.hidden = true; root.style.display = "none"; - const header = document.createElement("div"); - header.className = "comfy-progressive-preview-header"; - const grid = document.createElement("div"); - grid.className = "comfy-progressive-preview-grid"; + const header = createPreviewElement("div", "comfy-progressive-preview-header"); + const grid = createPreviewElement("div", "comfy-progressive-preview-grid"); root.appendChild(header); root.appendChild(grid); /** @type {ProgressivePreviewPanel} */ @@ -221,11 +386,19 @@ function ensureProgressivePreviewPanel(node) { header, grid, cells: [], + viewer: /** @type {any} */ (null), state: createSlotState(), + viewerIndex: null, + focusSlotIndex: null, visible: false, widget: null, finishedPromptId: null, + imageElements: new Map(), }; + panel.viewer = createProgressivePreviewViewer(panel); + root.appendChild(panel.viewer.root); + isolateProgressivePreviewPointerEvents(root); + root.addEventListener("keydown", (event) => handleProgressivePreviewKeydown(panel, event)); try { panel.widget = node.addDOMWidget(PROGRESSIVE_PREVIEW_WIDGET_NAME, "progressive-preview", root, { serialize: false, @@ -252,7 +425,9 @@ function ensureProgressivePreviewPanel(node) { function progressivePreviewCell(panel, index) { let cell = panel.cells[index]; if (!cell) { - cell = document.createElement("div"); + cell = createPreviewElement("button", "comfy-progressive-preview-cell"); + cell.addEventListener("click", () => openProgressivePreviewViewer(panel, index)); + cell.addEventListener("pointerenter", () => focusProgressivePreviewSlot(panel, index)); panel.cells[index] = cell; } return cell; @@ -274,7 +449,7 @@ function renderProgressivePreviewCell(cell, index, images) { cell.dataset.imageCount = String(images.length); cell.replaceChildren?.(); cell.className = `comfy-progressive-preview-cell${firstImage ? "" : " is-empty"}`; - cell.title = firstImage ? `Item ${index + 1}` : `Item ${index + 1} · waiting for remote result`; + cell.title = firstImage ? `Item ${index + 1} · click to enlarge` : `Item ${index + 1} · waiting for remote result`; if (firstImage) { const image = document.createElement("img"); image.src = imageKey; @@ -282,19 +457,40 @@ function renderProgressivePreviewCell(cell, index, images) { cell.appendChild(image); } if (images.length > 1) { - const badge = document.createElement("span"); - badge.className = "comfy-progressive-preview-badge"; - badge.textContent = `×${images.length}`; - cell.appendChild(badge); + cell.appendChild(createPreviewElement("span", "comfy-progressive-preview-badge", `×${images.length}`)); } - const label = document.createElement("span"); - label.className = "comfy-progressive-preview-label"; - label.textContent = String(index + 1); - cell.appendChild(label); + cell.appendChild(createPreviewElement("span", "comfy-progressive-preview-label", String(index + 1))); } /** - * Render a panel's header and slot grid from its state. + * Render the enlarged viewer for the currently selected slot. + * @param {ProgressivePreviewPanel} panel + */ +function renderProgressivePreviewViewer(panel) { + const { viewer, state, viewerIndex } = panel; + const open = viewerIndex !== null && state.slotCount > 0; + viewer.root.hidden = !open; + viewer.root.style.display = open ? "" : "none"; + if (!open) { + return; + } + const image = (state.slots.get(viewerIndex) ?? [])[0]; + const url = image ? progressivePreviewImageUrl(image) : ""; + if (viewer.image.src !== url) { + viewer.image.src = url; + } + viewer.image.alt = `Remote preview item ${viewerIndex + 1}`; + viewer.image.style.display = image ? "" : "none"; + viewer.waiting.textContent = image ? "" : `Waiting for item ${viewerIndex + 1}…`; + viewer.waiting.style.display = image ? "none" : ""; + viewer.counter.textContent = `${viewerIndex + 1} / ${state.slotCount}`; + for (const button of Object.values(viewer.actionButtons)) { + button.disabled = !image; + } +} + +/** + * Render a panel's header, slot grid, and viewer from its state. * @param {ProgressivePreviewPanel} panel */ function renderProgressivePreviewPanel(panel) { @@ -309,10 +505,56 @@ function renderProgressivePreviewPanel(panel) { } panel.cells.length = state.slotCount; panel.grid.replaceChildren?.(...cells); + if (panel.viewerIndex !== null && panel.viewerIndex >= state.slotCount) { + panel.viewerIndex = null; + } + renderProgressivePreviewViewer(panel); + syncProgressivePreviewMenuImages(panel); } /** - * Show or hide the progressive panel, handing the node back to the native preview when hidden. + * Enlarge one slot to fill the panel. + * @param {ProgressivePreviewPanel} panel + * @param {number} index + */ +function openProgressivePreviewViewer(panel, index) { + if (index < 0 || index >= panel.state.slotCount) { + return; + } + panel.viewerIndex = index; + renderProgressivePreviewViewer(panel); + syncProgressivePreviewMenuImages(panel); + panel.root.focus?.({ preventScroll: true }); + panel.node.setDirtyCanvas?.(true, true); +} + +/** + * Move the enlarged viewer to a neighboring slot, wrapping at either end. + * @param {ProgressivePreviewPanel} panel + * @param {number} delta + */ +function stepProgressivePreviewViewer(panel, delta) { + if (panel.viewerIndex === null) { + return; + } + panel.viewerIndex = stepSlotIndex(panel.state, panel.viewerIndex, delta); + renderProgressivePreviewViewer(panel); + syncProgressivePreviewMenuImages(panel); +} + +/** + * Return from the enlarged viewer to the slot grid. + * @param {ProgressivePreviewPanel} panel + */ +function closeProgressivePreviewViewer(panel) { + panel.viewerIndex = null; + renderProgressivePreviewViewer(panel); + syncProgressivePreviewMenuImages(panel); + panel.node.setDirtyCanvas?.(true, true); +} + +/** + * Show or hide the progressive panel. * @param {ProgressivePreviewPanel} panel * @param {boolean} visible */ @@ -325,8 +567,6 @@ function setProgressivePreviewVisible(panel, visible) { panel.root.style.display = visible ? "" : "none"; const node = panel.node; if (visible) { - node.imgs = undefined; - node.imageIndex = null; node.setSize?.([ Math.max(Number(node.size?.[0]) || 0, PROGRESSIVE_PREVIEW_MIN_WIDTH), Math.max(Number(node.size?.[1]) || 0, PROGRESSIVE_PREVIEW_MIN_HEIGHT + 40), @@ -335,6 +575,163 @@ function setProgressivePreviewVisible(panel, visible) { node.setDirtyCanvas?.(true, true); } +/** + * Suppress ComfyUI's native image preview so the panel is the only display. + * @param {object} node + */ +function suppressNativeImagePreview(node) { + node.hideOutputImages = true; + if (!node.__progressivePreviewDrawSuppressed) { + node.onDrawBackground = function skipNativeImagePreview() {}; + node.__progressivePreviewDrawSuppressed = true; + } +} + +/** + * Return the first image of one slot, if it has arrived. + * @param {ProgressivePreviewPanel} panel + * @param {number|null} index + * @returns {PreviewImage|null} + */ +function progressivePreviewSlotImage(panel, index) { + return index === null ? null : (panel.state.slots.get(index) ?? [])[0] ?? null; +} + +/** + * Return a cached element for one preview URL. + * @param {ProgressivePreviewPanel} panel + * @param {string} url + * @returns {HTMLImageElement} + */ +function progressivePreviewImageElement(panel, url) { + let element = panel.imageElements.get(url); + if (!element) { + element = new Image(); + element.src = url; + panel.imageElements.set(url, element); + } + return element; +} + +/** + * Expose the slot images through node.imgs so ComfyUI's Open/Copy/Save Image menu works. + * The selected image is the enlarged one, else the last hovered slot, else the first. + * @param {ProgressivePreviewPanel} panel + */ +function syncProgressivePreviewMenuImages(panel) { + const slotIndices = []; + const elements = []; + for (let index = 0; index < panel.state.slotCount; index += 1) { + const image = progressivePreviewSlotImage(panel, index); + if (image) { + slotIndices.push(index); + elements.push(progressivePreviewImageElement(panel, progressivePreviewImageUrl(image))); + } + } + const node = panel.node; + node.imgs = elements.length ? elements : undefined; + const selectedSlot = panel.viewerIndex ?? panel.focusSlotIndex; + const position = slotIndices.indexOf(selectedSlot); + node.imageIndex = elements.length ? Math.max(0, position) : null; +} + +/** + * Remember the hovered slot as the context-menu target. + * @param {ProgressivePreviewPanel} panel + * @param {number} index + */ +function focusProgressivePreviewSlot(panel, index) { + panel.focusSlotIndex = index; + syncProgressivePreviewMenuImages(panel); +} + +/** + * Return a PNG blob for an image URL, converting other formats through a canvas. + * @param {string} url + * @returns {Promise} + */ +async function fetchProgressivePreviewPng(url) { + const response = await fetch(url); + const blob = await response.blob(); + if (blob.type === "image/png") { + return blob; + } + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext("2d").drawImage(bitmap, 0, 0); + return await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); +} + +/** + * Open, save, or copy one preview image. + * @param {PreviewImage} image + * @param {"open"|"save"|"copy"} action + * @returns {Promise} + */ +async function performProgressivePreviewImageAction(image, action) { + const url = progressivePreviewImageUrl(image); + if (action === "open") { + window.open(url, "_blank"); + return; + } + if (action === "save") { + const link = document.createElement("a"); + link.href = url; + link.download = String(image.filename ?? "image.png"); + document.body.appendChild(link); + link.click(); + link.remove(); + return; + } + if (typeof ClipboardItem === "undefined" || !navigator.clipboard?.write) { + throw new Error("Image clipboard access is unavailable in this browser context."); + } + const png = await fetchProgressivePreviewPng(url); + await navigator.clipboard.write([new ClipboardItem({ "image/png": png })]); +} + +/** + * Run one viewer toolbar action on the enlarged image and flash the result. + * @param {ProgressivePreviewPanel} panel + * @param {"open"|"save"|"copy"} action + * @param {HTMLElement} button + * @returns {Promise} Whether the action succeeded. + */ +async function runProgressivePreviewAction(panel, action, button) { + const image = progressivePreviewSlotImage(panel, panel.viewerIndex); + if (!image) { + return false; + } + const label = button.textContent; + try { + await performProgressivePreviewImageAction(image, action); + button.textContent = "✓"; + return true; + } catch (error) { + console.error(`Remote progressive preview could not ${action} the image.`, error); + button.textContent = "!"; + return false; + } finally { + setTimeout(() => { + button.textContent = label; + }, 1200); + } +} + +/** + * Return whether an event belongs to a prompt whose final images were already shown. + * Replayed events after a browser refocus must not overwrite the final images. + * @param {ProgressivePreviewPanel} panel + * @param {Record} detail + * @returns {boolean} + */ +function isFinishedProgressivePreviewEvent(panel, detail) { + const promptId = detail?.prompt_id == null ? null : String(detail.prompt_id); + return promptId !== null && promptId === panel.finishedPromptId; +} + /** * Handle one progressive preview websocket event. * @param {{detail?: Record}} event @@ -350,9 +747,14 @@ function handleProgressivePreviewEvent(event) { if (!panel || isFinishedProgressivePreviewEvent(panel, detail)) { return false; } + const previousPromptId = panel.state.promptId; if (!applyProgressivePreviewEvent(panel.state, detail)) { return false; } + if (detail.phase === "start" || panel.state.promptId !== previousPromptId) { + panel.viewerIndex = null; + } + suppressNativeImagePreview(node); renderProgressivePreviewPanel(panel); setProgressivePreviewVisible(panel, true); app.graph?.setDirtyCanvas?.(true, true); @@ -360,26 +762,38 @@ function handleProgressivePreviewEvent(event) { } /** - * Return whether an event belongs to a prompt whose final images were already shown. - * Replayed events after a browser refocus must not resurrect a finished panel. - * @param {ProgressivePreviewPanel} panel - * @param {Record} detail - * @returns {boolean} + * Show ComfyUI's final images in the slot grid, which stays the node's only display. + * @param {object} node + * @param {Record|undefined} output + * @returns {boolean} Whether final images were shown. */ -function isFinishedProgressivePreviewEvent(panel, detail) { - const promptId = detail?.prompt_id == null ? null : String(detail.prompt_id); - return promptId !== null && promptId === panel.finishedPromptId; +function finishProgressivePreview(node, output) { + const panel = ensureProgressivePreviewPanel(node); + if (!panel) { + return false; + } + suppressNativeImagePreview(node); + panel.finishedPromptId = panel.state.promptId; + if (!applyFinalPreviewImages(panel.state, output?.images)) { + return false; + } + renderProgressivePreviewPanel(panel); + setProgressivePreviewVisible(panel, true); + return true; } /** - * Hide a node's progressive panel once ComfyUI delivers its final images. + * Show outputs ComfyUI restored without an executed event, such as after a page reload. * @param {object} node */ -function finishProgressivePreview(node) { +function restoreStoredProgressivePreview(node) { const panel = progressivePreviewPanels.get(node); - if (panel) { - panel.finishedPromptId = panel.state.promptId; - setProgressivePreviewVisible(panel, false); + if (panel?.visible) { + return; + } + const stored = app.nodeOutputs?.[String(node.id)] ?? app.nodeOutputs?.[node.id]; + if (stored?.images?.length) { + finishProgressivePreview(node, stored); } } @@ -396,8 +810,15 @@ app.registerExtension({ const originalOnExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function onProgressivePreviewExecuted(...args) { const result = originalOnExecuted?.apply(this, args); - finishProgressivePreview(this); + finishProgressivePreview(this, args[0]); return result; }; }, + async nodeCreated(node) { + if (!isProgressivePreviewNode(node)) { + return; + } + ensureProgressivePreviewPanel(node); + setTimeout(() => restoreStoredProgressivePreview(node), 0); + }, });