Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
840d1dbfaa | ||
|
|
4c6f65975b | ||
|
|
1711a01929 | ||
|
|
1bf88f335f |
@@ -21,7 +21,7 @@
|
||||
│ └──────────────────────────────────┘ │
|
||||
└───────────────┬──────────────────────┘
|
||||
│
|
||||
image · mask · filename · index · total
|
||||
image · mask · filename · index · total · file_path
|
||||
```
|
||||
|
||||
Drop the node on the canvas, point it at a folder, wire **image** (and **mask** if you need it) into your graph, and Queue. Each Queue loads the next image; when it reaches the end it loops back to the start. That's the whole idea.
|
||||
@@ -31,13 +31,17 @@ Drop the node on the canvas, point it at a folder, wire **image** (and **mask**
|
||||
The usual way to run a workflow over a folder of images is to swap the file in **Load Image** by hand between every run, or to build a batch-loader contraption that fires everything at once and floods your VRAM. This sits in the middle: it's a normal single-image loader that just **remembers where it got to** and steps forward each time you press Queue.
|
||||
|
||||
- **One image per Queue.** Process a folder at your own pace — tweak settings between runs, watch each result, stop whenever.
|
||||
- **▶ Queue all** — auto-queue the remaining images one run after another, and **stop automatically after the last file**. Unlike ComfyUI's built-in auto-queue (which loops forever), this knows where the folder ends. Click again (■ Stop queueing) to bail out early; errors or an interrupt also stop the chain.
|
||||
- **📁 Browse for folder** — a built-in folder picker (browses the ComfyUI server's filesystem) so you don't have to hand-type paths. Or just paste a path.
|
||||
- **Natural sort order.** `frame2.png` comes before `frame10.png`, the way you'd expect — not `frame10` before `frame2`.
|
||||
- **⏮ Reset to start** rewinds to the first image. **‹ Prev / Next ›** step the position without queuing. **↻ Rescan** re-reads the folder after you add or remove files.
|
||||
- **Scrub bar** — the status box is draggable: click anywhere to jump to that point in the folder, or click-drag left/right to scrub. A fill shows how far through you are. Perfect for big folders where stepping one-by-one would take forever.
|
||||
- **Pick your filetype** — `png`, `jpg`, `jpeg`, `webp`, `bmp`, `gif`, `tiff`, or `all` for every supported type at once.
|
||||
- **Reverse** runs the sequence last→first.
|
||||
- **Hold (pause) on a frame** — ⏸ Hold last (the image you just got) or ⏸ Hold next (the upcoming one) freezes the sequence so every Queue re-emits that same frame. Handy for locking onto one image while you tweak the rest of the graph. Click **▶ Resume** to carry on where you left off.
|
||||
- **Remembers your last folder.** A freshly-added node pre-fills with the folder you last used, so you're not re-typing paths all day.
|
||||
- **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.
|
||||
- **Thumbnail previews** at the bottom of the node: **Current** (the image the last run actually loaded — empty until you've queued something) and **Next** (the one your next Queue will load).
|
||||
|
||||
## Install
|
||||
|
||||
@@ -59,9 +63,11 @@ Restart ComfyUI. No extra Python dependencies — it uses Pillow and torch, whic
|
||||
5. Wire **image** (and **mask**) into your workflow.
|
||||
6. **Queue.** It loads the first image. Queue again → the next. And so on.
|
||||
|
||||
The status line shows `Next: N / TOTAL · filename`. Hit **⏮ Reset to start** any time to begin again from the first file.
|
||||
The status line shows `Next: N / TOTAL · filename` and doubles as a **scrub bar** — click it to jump anywhere in the folder, or click-drag to scrub. 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).
|
||||
At the bottom of the node, two thumbnails keep you oriented: **Current** (what the last run loaded — empty until you've queued something) and **Next** (what your next Queue will load).
|
||||
|
||||
Want the whole folder done in one go? Hit **▶ Queue all** — it queues the next image as each run finishes and stops by itself after the last file.
|
||||
|
||||
## Controls
|
||||
|
||||
@@ -74,7 +80,14 @@ At the bottom of the node, two thumbnails show what's coming: **Next** (the imag
|
||||
| **reverse** | Run the sequence last→first instead of first→last. |
|
||||
| **⏮ Reset to start** | Rewind to the first image in the current order. |
|
||||
| **‹ Prev / Next ›** | Step the position back/forward **without** queuing. |
|
||||
| **Status / scrub bar** | Shows `Next: N / TOTAL · filename`. Click to jump anywhere in the folder, or click-drag left/right to scrub; the fill shows your position. |
|
||||
| **⏸ Hold last** | Freeze on the image you just loaded — every Queue re-emits it. Click **▶ Resume** to continue. (Also cancels a running Queue all.) |
|
||||
| **⏸ Hold next** | Freeze on the upcoming image — every Queue re-emits it. Click **▶ Resume** to continue. (Also cancels a running Queue all.) |
|
||||
| **▶ Queue all** | Queue the remaining images one run after another, stopping automatically after the last file. Becomes **■ Stop queueing** while active; errors or Cancel also stop the chain. Starts from the current position — hit **⏮ Reset** first for the whole folder. |
|
||||
| **↻ Rescan** | Re-read the folder (after adding/removing files). |
|
||||
| **Current / Next previews** | Thumbnails at the bottom: what the last run loaded (empty until you queue something) and what the next Queue will load. |
|
||||
|
||||
While a hold is active, the other buttons grey out; **▶ Resume** restores the sequence from where it was about to go.
|
||||
|
||||
## Outputs
|
||||
|
||||
@@ -85,6 +98,7 @@ At the bottom of the node, two thumbnails show what's coming: **Next** (the imag
|
||||
| `filename` | STRING | basename of the loaded file |
|
||||
| `index` | INT | 0-based position used this run |
|
||||
| `total` | INT | number of matching files found |
|
||||
| `file_path` | STRING | absolute path of the loaded file (e.g. `C:\frames\frame_003.png`) |
|
||||
|
||||
## How "advance per Queue" works
|
||||
|
||||
@@ -93,8 +107,10 @@ ComfyUI only re-runs a node when one of its inputs changes, so a hidden `index`
|
||||
## Honest limits
|
||||
|
||||
- **Position is per-session widget state.** It's stored on the node and saved with your workflow JSON, so reopening a graph resumes where you left off — but external tools that re-run the workflow headless start from whatever index was saved.
|
||||
- **One queue = one image.** This is a deliberately manual, paced loader. If you want every image processed in a single Queue, that's a different (batch) node.
|
||||
- **One queue = one image.** Each run still processes exactly one file — **▶ Queue all** just presses Queue for you until the folder is done. If you want every image in a single run as a batch tensor, that's a different (batch) node.
|
||||
- **Queue all runs from where you are.** It continues from the current position to the last file — it doesn't rewind first. Hit **⏮ Reset to start** before Queue all to do the whole folder.
|
||||
- **Changing filetype mid-run keeps the index number.** If the new type has fewer files it wraps via modulo — hit **Reset** after switching type to start cleanly from the first file of the new set.
|
||||
- **Last-folder memory is per-browser.** The remembered folder lives in the browser's localStorage, so it's per-machine/per-browser and only pre-fills *new* nodes — a node loaded from a saved workflow keeps its own saved path.
|
||||
|
||||
## Compatibility
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "sequential-image-loader"
|
||||
description = "Load a folder of images one at a time, in order — a fresh image on every Queue press. A paced, single-image folder sequencer for batch-processing frames or photos through any ComfyUI workflow."
|
||||
version = "1.1.0"
|
||||
version = "1.4.0"
|
||||
license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
dependencies = []
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 104 KiB |
@@ -138,8 +138,10 @@ class SequentialImageLoader:
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "INT", "INT")
|
||||
RETURN_NAMES = ("image", "mask", "filename", "index", "total")
|
||||
# file_path is declared last (newest output) so links in older saved
|
||||
# workflows keep their slot positions.
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("image", "mask", "filename", "index", "total", "file_path")
|
||||
FUNCTION = "load"
|
||||
CATEGORY = "image"
|
||||
|
||||
@@ -162,7 +164,7 @@ class SequentialImageLoader:
|
||||
)
|
||||
|
||||
pos = int(index) % total # wrap around at the end
|
||||
path = files[pos]
|
||||
path = os.path.abspath(files[pos])
|
||||
filename = os.path.basename(path)
|
||||
|
||||
image, mask = _load_image_tensors(path)
|
||||
@@ -170,10 +172,11 @@ class SequentialImageLoader:
|
||||
return {
|
||||
"ui": {"seqloader": [{
|
||||
"filename": filename,
|
||||
"path": path,
|
||||
"index": pos,
|
||||
"total": total,
|
||||
}]},
|
||||
"result": (image, mask, filename, pos, total),
|
||||
"result": (image, mask, filename, pos, total, path),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+395
-63
@@ -1,11 +1,13 @@
|
||||
// Sequential Image Loader — folder PNG sequencer UI.
|
||||
// Sequential Image Loader — folder image sequencer UI.
|
||||
//
|
||||
// Each node loads the *next* PNG from a folder on every Queue. A global
|
||||
// Each node loads the *next* image from a folder on every Queue. A global
|
||||
// app.queuePrompt hook advances the (hidden) `index` widget after each
|
||||
// queue, so the run that was just sent uses the current index and the
|
||||
// next queue picks up the following file. A "Reset to start" button
|
||||
// rewinds index to 0. A status line shows how far through the folder we
|
||||
// are, refreshed from the /seqloader/list route.
|
||||
// are, refreshed from the /seqloader/list route. "▶ Queue all" chains
|
||||
// runs automatically (one per image) and stops after the last file.
|
||||
// Thumbnails show Current (what the last run loaded) and Next (upcoming).
|
||||
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
@@ -24,6 +26,17 @@ function setWidget(widget, value) {
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
// --- Remember the last folder used, so a freshly-added node pre-fills with
|
||||
// it (persists across sessions via localStorage; saved workflows still
|
||||
// keep their own per-node path).
|
||||
const LAST_DIR_KEY = "SeqLoader.lastDir";
|
||||
function saveLastDir(dir) {
|
||||
try { if (dir) localStorage.setItem(LAST_DIR_KEY, dir); } catch (e) { /* ignore */ }
|
||||
}
|
||||
function loadLastDir() {
|
||||
try { return localStorage.getItem(LAST_DIR_KEY) || ""; } catch (e) { return ""; }
|
||||
}
|
||||
|
||||
// --- Global queue hook: advance index AFTER the prompt is fully built and
|
||||
// submitted, so THIS run uses the current image and the next queue gets
|
||||
// the following one. We must await orig() first: in current ComfyUI
|
||||
@@ -41,6 +54,7 @@ function installQueueHook() {
|
||||
for (const n of nodes) {
|
||||
if (n?.type !== NODE_NAME) continue;
|
||||
if (n.mode === 2 || n.mode === 4) continue; // muted / bypassed
|
||||
if (n._seqHold) continue; // paused: keep re-emitting the held frame
|
||||
const idxW = findWidget(n, "index");
|
||||
if (!idxW) continue;
|
||||
const total = (n._seqFiles || []).length;
|
||||
@@ -60,12 +74,16 @@ function installQueueHook() {
|
||||
// --- Listing: ask the server which PNGs are in the folder. Cached on the
|
||||
// node as n._seqFiles so the status line can show the current name.
|
||||
async function refreshFileList(node) {
|
||||
// Sequence number so a slow response can't clobber a newer one (e.g.
|
||||
// rapid filetype toggles, or typing a path while a big folder scans).
|
||||
const seq = node._seqListReq = (node._seqListReq || 0) + 1;
|
||||
const dir = (findWidget(node, "directory")?.value || "").trim();
|
||||
const sub = !!findWidget(node, "include_subfolders")?.value;
|
||||
const filetype = findWidget(node, "filetype")?.value || "png";
|
||||
const reverse = !!findWidget(node, "reverse")?.value;
|
||||
if (!dir) {
|
||||
node._seqFiles = [];
|
||||
node._seqPaths = [];
|
||||
updateStatus(node);
|
||||
return;
|
||||
}
|
||||
@@ -81,9 +99,11 @@ async function refreshFileList(node) {
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (node._seqListReq !== seq) return; // superseded by a newer scan
|
||||
node._seqFiles = Array.isArray(data.files) ? data.files : [];
|
||||
node._seqPaths = Array.isArray(data.paths) ? data.paths : [];
|
||||
} catch (e) {
|
||||
if (node._seqListReq !== seq) return;
|
||||
node._seqFiles = [];
|
||||
node._seqPaths = [];
|
||||
}
|
||||
@@ -92,19 +112,28 @@ async function refreshFileList(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.
|
||||
// handled by api.fetchApi. A new request for a different frame ABORTS
|
||||
// the one in flight, so spamming Prev/Next can never leave a slow load
|
||||
// queued ahead of the frame you actually want.
|
||||
async function setThumb(img, path) {
|
||||
if (!img) return;
|
||||
if (!path) {
|
||||
if (img._abort) { img._abort.abort(); img._abort = null; }
|
||||
img._path = "";
|
||||
if (img._url) { URL.revokeObjectURL(img._url); img._url = null; }
|
||||
img.removeAttribute("src");
|
||||
return;
|
||||
}
|
||||
if (img._path === path) return;
|
||||
if (img._path === path) return; // already showing / fetching this one
|
||||
if (img._abort) { img._abort.abort(); img._abort = null; } // cancel stale load
|
||||
img._path = path;
|
||||
const controller = (typeof AbortController !== "undefined")
|
||||
? new AbortController() : null;
|
||||
img._abort = controller;
|
||||
try {
|
||||
const res = await api.fetchApi(
|
||||
"/seqloader/thumb?path=" + encodeURIComponent(path));
|
||||
"/seqloader/thumb?path=" + encodeURIComponent(path),
|
||||
controller ? { signal: controller.signal } : undefined);
|
||||
if (!res.ok) { img.removeAttribute("src"); return; }
|
||||
const blob = await res.blob();
|
||||
if (img._path !== path) return; // a newer request superseded this one
|
||||
@@ -112,33 +141,45 @@ async function setThumb(img, path) {
|
||||
img._url = URL.createObjectURL(blob);
|
||||
img.src = img._url;
|
||||
} catch (e) {
|
||||
img.removeAttribute("src");
|
||||
// AbortError is expected when a newer frame supersedes this load.
|
||||
if (e?.name !== "AbortError") img.removeAttribute("src");
|
||||
} finally {
|
||||
if (img._abort === controller) img._abort = null;
|
||||
}
|
||||
}
|
||||
|
||||
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 curImg = node._seqCurrentImg, nxtImg = node._seqNextImg;
|
||||
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";
|
||||
// Left cell: the image the last run actually loaded (set from the
|
||||
// execution message) — empty until something has been queued.
|
||||
const curPath = node._seqCurrentPath || "";
|
||||
if (curImg) curImg.title = baseName(curPath);
|
||||
// Right cell: the image the next Queue will load.
|
||||
let nextPath = "";
|
||||
if (total > 0) {
|
||||
const idx = findWidget(node, "index")?.value | 0;
|
||||
const pos = ((idx % total) + total) % total;
|
||||
nextPath = paths[pos];
|
||||
}
|
||||
if (nxtImg) nxtImg.title = baseName(nextPath);
|
||||
// Titles update instantly; defer the thumbnail fetches a touch so rapid
|
||||
// Prev/Next clicks coalesce into a single load of the final frame.
|
||||
clearTimeout(node._seqThumbTimer);
|
||||
node._seqThumbTimer = setTimeout(() => {
|
||||
setThumb(curImg, curPath);
|
||||
setThumb(nxtImg, nextPath);
|
||||
}, 80);
|
||||
}
|
||||
|
||||
// Filenames go into innerHTML — escape them so a file named e.g.
|
||||
// "<img src=x onerror=…>.png" can't inject markup into the status line.
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function updateStatus(node) {
|
||||
@@ -150,6 +191,7 @@ function updateStatus(node) {
|
||||
const idx = findWidget(node, "index")?.value | 0;
|
||||
|
||||
if (total === 0) {
|
||||
el.style.background = "#1a1a1a";
|
||||
const ft = findWidget(node, "filetype")?.value || "png";
|
||||
const label = ft === "all" ? "image" : ft.toUpperCase();
|
||||
const dir = (findWidget(node, "directory")?.value || "").trim();
|
||||
@@ -161,10 +203,15 @@ function updateStatus(node) {
|
||||
}
|
||||
const pos = ((idx % total) + total) % total; // wrap, handle negatives
|
||||
const name = files[pos] || "";
|
||||
// Progress fill behind the text: left edge = first file, right = last.
|
||||
const pct = total > 1 ? (pos / (total - 1)) * 100 : 100;
|
||||
el.style.background = "linear-gradient(to right, "
|
||||
+ `rgba(60,110,180,0.55) ${pct}%, #1a1a1a ${pct}%)`;
|
||||
const safe = escapeHtml(name);
|
||||
el.innerHTML =
|
||||
`<span style="color:#9cf;font-weight:bold">Next: ${pos + 1} / ${total}</span>`
|
||||
+ `<span style="color:#888"> · </span>`
|
||||
+ `<span style="color:#ddd" title="${name}">${name}</span>`;
|
||||
+ `<span style="color:#ddd" title="${safe}">${safe}</span>`;
|
||||
}
|
||||
|
||||
function attachUI(node) {
|
||||
@@ -190,6 +237,48 @@ function attachUI(node) {
|
||||
container.appendChild(status);
|
||||
node._seqStatusEl = status;
|
||||
|
||||
// Make the status box a scrubber: click to jump anywhere in the folder,
|
||||
// or click-drag left/right to scrub. The box fills to show position.
|
||||
status.style.cursor = "ew-resize";
|
||||
status.title = "Click to jump • drag left/right to scrub";
|
||||
const scrubTo = (clientX) => {
|
||||
if (node._seqHold) return; // disabled while paused
|
||||
const total = (node._seqPaths || []).length;
|
||||
if (total <= 1) return;
|
||||
const idxW = findWidget(node, "index");
|
||||
if (!idxW) return;
|
||||
const rect = status.getBoundingClientRect();
|
||||
let frac = (clientX - rect.left) / Math.max(1, rect.width);
|
||||
frac = Math.max(0, Math.min(1, frac));
|
||||
const pos = Math.round(frac * (total - 1));
|
||||
if ((((idxW.value | 0) % total) + total) % total !== pos) {
|
||||
setWidget(idxW, pos);
|
||||
updateStatus(node);
|
||||
}
|
||||
};
|
||||
status.addEventListener("pointerdown", (e) => {
|
||||
if (node._seqHold) return;
|
||||
if ((node._seqPaths || []).length <= 1) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
status._scrubbing = true;
|
||||
try { status.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||||
scrubTo(e.clientX);
|
||||
});
|
||||
status.addEventListener("pointermove", (e) => {
|
||||
if (!status._scrubbing) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
scrubTo(e.clientX);
|
||||
});
|
||||
const endScrub = (e) => {
|
||||
if (!status._scrubbing) return;
|
||||
status._scrubbing = false;
|
||||
try { status.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||||
};
|
||||
status.addEventListener("pointerup", endScrub);
|
||||
status.addEventListener("pointercancel", endScrub);
|
||||
|
||||
// Browse button (primary way to set the folder). Full width above the
|
||||
// navigation row.
|
||||
const browseBtn = makeButton("📁 Browse for folder…", "browse", () => {
|
||||
@@ -198,29 +287,79 @@ function attachUI(node) {
|
||||
// The column container stretches children to full width by default.
|
||||
container.appendChild(browseBtn);
|
||||
|
||||
// Button row.
|
||||
// Navigation row.
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display:flex; flex-direction:row; flex-wrap:wrap; gap:5px;";
|
||||
|
||||
row.appendChild(makeButton("⏮ Reset to start", "reset", () => {
|
||||
const resetBtn = makeButton("⏮ Reset to start", "reset", () => {
|
||||
setWidget(idxW, 0);
|
||||
updateStatus(node);
|
||||
}));
|
||||
row.appendChild(makeButton("‹ Prev", "neutral", () => {
|
||||
});
|
||||
const prevBtn = makeButton("‹ Prev", "neutral", () => {
|
||||
const cur = idxW?.value | 0;
|
||||
setWidget(idxW, Math.max(0, cur - 1));
|
||||
updateStatus(node);
|
||||
}));
|
||||
row.appendChild(makeButton("Next ›", "neutral", () => {
|
||||
});
|
||||
const nextBtn = makeButton("Next ›", "neutral", () => {
|
||||
setWidget(idxW, (idxW?.value | 0) + 1);
|
||||
updateStatus(node);
|
||||
}));
|
||||
row.appendChild(makeButton("↻ Rescan", "scan", () => {
|
||||
refreshFileList(node);
|
||||
}));
|
||||
});
|
||||
row.appendChild(resetBtn);
|
||||
row.appendChild(prevBtn);
|
||||
row.appendChild(nextBtn);
|
||||
container.appendChild(row);
|
||||
|
||||
// Image previews of the current + next file, at the bottom.
|
||||
// Hold row — pause on the last or next image. Below the nav buttons,
|
||||
// above Rescan.
|
||||
const holdRow = document.createElement("div");
|
||||
holdRow.style.cssText = "display:flex; flex-direction:row; gap:5px;";
|
||||
const holdLastBtn = makeButton("⏸ Hold last", "hold", () => {
|
||||
if (node._seqHold) resumeHold(node);
|
||||
else enterHold(node, "last");
|
||||
});
|
||||
const holdNextBtn = makeButton("⏸ Hold next", "hold", () => {
|
||||
if (node._seqHold) resumeHold(node);
|
||||
else enterHold(node, "next");
|
||||
});
|
||||
holdRow.appendChild(holdLastBtn);
|
||||
holdRow.appendChild(holdNextBtn);
|
||||
container.appendChild(holdRow);
|
||||
|
||||
// Auto-queue + Rescan row. "Queue all" queues the remaining images one
|
||||
// after another — each run triggers the next — and stops by itself after
|
||||
// the last file (unlike ComfyUI's auto-queue, which loops forever).
|
||||
const rescanRow = document.createElement("div");
|
||||
rescanRow.style.cssText = "display:flex; flex-direction:row; gap:5px;";
|
||||
const autoBtn = makeButton("▶ Queue all", "auto", () => {
|
||||
if (node._seqHold) return;
|
||||
if (node._seqAuto) { // acts as a Stop while running
|
||||
node._seqAuto = false;
|
||||
updateAutoUI(node);
|
||||
return;
|
||||
}
|
||||
if ((node._seqPaths || []).length === 0) return;
|
||||
node._seqAuto = true;
|
||||
updateAutoUI(node);
|
||||
app.queuePrompt(0, 1);
|
||||
});
|
||||
autoBtn.title = "Queue every remaining image, one run after another, "
|
||||
+ "then stop after the last file.";
|
||||
const rescanBtn = makeButton("↻ Rescan", "scan", () => {
|
||||
refreshFileList(node);
|
||||
});
|
||||
rescanRow.appendChild(autoBtn);
|
||||
rescanRow.appendChild(rescanBtn);
|
||||
container.appendChild(rescanRow);
|
||||
|
||||
node._seqBtns = {
|
||||
browse: browseBtn, holdLast: holdLastBtn, holdNext: holdNextBtn,
|
||||
reset: resetBtn, prev: prevBtn, next: nextBtn, rescan: rescanBtn,
|
||||
auto: autoBtn,
|
||||
};
|
||||
|
||||
// Image previews at the bottom: Current (what the last run loaded —
|
||||
// empty until something has been queued) and Next (what the next Queue
|
||||
// will load).
|
||||
const preview = document.createElement("div");
|
||||
preview.style.cssText = "display:flex; gap:6px; margin-top:2px;";
|
||||
const makeCell = (labelText) => {
|
||||
@@ -240,24 +379,22 @@ function attachUI(node) {
|
||||
cell.appendChild(img);
|
||||
return { cell, lab, img };
|
||||
};
|
||||
const curCell = makeCell("Next");
|
||||
const nextCell = makeCell("Next + 1");
|
||||
preview.appendChild(curCell.cell);
|
||||
const currentCell = makeCell("Current");
|
||||
const nextCell = makeCell("Next");
|
||||
preview.appendChild(currentCell.cell);
|
||||
preview.appendChild(nextCell.cell);
|
||||
container.appendChild(preview);
|
||||
node._seqCurImg = curCell.img;
|
||||
node._seqCurrentImg = currentCell.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
|
||||
// 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.
|
||||
// status + browse + nav row + hold row + rescan row (~28px each) plus a
|
||||
// ~115px preview row (two 96px thumbnails with labels). A constant floor
|
||||
// of 306 fits the content without clipping or leaving a gap.
|
||||
const widget = node.addDOMWidget("seqloader_ui", "seqloader_panel", container, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => 238,
|
||||
getMinHeight: () => 306,
|
||||
});
|
||||
|
||||
// Make the panel fill the node's full width: define `width` as a getter
|
||||
@@ -273,29 +410,59 @@ function attachUI(node) {
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// Re-scan whenever the folder path, subfolder toggle, filetype, or
|
||||
// direction changes.
|
||||
// direction changes. Remember the folder path for next time.
|
||||
for (const wn of ["directory", "include_subfolders", "filetype", "reverse"]) {
|
||||
const w = findWidget(node, wn);
|
||||
if (!w) continue;
|
||||
const origCb = w.callback;
|
||||
w.callback = (v, ...rest) => {
|
||||
try { origCb?.call(w, v, ...rest); } catch (e) { /* ignore */ }
|
||||
if (wn === "directory") saveLastDir((v || "").trim());
|
||||
refreshFileList(node);
|
||||
};
|
||||
}
|
||||
|
||||
updateHoldUI(node);
|
||||
|
||||
// Pre-fill a brand-new (empty) node with the last folder used. A
|
||||
// workflow-loaded node has its own saved path restored later by
|
||||
// onConfigure, so this only affects fresh nodes.
|
||||
const dirW = findWidget(node, "directory");
|
||||
if (dirW && !((dirW.value || "").trim())) {
|
||||
const last = loadLastDir();
|
||||
if (last) setWidget(dirW, last);
|
||||
}
|
||||
|
||||
// Initial populate (deferred so the widget value restore can land first).
|
||||
setTimeout(() => refreshFileList(node), 50);
|
||||
}
|
||||
|
||||
const THEMES = {
|
||||
reset: { fg: "#ffe0d0", bg: "rgba(70,50,50,0.95)", border: "rgba(220,140,100,0.9)" },
|
||||
scan: { fg: "#d2f3e2", bg: "rgba(48,66,60,0.95)", border: "rgba(110,200,160,0.9)" },
|
||||
browse: { fg: "#dde7ff", bg: "rgba(48,58,78,0.95)", border: "rgba(120,160,230,0.9)" },
|
||||
hold: { fg: "#ffe9c2", bg: "rgba(74,62,40,0.95)", border: "rgba(220,180,110,0.9)" },
|
||||
resume: { fg: "#d2f3e2", bg: "rgba(40,72,56,0.95)", border: "rgba(110,210,160,0.95)" },
|
||||
auto: { fg: "#e6dcff", bg: "rgba(56,48,80,0.95)", border: "rgba(160,130,230,0.9)" },
|
||||
neutral: { fg: "#ccc", bg: "#2a2a2a", border: "#555" },
|
||||
};
|
||||
|
||||
function setButtonTheme(btn, kind) {
|
||||
const th = THEMES[kind] || THEMES.neutral;
|
||||
btn.style.border = `1px solid ${th.border}`;
|
||||
btn.style.background = th.bg;
|
||||
btn.style.color = th.fg;
|
||||
}
|
||||
|
||||
function setEnabled(btn, on) {
|
||||
if (!btn) return;
|
||||
btn.disabled = !on; // native disabled also blocks the click event
|
||||
btn.style.opacity = on ? "1" : "0.4";
|
||||
btn.style.cursor = on ? "pointer" : "not-allowed";
|
||||
}
|
||||
|
||||
function makeButton(label, kind, onClick) {
|
||||
const themes = {
|
||||
reset: { fg: "#ffe0d0", bg: "rgba(70,50,50,0.95)", border: "rgba(220,140,100,0.9)" },
|
||||
scan: { fg: "#d2f3e2", bg: "rgba(48,66,60,0.95)", border: "rgba(110,200,160,0.9)" },
|
||||
browse: { fg: "#dde7ff", bg: "rgba(48,58,78,0.95)", border: "rgba(120,160,230,0.9)" },
|
||||
neutral: { fg: "#ccc", bg: "#2a2a2a", border: "#555" },
|
||||
};
|
||||
const th = themes[kind] || themes.neutral;
|
||||
const th = THEMES[kind] || THEMES.neutral;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = label;
|
||||
@@ -306,11 +473,148 @@ function makeButton(label, kind, onClick) {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (btn.disabled) return;
|
||||
onClick();
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
// --- Auto-queue ("Queue all"): after each successful run, queue the next
|
||||
// image automatically until the LAST file in the folder has been done,
|
||||
// then stop — no endless wrap-around like ComfyUI's built-in auto-queue.
|
||||
// The stop decision uses the index/total the run *actually executed with*
|
||||
// (reported via the node's ui message), not the frontend widget, so
|
||||
// scrubbing mid-run can't confuse it.
|
||||
|
||||
function seqNodes() {
|
||||
return ((app.graph && app.graph._nodes) || [])
|
||||
.filter((n) => n?.type === NODE_NAME);
|
||||
}
|
||||
|
||||
function updateAutoUI(node) {
|
||||
const btn = node._seqBtns?.auto;
|
||||
if (!btn) return;
|
||||
if (node._seqAuto) {
|
||||
btn.textContent = "■ Stop queueing";
|
||||
setButtonTheme(btn, "reset");
|
||||
} else {
|
||||
btn.textContent = "▶ Queue all";
|
||||
setButtonTheme(btn, "auto");
|
||||
}
|
||||
}
|
||||
|
||||
function stopAllAuto() {
|
||||
for (const n of seqNodes()) {
|
||||
if (n._seqAuto) {
|
||||
n._seqAuto = false;
|
||||
updateAutoUI(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installExecutionHooks() {
|
||||
if (app._SeqLoaderExecHooksInstalled) return;
|
||||
api.addEventListener("execution_success", () => {
|
||||
let queueMore = false;
|
||||
for (const n of seqNodes()) {
|
||||
if (!n._seqAuto || !n._seqRanFlag) continue;
|
||||
n._seqRanFlag = false; // consumed — set again on the next run
|
||||
const pos = n._seqLastExecPos, total = n._seqLastExecTotal;
|
||||
if (typeof pos === "number" && typeof total === "number"
|
||||
&& pos >= total - 1) {
|
||||
n._seqAuto = false; // last image just finished — we're done
|
||||
updateAutoUI(n);
|
||||
try {
|
||||
app.extensionManager?.toast?.add?.({
|
||||
severity: "success",
|
||||
summary: "Sequential Image Loader",
|
||||
detail: `Queue all finished — all ${total} images done.`,
|
||||
life: 5000,
|
||||
});
|
||||
} catch (e) { /* toast API optional */ }
|
||||
} else {
|
||||
queueMore = true;
|
||||
}
|
||||
}
|
||||
// One queue per completed run even if several nodes are auto-active.
|
||||
if (queueMore) setTimeout(() => app.queuePrompt(0, 1), 0);
|
||||
});
|
||||
// Errors / user interrupt end the chain rather than ploughing on.
|
||||
api.addEventListener("execution_error", stopAllAuto);
|
||||
api.addEventListener("execution_interrupted", stopAllAuto);
|
||||
app._SeqLoaderExecHooksInstalled = true;
|
||||
}
|
||||
|
||||
// --- Hold (pause) state. "Hold last" freezes on the image you just got;
|
||||
// "Hold next" freezes on the upcoming one. While held, the queue hook
|
||||
// stops advancing (so every Queue re-emits the held frame) and all the
|
||||
// other controls grey out. Resume restores the position and re-enables
|
||||
// everything. Pure frontend state — Python loads files[index] regardless.
|
||||
function enterHold(node, type) {
|
||||
const idxW = findWidget(node, "index");
|
||||
const total = (node._seqPaths || []).length;
|
||||
if (!idxW || total === 0) return;
|
||||
const k = idxW.value | 0;
|
||||
if (node._seqAuto) { // holding a frame ends an auto-run
|
||||
node._seqAuto = false;
|
||||
updateAutoUI(node);
|
||||
}
|
||||
node._seqResumeIndex = k; // where the sequence continues after Resume
|
||||
node._seqHold = true;
|
||||
node._seqHoldType = type;
|
||||
if (type === "last") {
|
||||
// The frame just rendered is one behind the "next" pointer.
|
||||
setWidget(idxW, ((k - 1) % total + total) % total);
|
||||
}
|
||||
updateHoldUI(node);
|
||||
updateStatus(node);
|
||||
}
|
||||
|
||||
function resumeHold(node) {
|
||||
const idxW = findWidget(node, "index");
|
||||
node._seqHold = false;
|
||||
node._seqHoldType = null;
|
||||
if (idxW && typeof node._seqResumeIndex === "number") {
|
||||
setWidget(idxW, node._seqResumeIndex);
|
||||
}
|
||||
updateHoldUI(node);
|
||||
updateStatus(node);
|
||||
}
|
||||
|
||||
function updateHoldUI(node) {
|
||||
const b = node._seqBtns;
|
||||
if (!b) return;
|
||||
const held = !!node._seqHold;
|
||||
const type = node._seqHoldType;
|
||||
|
||||
if (node._seqStatusEl) {
|
||||
node._seqStatusEl.style.cursor = held ? "default" : "ew-resize";
|
||||
}
|
||||
|
||||
if (!held) {
|
||||
b.holdLast.textContent = "⏸ Hold last";
|
||||
b.holdNext.textContent = "⏸ Hold next";
|
||||
setButtonTheme(b.holdLast, "hold");
|
||||
setButtonTheme(b.holdNext, "hold");
|
||||
for (const key of ["browse", "reset", "prev", "next", "rescan",
|
||||
"auto", "holdLast", "holdNext"]) {
|
||||
setEnabled(b[key], true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Held: grey everything except the active button, which becomes Resume.
|
||||
for (const key of ["browse", "reset", "prev", "next", "rescan", "auto"]) {
|
||||
setEnabled(b[key], false);
|
||||
}
|
||||
const active = type === "last" ? b.holdLast : b.holdNext;
|
||||
const other = type === "last" ? b.holdNext : b.holdLast;
|
||||
active.textContent = "▶ Resume";
|
||||
setButtonTheme(active, "resume");
|
||||
setEnabled(active, true);
|
||||
setEnabled(other, false);
|
||||
}
|
||||
|
||||
// --- Server-side folder picker. Browsers can't hand back a real absolute
|
||||
// path, and the images live on the ComfyUI server anyway, so we browse
|
||||
// the server's filesystem via /seqloader/browse and write the chosen
|
||||
@@ -471,6 +775,7 @@ app.registerExtension({
|
||||
|
||||
async setup() {
|
||||
installQueueHook();
|
||||
installExecutionHooks();
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
@@ -482,7 +787,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, 500];
|
||||
const min = [320, 574];
|
||||
if (this.size[0] < min[0]) this.size[0] = min[0];
|
||||
if (this.size[1] < min[1]) this.size[1] = min[1];
|
||||
};
|
||||
@@ -494,20 +799,47 @@ app.registerExtension({
|
||||
setTimeout(() => refreshFileList(this), 50);
|
||||
};
|
||||
|
||||
// Reflect what was actually loaded after a run.
|
||||
// Reflect what was actually loaded after a run: feeds the "Current"
|
||||
// preview and the auto-queue stop check.
|
||||
const origOnExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
origOnExecuted?.apply(this, arguments);
|
||||
try {
|
||||
const info = message?.seqloader?.[0];
|
||||
if (info && this._seqStatusEl) {
|
||||
// Keep file count fresh; status recompute happens via hook.
|
||||
if (typeof info.total === "number" && this._seqFiles
|
||||
&& this._seqFiles.length !== info.total) {
|
||||
refreshFileList(this);
|
||||
}
|
||||
if (!info) return;
|
||||
this._seqLastExecPos =
|
||||
typeof info.index === "number" ? info.index : null;
|
||||
this._seqLastExecTotal =
|
||||
typeof info.total === "number" ? info.total : null;
|
||||
this._seqRanFlag = true; // this node ran in the active prompt
|
||||
if (info.path) {
|
||||
this._seqCurrentPath = info.path;
|
||||
} else if (typeof info.index === "number") {
|
||||
this._seqCurrentPath =
|
||||
(this._seqPaths || [])[info.index] || "";
|
||||
}
|
||||
// Keep file count fresh if the folder changed server-side.
|
||||
if (typeof info.total === "number" && this._seqFiles
|
||||
&& this._seqFiles.length !== info.total) {
|
||||
refreshFileList(this);
|
||||
}
|
||||
updateStatus(this);
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
// Tidy up thumbnail blobs / in-flight fetches when a node is deleted.
|
||||
const origOnRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
clearTimeout(this._seqThumbTimer);
|
||||
for (const img of [this._seqCurrentImg, this._seqNextImg]) {
|
||||
if (!img) continue;
|
||||
if (img._abort) {
|
||||
try { img._abort.abort(); } catch (e) { /* ignore */ }
|
||||
img._abort = null;
|
||||
}
|
||||
if (img._url) { URL.revokeObjectURL(img._url); img._url = null; }
|
||||
}
|
||||
origOnRemoved?.apply(this, arguments);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user