1 Commits
Author SHA1 Message Date
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 185 additions and 48 deletions
+11 -5
View File
@@ -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.
@@ -101,7 +106,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.3.0"
license = { file = "LICENSE" }
readme = "README.md"
dependencies = []
+1
View File
@@ -170,6 +170,7 @@ class SequentialImageLoader:
return {
"ui": {"seqloader": [{
"filename": filename,
"path": path,
"index": pos,
"total": total,
}]},
+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);
};
},
});