2 Commits
Author SHA1 Message Date
shootthesound 840d1dbfaa Add file_path output with the absolute path of the loaded image
New STRING output (appended last so existing workflows keep their
link slots) carrying the absolute path of the file loaded this run,
alongside the existing filename output. Bump version to 1.4.0.

Closes #1.
2026-07-24 01:19:01 +01:00
shootthesound 4c6f65975b Add Queue-all auto-queue, Current/Next previews, and bug fixes
- New "Queue all" button: auto-queues the next image as each run
  finishes, stopping automatically after the last file (unlike
  ComfyUI's endless auto-queue). Stops on error/interrupt/hold.
- Bottom previews are now Current (what the last run loaded, empty
  until first queue) and Next (what the next Queue will load);
  Python reports the loaded file path back to the UI.
- Fix HTML injection via filenames in the status line.
- Fix file-list race where a slow scan could clobber a newer one.
- Fix object-URL leaks and clean up timers/fetches on node removal.
- Bump version to 1.3.0.
2026-07-23 23:48:32 +01:00
4 changed files with 193 additions and 53 deletions
+13 -6
View File
@@ -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,6 +31,7 @@ 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.
@@ -40,7 +41,7 @@ The usual way to run a workflow over a folder of images is to swap the file in *
- **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 next two images (**Next** and **Next + 1**) 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
@@ -64,7 +65,9 @@ Restart ComfyUI. No extra Python dependencies — it uses Pillow and torch, whic
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
@@ -78,9 +81,11 @@ At the bottom of the node, two thumbnails show what's coming: **Next** (the imag
| **⏮ 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. |
| **⏸ Hold next** | Freeze on the upcoming image — every Queue re-emits it. Click **▶ Resume** to continue. |
| **⏸ 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.
@@ -93,6 +98,7 @@ While a hold is active, the other buttons grey out; **▶ Resume** restores the
| `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
@@ -101,7 +107,8 @@ 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.
+1 -1
View File
@@ -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.2.0"
version = "1.4.0"
license = { file = "LICENSE" }
readme = "README.md"
dependencies = []
+7 -4
View File
@@ -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),
}
+172 -42
View File
@@ -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";
@@ -72,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;
}
@@ -93,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 = [];
}
@@ -112,6 +120,7 @@ async function setThumb(img, path) {
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;
}
@@ -142,34 +151,37 @@ async function setThumb(img, path) {
function updatePreviews(node) {
const paths = node._seqPaths || [];
const total = paths.length;
const cur = node._seqCurImg, nxt = node._seqNextImg;
if (total === 0) {
clearTimeout(node._seqThumbTimer);
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;
// Labels/titles update instantly; defer the thumbnail fetches a touch so
// rapid Prev/Next clicks coalesce into a single load of the final frame.
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(cur, paths[pos]);
setThumb(nxt, paths[nextPos]);
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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function updateStatus(node) {
updatePreviews(node);
const el = node._seqStatusEl;
@@ -195,10 +207,11 @@ function updateStatus(node) {
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"> &middot; </span>`
+ `<span style="color:#ddd" title="${name}">${name}</span>`;
+ `<span style="color:#ddd" title="${safe}">${safe}</span>`;
}
function attachUI(node) {
@@ -312,21 +325,41 @@ function attachUI(node) {
holdRow.appendChild(holdNextBtn);
container.appendChild(holdRow);
// Rescan row.
// 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 of the current + next file, at the bottom.
// 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) => {
@@ -346,15 +379,13 @@ 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 + nav row + hold row + rescan row (~28px each) plus a
@@ -412,6 +443,7 @@ const THEMES = {
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" },
};
@@ -447,6 +479,72 @@ function makeButton(label, kind, 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
@@ -457,6 +555,10 @@ function enterHold(node, type) {
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;
@@ -495,14 +597,14 @@ function updateHoldUI(node) {
setButtonTheme(b.holdLast, "hold");
setButtonTheme(b.holdNext, "hold");
for (const key of ["browse", "reset", "prev", "next", "rescan",
"holdLast", "holdNext"]) {
"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"]) {
for (const key of ["browse", "reset", "prev", "next", "rescan", "auto"]) {
setEnabled(b[key], false);
}
const active = type === "last" ? b.holdLast : b.holdNext;
@@ -673,6 +775,7 @@ app.registerExtension({
async setup() {
installQueueHook();
installExecutionHooks();
},
async beforeRegisterNodeDef(nodeType, nodeData) {
@@ -696,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);
};
},
});