2 Commits
4 changed files with 243 additions and 32 deletions
+11 -2
View File
@@ -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
+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.2.0"
license = { file = "LICENSE" }
readme = "README.md"
dependencies = []
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 104 KiB

+231 -29
View File
@@ -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 =
`<span style="color:#9cf;font-weight:bold">Next: ${pos + 1} / ${total}</span>`
+ `<span style="color:#888"> &middot; </span>`
@@ -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];
};