From 7f0f8b37f2bb6f81f220fa7831fbcb2fc335f233 Mon Sep 17 00:00:00 2001 From: CoCo Date: Fri, 6 Mar 2026 16:48:55 +0000 Subject: [PATCH] feat: add dynamic file browser for load/save nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side /cocotools/browse API endpoint returns directory listings with extension filtering. JS modal file browser injects browse buttons on ImageLoader, LoadExr, LoadExrSequence, and SaverNode path widgets. Zero changes to node INPUT_TYPES — fully backward compatible. Co-Authored-By: Claude Opus 4.6 --- __init__.py | 1 + .../cocotools_rhino_demo.exr} | Bin js/filebrowser.js | 311 ++++++++++++++++++ js/index.js | 1 + utils/server_routes.py | 86 +++++ 5 files changed, 399 insertions(+) rename img/{cocotools_rhino.exr => exr/cocotools_rhino_demo.exr} (100%) create mode 100644 js/filebrowser.js create mode 100644 utils/server_routes.py diff --git a/__init__.py b/__init__.py index 3996bd9..ef5d103 100644 --- a/__init__.py +++ b/__init__.py @@ -9,6 +9,7 @@ from .modules.colorspace import ColorspaceNode from .modules.znormalize import ZNormalizeNode from .modules.histogram import HistogramNode from .modules.vectorscope import VectorscopeNode +from .utils import server_routes # noqa: F401 — registers /cocotools/browse API route # Initialize node mappings NODE_CLASS_MAPPINGS = {} diff --git a/img/cocotools_rhino.exr b/img/exr/cocotools_rhino_demo.exr similarity index 100% rename from img/cocotools_rhino.exr rename to img/exr/cocotools_rhino_demo.exr diff --git a/js/filebrowser.js b/js/filebrowser.js new file mode 100644 index 0000000..f247692 --- /dev/null +++ b/js/filebrowser.js @@ -0,0 +1,311 @@ +import { app } from "../../../scripts/app.js"; +import { api } from "../../../scripts/api.js"; + +// --- File Browser Modal --- + +async function fetchDirectory(dirPath, extensions) { + const params = new URLSearchParams(); + if (dirPath) params.set("path", dirPath); + if (extensions) params.set("extensions", extensions); + const resp = await api.fetchApi(`/cocotools/browse?${params.toString()}`); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${resp.status}`); + } + return resp.json(); +} + +function formatSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`; + return `${(bytes / 1073741824).toFixed(1)} GB`; +} + +function clearChildren(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} + +function openFileBrowser({ startPath = "", extensions = "", title = "Browse", mode = "file", onSelect }) { + // Overlay + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center;"; + + // Modal container + const modal = document.createElement("div"); + modal.style.cssText = "background:#1a1a1a;border:1px solid #444;border-radius:8px;width:600px;max-height:80vh;display:flex;flex-direction:column;font-family:monospace;color:#ddd;box-shadow:0 8px 32px rgba(0,0,0,0.5);"; + + // Header + const header = document.createElement("div"); + header.style.cssText = "padding:12px 16px;border-bottom:1px solid #333;display:flex;align-items:center;gap:8px;"; + + const titleEl = document.createElement("span"); + titleEl.textContent = title; + titleEl.style.cssText = "font-size:14px;font-weight:bold;color:#fff;"; + header.appendChild(titleEl); + + const spacer = document.createElement("span"); + spacer.style.cssText = "flex:1;"; + header.appendChild(spacer); + + const closeBtn = document.createElement("button"); + closeBtn.textContent = "\u2715"; + closeBtn.style.cssText = "background:none;border:none;color:#888;font-size:18px;cursor:pointer;padding:0 4px;"; + closeBtn.addEventListener("click", close); + header.appendChild(closeBtn); + modal.appendChild(header); + + // Path bar + const pathBar = document.createElement("div"); + pathBar.style.cssText = "padding:8px 16px;border-bottom:1px solid #333;display:flex;gap:6px;align-items:center;"; + + const upBtn = document.createElement("button"); + upBtn.textContent = "\u2191"; + upBtn.title = "Parent directory"; + upBtn.style.cssText = "background:#2a2a2a;border:1px solid #444;color:#ddd;font-size:14px;cursor:pointer;padding:4px 8px;border-radius:4px;"; + pathBar.appendChild(upBtn); + + const pathInput = document.createElement("input"); + pathInput.type = "text"; + pathInput.style.cssText = "flex:1;background:#111;border:1px solid #444;color:#ddd;font-family:monospace;font-size:12px;padding:6px 8px;border-radius:4px;outline:none;"; + pathInput.placeholder = "Enter path..."; + pathBar.appendChild(pathInput); + modal.appendChild(pathBar); + + // Error display + const errorEl = document.createElement("div"); + errorEl.style.cssText = "padding:4px 16px;color:#f66;font-size:11px;display:none;"; + modal.appendChild(errorEl); + + // File list + const listContainer = document.createElement("div"); + listContainer.style.cssText = "flex:1;overflow-y:auto;min-height:200px;max-height:50vh;"; + modal.appendChild(listContainer); + + // Footer (for directory mode) + const footer = document.createElement("div"); + footer.style.cssText = "padding:10px 16px;border-top:1px solid #333;display:flex;justify-content:flex-end;gap:8px;"; + + if (mode === "directory") { + const selectFolderBtn = document.createElement("button"); + selectFolderBtn.textContent = "Select This Folder"; + selectFolderBtn.style.cssText = "background:#2563eb;border:none;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;border-radius:4px;cursor:pointer;"; + selectFolderBtn.addEventListener("click", () => { + const currentPath = pathInput.value.trim(); + if (currentPath) { + onSelect(currentPath); + close(); + } + }); + footer.appendChild(selectFolderBtn); + } + + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = "Cancel"; + cancelBtn.style.cssText = "background:#333;border:1px solid #555;color:#ddd;font-family:monospace;font-size:12px;padding:6px 16px;border-radius:4px;cursor:pointer;"; + cancelBtn.addEventListener("click", close); + footer.appendChild(cancelBtn); + modal.appendChild(footer); + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + let currentPath = ""; + + function close() { + document.body.removeChild(overlay); + document.removeEventListener("keydown", onKey); + } + + function onKey(e) { + if (e.key === "Escape") close(); + } + document.addEventListener("keydown", onKey); + + overlay.addEventListener("click", (e) => { + if (e.target === overlay) close(); + }); + + async function navigate(dirPath) { + errorEl.style.display = "none"; + clearChildren(listContainer); + + const loading = document.createElement("div"); + loading.textContent = "Loading..."; + loading.style.cssText = "padding:20px;text-align:center;color:#888;"; + listContainer.appendChild(loading); + + try { + const data = await fetchDirectory(dirPath, extensions); + currentPath = data.path || ""; + pathInput.value = currentPath; + clearChildren(listContainer); + + if (data.items.length === 0) { + const empty = document.createElement("div"); + empty.textContent = "Empty directory"; + empty.style.cssText = "padding:20px;text-align:center;color:#666;"; + listContainer.appendChild(empty); + return; + } + + for (const item of data.items) { + const row = document.createElement("div"); + row.style.cssText = "padding:6px 16px;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:12px;border-bottom:1px solid #222;"; + + row.addEventListener("mouseenter", () => { row.style.background = "#2a2a2a"; }); + row.addEventListener("mouseleave", () => { row.style.background = "none"; }); + + const icon = document.createElement("span"); + icon.style.cssText = "font-size:14px;width:20px;text-align:center;flex-shrink:0;"; + + const name = document.createElement("span"); + name.style.cssText = "flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"; + name.textContent = item.name; + + if (item.type === "directory") { + icon.textContent = "\uD83D\uDCC1"; + name.style.color = "#7cb3f0"; + row.addEventListener("click", () => { + navigate(item.path || (currentPath ? currentPath + "/" + item.name : item.name)); + }); + } else { + icon.textContent = "\uD83D\uDCC4"; + const sizeEl = document.createElement("span"); + sizeEl.textContent = formatSize(item.size || 0); + sizeEl.style.cssText = "color:#888;font-size:11px;flex-shrink:0;"; + row.appendChild(icon); + row.appendChild(name); + row.appendChild(sizeEl); + + if (mode === "file") { + row.addEventListener("click", () => { + const filePath = currentPath ? currentPath + "/" + item.name : item.name; + onSelect(filePath); + close(); + }); + } + listContainer.appendChild(row); + continue; + } + + row.appendChild(icon); + row.appendChild(name); + listContainer.appendChild(row); + } + } catch (err) { + clearChildren(listContainer); + errorEl.textContent = err.message; + errorEl.style.display = "block"; + } + } + + upBtn.addEventListener("click", () => { + if (currentPath) { + const parent = currentPath.replace(/[\\/][^\\/]*$/, "") || ""; + navigate(parent); + } + }); + + pathInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + navigate(pathInput.value.trim()); + } + }); + + navigate(startPath); +} + +// --- Sequence pattern detection --- + +function detectSequencePattern(filePath) { + const dir = filePath.replace(/[\\/][^\\/]*$/, ""); + const filename = filePath.split(/[\\/]/).pop(); + // Find trailing digits before extension: render_0001.exr -> render_####.exr + const match = filename.match(/^(.*?)(\d+)(\.[^.]+)$/); + if (!match) return filePath; + const prefix = match[1]; + const digits = match[2]; + const ext = match[3]; + const hashes = "#".repeat(digits.length); + return dir + "/" + prefix + hashes + ext; +} + +// --- Browse button injection --- + +const BROWSE_TARGETS = [ + { nodeClass: "ImageLoader", widget: "image_path", extensions: "png,jpg,jpeg,tiff,tif,webp,bmp,exr", mode: "file" }, + { nodeClass: "LoadExr", widget: "image_path", extensions: "exr", mode: "file" }, + { nodeClass: "LoadExrSequence", widget: "sequence_path", extensions: "exr", mode: "file" }, + { nodeClass: "SaverNode", widget: "file_path", extensions: "", mode: "directory" }, +]; + +function injectBrowseButton(node, targetWidget, extensions, mode, isSequence) { + // Find the target widget + const widget = node.widgets?.find(w => w.name === targetWidget); + if (!widget) return; + + // Create the browse button as a DOM widget + const btn = document.createElement("button"); + btn.textContent = "\uD83D\uDCC2 Browse"; + btn.title = mode === "directory" ? "Browse for folder" : "Browse for file"; + btn.style.cssText = "background:#2a2a2a;border:1px solid #555;color:#ddd;font-family:monospace;font-size:11px;padding:4px 10px;border-radius:4px;cursor:pointer;width:100%;"; + + btn.addEventListener("mouseenter", () => { btn.style.background = "#3a3a3a"; }); + btn.addEventListener("mouseleave", () => { btn.style.background = "#2a2a2a"; }); + + btn.addEventListener("click", () => { + const currentValue = widget.value || ""; + let startPath = ""; + if (currentValue) { + // Try to start from directory of current value + const dirPart = currentValue.replace(/[\\/][^\\/]*$/, ""); + if (dirPart && dirPart !== currentValue) { + startPath = dirPart; + } else { + startPath = currentValue; + } + } + + openFileBrowser({ + startPath, + extensions, + title: mode === "directory" ? "Select Folder" : "Select File", + mode, + onSelect: (selectedPath) => { + if (isSequence) { + widget.value = detectSequencePattern(selectedPath); + } else { + widget.value = selectedPath; + } + widget.callback?.(widget.value); + app.graph.setDirtyCanvas(true); + }, + }); + }); + + const browseWidget = node.addDOMWidget(`${targetWidget}_browse`, "btn", btn, { + serialize: false, + getMinHeight: () => 26, + }); + browseWidget.computeSize = () => [200, 30]; +} + +app.registerExtension({ + name: "CocoTools.FileBrowser", + async nodeCreated(node) { + const nodeClass = node.constructor.type || node.type; + for (const target of BROWSE_TARGETS) { + if (nodeClass === target.nodeClass) { + const isSequence = target.nodeClass === "LoadExrSequence"; + // Defer to next frame so all widgets are created + requestAnimationFrame(() => { + injectBrowseButton(node, target.widget, target.extensions, target.mode, isSequence); + }); + break; + } + } + }, +}); diff --git a/js/index.js b/js/index.js index 924d06d..87b9572 100644 --- a/js/index.js +++ b/js/index.js @@ -4,5 +4,6 @@ import "./load_exr_layer_by_name.js"; import "./histogram.js"; import "./vectorscope.js"; import "./cryptomatte.js"; +import "./filebrowser.js"; console.log("CoCoTools_IO extensions loaded successfully"); \ No newline at end of file diff --git a/utils/server_routes.py b/utils/server_routes.py new file mode 100644 index 0000000..864c406 --- /dev/null +++ b/utils/server_routes.py @@ -0,0 +1,86 @@ +import os +import logging + +import server +from aiohttp import web + +logger = logging.getLogger(__name__) + + +@server.PromptServer.instance.routes.get("/cocotools/browse") +async def browse_directory(request): + """Return directory listing with optional extension filtering.""" + query = request.rel_url.query + path = query.get("path", "").strip() + extensions = query.get("extensions", "").strip() + + ext_set = set() + if extensions: + ext_set = {e.strip().lower().lstrip(".") for e in extensions.split(",")} + + # No path provided — return starting points + if not path: + items = [] + home = os.path.expanduser("~") + if os.path.isdir(home): + items.append({"name": os.path.basename(home) or "home", "path": home, "type": "directory"}) + # Add OS roots + if os.name == "nt": + import string + for letter in string.ascii_uppercase: + drive = f"{letter}:\\" + if os.path.isdir(drive): + items.append({"name": drive, "path": drive, "type": "directory"}) + else: + items.append({"name": "/", "path": "/", "type": "directory"}) + return web.json_response({"path": "", "parent": "", "items": items}) + + # Normalize and validate path + path = os.path.normpath(os.path.expanduser(path)) + if not os.path.isdir(path): + return web.json_response( + {"error": f"Not a directory: {path}"}, + status=400, + ) + + parent = os.path.dirname(path) + if parent == path: + parent = "" + + items = [] + try: + entries = os.listdir(path) + except PermissionError: + return web.json_response( + {"error": f"Permission denied: {path}"}, + status=403, + ) + + dirs = [] + files = [] + + for entry in entries: + # Skip hidden files + if entry.startswith("."): + continue + full = os.path.join(path, entry) + try: + if os.path.isdir(full): + dirs.append({"name": entry, "type": "directory"}) + elif os.path.isfile(full): + ext = os.path.splitext(entry)[1].lower().lstrip(".") + if ext_set and ext not in ext_set: + continue + try: + size = os.path.getsize(full) + except OSError: + size = 0 + files.append({"name": entry, "type": "file", "size": size}) + except OSError: + continue + + dirs.sort(key=lambda d: d["name"].lower()) + files.sort(key=lambda f: f["name"].lower()) + items = dirs + files + + return web.json_response({"path": path, "parent": parent, "items": items})