Add current/next image thumbnail previews (Next / Next+1)
This commit is contained in:
@@ -37,6 +37,7 @@ The usual way to run a workflow over a folder of images is to swap the file in *
|
||||
- **Pick your filetype** — `png`, `jpg`, `jpeg`, `webp`, `bmp`, `gif`, `tiff`, or `all` for every supported type at once.
|
||||
- **Reverse** runs the sequence last→first.
|
||||
- **Live status line** shows exactly where you are: `Next: 3 / 240 · frame_003.png`.
|
||||
- **Thumbnail previews** of the current and next image at the bottom of the node, so you can see what's coming before you Queue.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -60,6 +61,8 @@ Restart ComfyUI. No extra Python dependencies — it uses Pillow and torch, whic
|
||||
|
||||
The status line shows `Next: N / TOTAL · filename`. Hit **⏮ Reset to start** any time to begin again from the first file.
|
||||
|
||||
At the bottom of the node, two thumbnails show what's coming: **Next** (the image that loads on your next Queue) and **Next + 1** (the one after it, looping back to the start at the end).
|
||||
|
||||
## Controls
|
||||
|
||||
| Control | What it does |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- [Publishing ComfyUI nodes](publishing-comfyui-nodes.md) — two-target publish flow: Comfy Registry (comfy node publish) + legacy Manager PR (Comfy-Org/ComfyUI-Manager)
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: publishing-comfyui-nodes
|
||||
description: How the user publishes ComfyUI custom nodes (Comfy Registry + legacy Manager)
|
||||
metadata:
|
||||
type: project
|
||||
---
|
||||
|
||||
The user (shootthesound) publishes ComfyUI custom nodes to two places:
|
||||
|
||||
1. **Comfy Registry** — needs a `pyproject.toml` with `[project]` (name = lowercase-hyphen id, version, license, description) and `[tool.comfy]` (`PublisherId = "shootthesound"`, `DisplayName`). Publish with `comfy node publish --token <COMFY_API_KEY>` (comfy-cli installed, v1.10.3). Registry URL: `https://registry.comfy.org/nodes/<name>`.
|
||||
|
||||
2. **Legacy ComfyUI-Manager** — PR adding an entry to `custom-node-list.json` in **Comfy-Org/ComfyUI-Manager** (moved from ltdrdata). New entries go at the TOP of the `custom_nodes` array. The user already has a fork `shootthesound/ComfyUI-Manager` (default branch `main`); GitHub allows only one fork per repo, so reuse it — branch off upstream `main`, push branch to the fork, open PR against `Comfy-Org:main`. Entry shape: `author, title, reference, files[], install_type:"git-clone", description`.
|
||||
|
||||
**Why:** The user asked to publish to "both the legacy manager and the comfy registry" — this is a recurring two-target workflow for them.
|
||||
|
||||
**How to apply:** Never write the Comfy API key into any file; pass it inline to the publish command only. Advise rotating the key if it was pasted in chat.
|
||||
@@ -8,6 +8,7 @@ resolves index → file → tensors and exposes a listing route the UI uses
|
||||
to show progress.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
@@ -250,8 +251,26 @@ try:
|
||||
return web.json_response({
|
||||
"count": len(files),
|
||||
"files": [os.path.basename(p) for p in files],
|
||||
"paths": files,
|
||||
})
|
||||
|
||||
@PromptServer.instance.routes.get("/seqloader/thumb")
|
||||
async def _seqloader_thumb(request):
|
||||
path = (request.query.get("path", "") or "").strip()
|
||||
if (not path or not os.path.isfile(path)
|
||||
or not path.lower().endswith(_ALL_EXTS)):
|
||||
return web.Response(status=404)
|
||||
try:
|
||||
img = Image.open(path)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail((256, 256))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=85)
|
||||
return web.Response(body=buf.getvalue(), content_type="image/jpeg")
|
||||
except Exception as e:
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
@PromptServer.instance.routes.post("/seqloader/browse")
|
||||
async def _seqloader_browse(request):
|
||||
try:
|
||||
|
||||
+88
-4
@@ -82,13 +82,67 @@ async function refreshFileList(node) {
|
||||
});
|
||||
const data = await res.json();
|
||||
node._seqFiles = Array.isArray(data.files) ? data.files : [];
|
||||
node._seqPaths = Array.isArray(data.paths) ? data.paths : [];
|
||||
} catch (e) {
|
||||
node._seqFiles = [];
|
||||
node._seqPaths = [];
|
||||
}
|
||||
updateStatus(node);
|
||||
}
|
||||
|
||||
// --- Thumbnail previews of the current + next image. Fetched from the
|
||||
// server (the files live there) as blobs so base-path / auth are
|
||||
// handled by api.fetchApi. Skips refetching when the path is unchanged.
|
||||
async function setThumb(img, path) {
|
||||
if (!img) return;
|
||||
if (!path) {
|
||||
img._path = "";
|
||||
img.removeAttribute("src");
|
||||
return;
|
||||
}
|
||||
if (img._path === path) return;
|
||||
img._path = path;
|
||||
try {
|
||||
const res = await api.fetchApi(
|
||||
"/seqloader/thumb?path=" + encodeURIComponent(path));
|
||||
if (!res.ok) { img.removeAttribute("src"); return; }
|
||||
const blob = await res.blob();
|
||||
if (img._path !== path) return; // a newer request superseded this one
|
||||
if (img._url) URL.revokeObjectURL(img._url);
|
||||
img._url = URL.createObjectURL(blob);
|
||||
img.src = img._url;
|
||||
} catch (e) {
|
||||
img.removeAttribute("src");
|
||||
}
|
||||
}
|
||||
|
||||
function updatePreviews(node) {
|
||||
const paths = node._seqPaths || [];
|
||||
const total = paths.length;
|
||||
const cur = node._seqCurImg, nxt = node._seqNextImg;
|
||||
if (total === 0) {
|
||||
setThumb(cur, "");
|
||||
setThumb(nxt, "");
|
||||
if (node._seqCurLab) node._seqCurLab.textContent = "Next";
|
||||
if (node._seqNextLab) node._seqNextLab.textContent = "Next + 1";
|
||||
return;
|
||||
}
|
||||
const idx = findWidget(node, "index")?.value | 0;
|
||||
const pos = ((idx % total) + total) % total;
|
||||
const nextPos = (pos + 1) % total;
|
||||
setThumb(cur, paths[pos]);
|
||||
setThumb(nxt, paths[nextPos]);
|
||||
const baseName = (p) => (p || "").split(/[\\/]/).pop();
|
||||
if (node._seqCurImg) node._seqCurImg.title = baseName(paths[pos]);
|
||||
if (node._seqNextImg) node._seqNextImg.title = baseName(paths[nextPos]);
|
||||
if (node._seqCurLab) node._seqCurLab.textContent = "Next";
|
||||
if (node._seqNextLab) {
|
||||
node._seqNextLab.textContent = (total === 1) ? "Next + 1 (loops)" : "Next + 1";
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatus(node) {
|
||||
updatePreviews(node);
|
||||
const el = node._seqStatusEl;
|
||||
if (!el) return;
|
||||
const files = node._seqFiles || [];
|
||||
@@ -166,14 +220,44 @@ function attachUI(node) {
|
||||
}));
|
||||
container.appendChild(row);
|
||||
|
||||
// Image previews of the current + next file, at the bottom.
|
||||
const preview = document.createElement("div");
|
||||
preview.style.cssText = "display:flex; gap:6px; margin-top:2px;";
|
||||
const makeCell = (labelText) => {
|
||||
const cell = document.createElement("div");
|
||||
cell.style.cssText =
|
||||
"flex:1 1 0; min-width:0; display:flex; flex-direction:column; gap:3px;";
|
||||
const lab = document.createElement("div");
|
||||
lab.style.cssText =
|
||||
"font-size:10px; color:#9cf; text-align:center; white-space:nowrap; "
|
||||
+ "overflow:hidden; text-overflow:ellipsis;";
|
||||
lab.textContent = labelText;
|
||||
const img = document.createElement("img");
|
||||
img.style.cssText =
|
||||
"width:100%; height:96px; object-fit:contain; background:#111; "
|
||||
+ "border:1px solid #3a3a3a; border-radius:4px;";
|
||||
cell.appendChild(lab);
|
||||
cell.appendChild(img);
|
||||
return { cell, lab, img };
|
||||
};
|
||||
const curCell = makeCell("Next");
|
||||
const nextCell = makeCell("Next + 1");
|
||||
preview.appendChild(curCell.cell);
|
||||
preview.appendChild(nextCell.cell);
|
||||
container.appendChild(preview);
|
||||
node._seqCurImg = curCell.img;
|
||||
node._seqNextImg = nextCell.img;
|
||||
node._seqCurLab = curCell.lab;
|
||||
node._seqNextLab = nextCell.lab;
|
||||
|
||||
// Attach as a DOM widget so LiteGraph manages its layout. The panel is
|
||||
// three fixed rows (status + browse + button row) at ~28px each with two
|
||||
// 6px gaps and 6px top+bottom padding — a constant floor of 112 fits the
|
||||
// status + browse + button row (~28px each) plus a ~115px preview row
|
||||
// (two 96px thumbnails with labels). A constant floor of 238 fits the
|
||||
// content without clipping or leaving a gap.
|
||||
const widget = node.addDOMWidget("seqloader_ui", "seqloader_panel", container, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => 112,
|
||||
getMinHeight: () => 238,
|
||||
});
|
||||
|
||||
// Make the panel fill the node's full width: define `width` as a getter
|
||||
@@ -398,7 +482,7 @@ app.registerExtension({
|
||||
attachUI(this);
|
||||
// Enforce a sensible minimum node size once. Width ~320; height
|
||||
// accommodates the standard widgets plus the DOM panel.
|
||||
const min = [320, 370];
|
||||
const min = [320, 500];
|
||||
if (this.size[0] < min[0]) this.size[0] = min[0];
|
||||
if (this.size[1] < min[1]) this.size[1] = min[1];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user