4 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
shootthesound 1711a01929 Add hold/pause, last-folder memory, scrub bar, and fast-click abort fix 2026-06-09 01:54:33 +01:00
shootthesound 1bf88f335f Update README screenshot to show image previews 2026-06-09 00:50:38 +01:00
5 changed files with 424 additions and 73 deletions
+21 -5
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,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
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.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

+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),
}
+395 -63
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";
@@ -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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[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"> &middot; </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);
};
},
});