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]; };