feat: add dynamic file browser for load/save nodes
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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");
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user