Spaces:
Running
on
Zero
Running
on
Zero
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); | |
} | |
.comfyui-body-left .pysssss-image-feed--left, .comfyui-body-right .pysssss-image-feed--right { | |
height: 100%; | |
} | |
.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) { | |
const el = document.querySelector(".comfyui-body-" + location); | |
if (!el) return; | |
el.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", "hidden"].map((m) => | |
$el("option", { | |
value: m, | |
textContent: m, | |
selected: feedLocation.value === m, | |
}) | |
) | |
), | |
]), | |
]); | |
}, | |
onChange(value) { | |
if (value === "hidden") { | |
imageFeed.remove(); | |
showButton.style.display = "none"; | |
} else { | |
showButton.style.display = visible ? "none" : "unset"; | |
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 maxImages = app.ui.settings.addSetting({ | |
id: "pysssss.ImageFeed.MaxImages", | |
name: "🐍 Image Feed Max Images", | |
tooltip: `Limits the number of images in the feed to a maximum, removing the oldest images as new ones are added.`, | |
defaultValue: 0, | |
type: "number", | |
}); | |
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 = feedLocation.value === "hidden" ? "none" : "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"; | |
if (maxImages.value > 0 && imageList.children.length >= maxImages.value) { | |
imageList.children[method === "prepend" ? imageList.children.length - 1 : 0].remove(); | |
} | |
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); | |
} | |
} | |
} | |
}); | |
}, | |
}); | |