diff --git a/README.md b/README.md
index 591af34..54f53b4 100644
--- a/README.md
+++ b/README.md
@@ -34,10 +34,13 @@ The usual way to run a workflow over a folder of images is to swap the file in *
- **📁 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** 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.
## Install
@@ -59,7 +62,7 @@ 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).
@@ -74,8 +77,13 @@ 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. |
+| **⏸ Hold next** | Freeze on the upcoming image — every Queue re-emits it. Click **▶ Resume** to continue. |
| **↻ Rescan** | Re-read the folder (after adding/removing files). |
+While a hold is active, the other buttons grey out; **▶ Resume** restores the sequence from where it was about to go.
+
## Outputs
| Output | Type | Notes |
@@ -95,6 +103,7 @@ ComfyUI only re-runs a node when one of its inputs changes, so a hidden `index`
- **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.
- **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
diff --git a/pyproject.toml b/pyproject.toml
index 74dccd1..c292c4f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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.2.0"
license = { file = "LICENSE" }
readme = "README.md"
dependencies = []
diff --git a/screenshots/node.png b/screenshots/node.png
index fec2d85..9f54bfa 100644
Binary files a/screenshots/node.png and b/screenshots/node.png differ
diff --git a/web/seqloader.js b/web/seqloader.js
index 4eaf41a..b14bbce 100644
--- a/web/seqloader.js
+++ b/web/seqloader.js
@@ -24,6 +24,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 +52,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;
@@ -92,19 +104,27 @@ 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 = "";
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,7 +132,10 @@ 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;
}
}
@@ -121,6 +144,7 @@ function updatePreviews(node) {
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";
@@ -130,8 +154,8 @@ function updatePreviews(node) {
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]);
+ // 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 baseName = (p) => (p || "").split(/[\\/]/).pop();
if (node._seqCurImg) node._seqCurImg.title = baseName(paths[pos]);
if (node._seqNextImg) node._seqNextImg.title = baseName(paths[nextPos]);
@@ -139,6 +163,11 @@ function updatePreviews(node) {
if (node._seqNextLab) {
node._seqNextLab.textContent = (total === 1) ? "Next + 1 (loops)" : "Next + 1";
}
+ clearTimeout(node._seqThumbTimer);
+ node._seqThumbTimer = setTimeout(() => {
+ setThumb(cur, paths[pos]);
+ setThumb(nxt, paths[nextPos]);
+ }, 80);
}
function updateStatus(node) {
@@ -150,6 +179,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,6 +191,10 @@ 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}%)`;
el.innerHTML =
`Next: ${pos + 1} / ${total}`
+ ` · `
@@ -190,6 +224,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,28 +274,58 @@ 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);
+ // 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);
+
+ // Rescan row.
+ const rescanRow = document.createElement("div");
+ rescanRow.style.cssText = "display:flex; flex-direction:row; gap:5px;";
+ const rescanBtn = makeButton("↻ Rescan", "scan", () => {
+ refreshFileList(node);
+ });
+ rescanRow.appendChild(rescanBtn);
+ container.appendChild(rescanRow);
+
+ node._seqBtns = {
+ browse: browseBtn, holdLast: holdLastBtn, holdNext: holdNextBtn,
+ reset: resetBtn, prev: prevBtn, next: nextBtn, rescan: rescanBtn,
+ };
+
// Image previews of the current + next file, at the bottom.
const preview = document.createElement("div");
preview.style.cssText = "display:flex; gap:6px; margin-top:2px;";
@@ -251,13 +357,13 @@ function attachUI(node) {
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 +379,58 @@ 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)" },
+ 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 +441,78 @@ function makeButton(label, kind, onClick) {
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
+ if (btn.disabled) return;
onClick();
});
return btn;
}
+// --- 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;
+ 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",
+ "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"]) {
+ 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
@@ -482,7 +684,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];
};