import { api } from "../../../scripts/api.js"; import { app } from "../../../scripts/app.js"; import { $el } from "../../../scripts/ui.js"; import { lightbox } from "./common/lightbox.js"; $el("style", { textContent: ` .pysssss-image-feed { position: absolute; background: var(--comfy-menu-bg); color: var(--fg-color); z-index: 99; font-family: sans-serif; font-size: 12px; display: flex; flex-direction: column; } div > .pysssss-image-feed { position: static; } .pysssss-image-feed--top, .pysssss-image-feed--bottom { width: 100vw; min-height: 30px; max-height: calc(var(--max-size, 20) * 1vh); } .pysssss-image-feed--top { top: 0; } .pysssss-image-feed--bottom { bottom: 0; flex-direction: column-reverse; padding-top: 5px; } .pysssss-image-feed--left, .pysssss-image-feed--right { top: 0; height: 100vh; min-width: 200px; max-width: calc(var(--max-size, 10) * 1vw); } .pysssss-image-feed--left { left: 0; } .pysssss-image-feed--right { right: 0; } .pysssss-image-feed--left .pysssss-image-feed-menu, .pysssss-image-feed--right .pysssss-image-feed-menu { flex-direction: column; } .pysssss-image-feed-menu { position: relative; flex: 0 1 min-content; display: flex; gap: 5px; padding: 5px; justify-content: space-between; } .pysssss-image-feed-btn-group { align-items: stretch; display: flex; gap: .5rem; flex: 0 1 fit-content; justify-content: flex-end; } .pysssss-image-feed-btn { background-color:var(--comfy-input-bg); border-radius:5px; border:2px solid var(--border-color); color: var(--fg-color); cursor:pointer; display:inline-block; flex: 0 1 fit-content; text-decoration:none; } .pysssss-image-feed-btn.sizing-btn:checked { filter: invert(); } .pysssss-image-feed-btn.clear-btn { padding: 5px 20px; } .pysssss-image-feed-btn.hide-btn { padding: 5px; aspect-ratio: 1 / 1; } .pysssss-image-feed-btn:hover { filter: brightness(1.2); } .pysssss-image-feed-btn:active { position:relative; top:1px; } .pysssss-image-feed-menu section { border-radius: 5px; background: rgba(0,0,0,0.6); padding: 0 5px; display: flex; gap: 5px; align-items: center; position: relative; } .pysssss-image-feed-menu section span { white-space: nowrap; } .pysssss-image-feed-menu section input { flex: 1 1 100%; background: rgba(0,0,0,0.6); border-radius: 5px; overflow: hidden; z-index: 100; } .sizing-menu { position: relative; } .size-controls-flyout { position: absolute; transform: scaleX(0%); transition: 200ms ease-out; transition-delay: 500ms; z-index: 101; width: 300px; } .sizing-menu:hover .size-controls-flyout { transform: scale(1, 1); transition: 200ms linear; transition-delay: 0; } .pysssss-image-feed--bottom .size-controls-flyout { transform: scale(1,0); transform-origin: bottom; bottom: 0; left: 0; } .pysssss-image-feed--top .size-controls-flyout { transform: scale(1,0); transform-origin: top; top: 0; left: 0; } .pysssss-image-feed--left .size-controls-flyout { transform: scale(0, 1); transform-origin: left; top: 0; left: 0; } .pysssss-image-feed--right .size-controls-flyout { transform: scale(0, 1); transform-origin: right; top: 0; right: 0; } .pysssss-image-feed-menu > * { min-height: 24px; } .pysssss-image-feed-list { flex: 1 1 auto; overflow-y: auto; display: grid; align-items: center; justify-content: center; gap: 4px; grid-auto-rows: min-content; grid-template-columns: repeat(var(--img-sz, 3), 1fr); transition: 100ms linear; scrollbar-gutter: stable both-edges; padding: 5px; background: var(--comfy-input-bg); border-radius: 5px; margin: 5px; margin-top: 0px; } .pysssss-image-feed-list:empty { display: none; } .pysssss-image-feed-list div { height: 100%; text-align: center; } .pysssss-image-feed-list::-webkit-scrollbar { background: var(--comfy-input-bg); border-radius: 5px; } .pysssss-image-feed-list::-webkit-scrollbar-thumb { background:var(--comfy-menu-bg); border: 5px solid transparent; border-radius: 8px; background-clip: content-box; } .pysssss-image-feed-list::-webkit-scrollbar-thumb:hover { background: var(--border-color); background-clip: content-box; } .pysssss-image-feed-list img { object-fit: var(--img-fit, contain); max-width: 100%; max-height: calc(var(--max-size) * 1vh); border-radius: 4px; } .pysssss-image-feed-list img:hover { filter: brightness(1.2); }`, parent: document.body, }); app.registerExtension({ name: "pysssss.ImageFeed", async setup() { let visible = true; const seenImages = new Map(); const showButton = $el("button.comfy-settings-btn", { textContent: "🖼️", style: { right: "16px", cursor: "pointer", display: "none", }, }); let showMenuButton; if (!app.menu?.element.style.display && app.menu?.settingsGroup) { showMenuButton = new (await import("../../../scripts/ui/components/button.js")).ComfyButton({ icon: "image-multiple", action: () => showButton.click(), tooltip: "Show Image Feed 🐍", content: "Show Image Feed 🐍", }); showMenuButton.enabled = false; app.menu.settingsGroup.append(showMenuButton); } const getVal = (n, d) => { const v = localStorage.getItem("pysssss.ImageFeed." + n); if (v && !isNaN(+v)) { return v; } return d; }; const saveVal = (n, v) => { localStorage.setItem("pysssss.ImageFeed." + n, v); }; const imageFeed = $el("div.pysssss-image-feed"); const imageList = $el("div.pysssss-image-feed-list"); function updateMenuParent(location) { if (showMenuButton) { document.querySelector(".comfyui-body-" + location).append(imageFeed); } else { if (!imageFeed.parent) { document.body.append(imageFeed); } } } const feedLocation = app.ui.settings.addSetting({ id: "pysssss.ImageFeed.Location", name: "🐍 Image Feed Location", defaultValue: "bottom", type: () => { return $el("tr", [ $el("td", [ $el("label", { textContent: "🐍 Image Feed Location:", }), ]), $el("td", [ $el( "select", { style: { fontSize: "14px", }, oninput: (e) => { feedLocation.value = e.target.value; imageFeed.className = `pysssss-image-feed pysssss-image-feed--${feedLocation.value}`; updateMenuParent(feedLocation.value); window.dispatchEvent(new Event("resize")); }, }, ["left", "top", "right", "bottom"].map((m) => $el("option", { value: m, textContent: m, selected: feedLocation.value === m, }) ) ), ]), ]); }, onChange(value) { imageFeed.className = `pysssss-image-feed pysssss-image-feed--${value}`; updateMenuParent(value); }, }); const feedDirection = app.ui.settings.addSetting({ id: "pysssss.ImageFeed.Direction", name: "🐍 Image Feed Direction", defaultValue: "newest first", type: () => { return $el("tr", [ $el("td", [ $el("label", { textContent: "🐍 Image Feed Direction:", }), ]), $el("td", [ $el( "select", { style: { fontSize: "14px", }, oninput: (e) => { feedDirection.value = e.target.value; imageList.replaceChildren(...[...imageList.childNodes].reverse()); }, }, ["newest first", "oldest first"].map((m) => $el("option", { value: m, textContent: m, selected: feedDirection.value === m, }) ) ), ]), ]); }, }); const deduplicateFeed = app.ui.settings.addSetting({ id: "pysssss.ImageFeed.Deduplication", name: "🐍 Image Feed Deduplication", tooltip: `Ensures unique images in the image feed but at the cost of CPU-bound performance impact \ (from hundreds of milliseconds to seconds per image, depending on byte size). For workflows that produce duplicate images, turning this setting on may yield overall client-side performance improvements \ by reducing the number of images in the feed. Recommended: "enabled (max performance)" uness images are erroneously deduplicated.`, defaultValue: 0, type: "combo", options: (value) => { let dedupeOptions = {"disabled": 0, "enabled (slow)": 1, "enabled (performance)": 0.5, "enabled (max performance)": 0.25}; return Object.entries(dedupeOptions).map(([k, v]) => ({ value: v, text: k, selected: k === value, }) ) }, }); const clearButton = $el("button.pysssss-image-feed-btn.clear-btn", { textContent: "Clear", onclick: () => { imageList.replaceChildren(); window.dispatchEvent(new Event("resize")); }, }); const hideButton = $el("button.pysssss-image-feed-btn.hide-btn", { textContent: "❌", onclick: () => { imageFeed.style.display = "none"; showButton.style.display = "unset"; if (showMenuButton) showMenuButton.enabled = true; saveVal("Visible", 0); visible = false; window.dispatchEvent(new Event("resize")); }, }); let columnInput; function updateColumnCount(v) { columnInput.parentElement.title = `Controls the number of columns in the feed (${v} columns).\nClick label to set custom value.`; imageFeed.style.setProperty("--img-sz", v); saveVal("ImageSize", v); columnInput.max = Math.max(10, v, columnInput.max); columnInput.value = v; window.dispatchEvent(new Event("resize")); } function addImageToFeed(href) { const method = feedDirection.value === "newest first" ? "prepend" : "append"; imageList[method]( $el("div", [ $el( "a", { target: "_blank", href, onclick: (e) => { const imgs = [...imageList.querySelectorAll("img")].map((img) => img.getAttribute("src")); lightbox.show(imgs, imgs.indexOf(href)); e.preventDefault(); }, }, [$el("img", { src: href })] ), ]) ); // If lightbox is open, update it with new image lightbox.updateWithNewImage(href, feedDirection.value); } imageFeed.append( $el("div.pysssss-image-feed-menu", [ $el("section.sizing-menu", {}, [ $el("label.size-control-handle", { textContent: "↹ Resize Feed" }), $el("div.size-controls-flyout", {}, [ $el("section.size-control.feed-size-control", {}, [ $el("span", { textContent: "Feed Size...", }), $el("input", { type: "range", min: 10, max: 80, oninput: (e) => { e.target.parentElement.title = `Controls the maximum size of the image feed panel (${e.target.value}vh)`; imageFeed.style.setProperty("--max-size", e.target.value); saveVal("FeedSize", e.target.value); window.dispatchEvent(new Event("resize")); }, $: (el) => { requestAnimationFrame(() => { el.value = getVal("FeedSize", 25); el.oninput({ target: el }); }); }, }), ]), $el("section.size-control.image-size-control", {}, [ $el("a", { textContent: "Column count...", style: { cursor: "pointer", textDecoration: "underline", }, onclick: () => { const v = +prompt("Enter custom column count", 20); if (!isNaN(v)) { updateColumnCount(v); } }, }), $el("input", { type: "range", min: 1, max: 10, step: 1, oninput: (e) => { updateColumnCount(e.target.value); }, $: (el) => { columnInput = el; requestAnimationFrame(() => { updateColumnCount(getVal("ImageSize", 4)); }); }, }), ]), ]), ]), $el("div.pysssss-image-feed-btn-group", {}, [clearButton, hideButton]), ]), imageList ); showButton.onclick = () => { imageFeed.style.display = "flex"; showButton.style.display = "none"; if (showMenuButton) showMenuButton.enabled = false; saveVal("Visible", 1); visible = true; window.dispatchEvent(new Event("resize")); }; document.querySelector(".comfy-settings-btn").after(showButton); window.dispatchEvent(new Event("resize")); if (!+getVal("Visible", 1)) { hideButton.onclick(); } api.addEventListener("executed", ({ detail }) => { if (visible && detail?.output?.images) { if (detail.node?.includes?.(":")) { // Ignore group nodes const n = app.graph.getNodeById(detail.node.split(":")[0]); if (n?.getInnerNodes) return; } for (const src of detail.output.images) { const href = `./view?filename=${encodeURIComponent(src.filename)}&type=${src.type}& subfolder=${encodeURIComponent(src.subfolder)}&t=${+new Date()}`; // deduplicateFeed.value is essentially the scaling factor used for image hashing // but when deduplication is disabled, this value is "0" if (deduplicateFeed.value > 0) { // deduplicate by ignoring images with the same filename/type/subfolder const fingerprint = JSON.stringify({ filename: src.filename, type: src.type, subfolder: src.subfolder }); if (seenImages.has(fingerprint)) { // NOOP: image is a duplicate } else { seenImages.set(fingerprint, true); let img = $el("img", { src: href }) img.onerror = () => { // fall back to default behavior addImageToFeed(href); } img.onload = () => { // redraw the image onto a canvas to strip metadata (resize if performance mode) let imgCanvas = document.createElement("canvas"); let imgScalar = deduplicateFeed.value; imgCanvas.width = imgScalar * img.width; imgCanvas.height = imgScalar * img.height; let imgContext = imgCanvas.getContext("2d"); imgContext.drawImage(img, 0, 0, imgCanvas.width, imgCanvas.height); const data = imgContext.getImageData(0, 0, imgCanvas.width, imgCanvas.height); // calculate fast hash of the image data let hash = 0; for (const b of data.data) { hash = ((hash << 5) - hash) + b; } // add image to feed if we've never seen the hash before if (seenImages.has(hash)) { // NOOP: image is a duplicate } else { // if we got to here, then the image is unique--so add to feed seenImages.set(hash, true); addImageToFeed(href); } } } } else { addImageToFeed(href); } } } }); }, });