/** * YFG Random Image From Directory β€” UI Extension v1.2.0 * * Adds two native buttons to the RandomImageFromDirectory_node: * πŸ“ Browse for Directory β€” navigable server-side directory browser modal * πŸ• Recent Directories β€” MRU list from /yfg/dir_history * * v1.2.0: Inline output slot value display β€” shows index, dimensions, * total count and previous index directly next to each output slot. * * @author Manny Gonzalez * @title 🐯 YFG Comical Nodes * @version 1.2.1 */ import { app } from "../../../scripts/app.js"; import { api } from "../../../scripts/api.js"; const NODE_TYPE = "RandomImageFromDirectory_node"; // Output slot indices that get inline value display. // Keys match what Python sends as flat ui dict keys. const DISPLAY_SLOTS = { yfg_index_current: 2, yfg_width: 4, yfg_height: 5, yfg_total_count: 7, yfg_index_previous: 9, }; console.log("[YFG] RandomImageFromDirectory JS extension loading…"); // ─────────────────────────── API helpers ───────────────────────────────────── async function apiGet(path) { const r = await fetch(path); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); } async function apiPost(path, body) { const r = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); } // ─────────────────────────── tiny DOM helpers ───────────────────────────────── function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text) e.textContent = text; return e; } function btn(label, cls, onclick) { const b = el("button", cls, label); b.type = "button"; b.onclick = onclick; return b; } // ─────────────────────────── CSS (injected once) ────────────────────────────── let _stylesInjected = false; function ensureStyles() { if (_stylesInjected) return; _stylesInjected = true; const css = ` /* ── overlay / modal ──────────────────────────────────────── */ .yfg-overlay { position: fixed; inset: 0; z-index: 99999; background: rgba(0,0,0,.65); display: flex; align-items: center; justify-content: center; } .yfg-modal { background: #1e1e2e; color: #cdd6f4; border: 1px solid #45475a; border-radius: 10px; width: min(680px, 93vw); max-height: 82vh; display: flex; flex-direction: column; font-family: ui-monospace, "Cascadia Code", monospace; font-size: 13px; box-shadow: 0 12px 60px rgba(0,0,0,.85); overflow: hidden; } .yfg-modal-header { padding: 11px 16px; background: #181825; border-bottom: 1px solid #45475a; display: flex; align-items: center; gap: 8px; } .yfg-modal-title { font-weight: 700; font-size: 14px; color: #cba6f7; flex: 1; } .yfg-breadcrumb { padding: 7px 16px; background: #11111b; border-bottom: 1px solid #313244; font-size: 11px; color: #a6adc8; word-break: break-all; min-height: 30px; display: flex; align-items: center; } .yfg-dir-list { flex: 1; overflow-y: auto; padding: 4px 0; border-bottom: 1px solid #313244; } .yfg-dir-item { padding: 8px 16px; cursor: pointer; display: flex; align-items: center; gap: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background .1s; user-select: none; } .yfg-dir-item:hover { background: #313244; } .yfg-dir-item.yfg-up { color: #89b4fa; font-style: italic; } .yfg-dir-name { overflow: hidden; text-overflow: ellipsis; } .yfg-modal-footer { padding: 10px 16px; background: #181825; display: flex; gap: 8px; justify-content: flex-end; border-top: 1px solid #45475a; } /* ── shared button styles ─────────────────────────────────── */ .yfg-btn { padding: 6px 14px; border-radius: 6px; border: none; cursor: pointer; font-size: 12px; font-weight: 700; transition: opacity .15s, filter .15s; } .yfg-btn:hover { opacity: .85; } .yfg-btn:active { filter: brightness(.85); } .yfg-btn-primary { background: #cba6f7; color: #1e1e2e; } .yfg-btn-secondary { background: #45475a; color: #cdd6f4; } .yfg-btn-danger { background: #f38ba8; color: #1e1e2e; } .yfg-btn-sm { padding: 3px 10px; font-size: 11px; } /* ── states ───────────────────────────────────────────────── */ .yfg-empty { padding: 28px; text-align: center; color: #6c7086; white-space: pre-line; } .yfg-spinner { text-align: center; padding: 22px; color: #89dceb; } .yfg-err { padding: 14px 16px; color: #f38ba8; } /* ── history list (inside modal) ──────────────────────────── */ .yfg-history-item { padding: 9px 16px; cursor: pointer; display: flex; align-items: center; gap: 9px; border-bottom: 1px solid #313244; transition: background .1s; } .yfg-history-item:hover { background: #313244; } .yfg-history-item:last-of-type { border-bottom: none; } .yfg-history-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #cdd6f4; font-size: 12px; } .yfg-history-del { flex-shrink: 0; color: #f38ba8; cursor: pointer; font-size: 15px; line-height: 1; padding: 0 3px; opacity: .6; transition: opacity .1s; } .yfg-history-del:hover { opacity: 1; } .yfg-history-clear-row { padding: 8px 16px; display: flex; justify-content: flex-end; border-top: 1px solid #45475a; background: #181825; } `; const s = document.createElement("style"); s.textContent = css; document.head.appendChild(s); } // ─────────────────────────── Directory Browser Modal ───────────────────────── function openBrowseModal(startPath, onSelect) { ensureStyles(); let activePath = (startPath || "").trim(); const overlay = el("div", "yfg-overlay"); const modal = el("div", "yfg-modal"); const header = el("div", "yfg-modal-header"); const title = el("span", "yfg-modal-title", "πŸ“ Browse for Directory"); const breadcrumb = el("div", "yfg-breadcrumb", activePath || "Loading…"); const listWrap = el("div", "yfg-dir-list"); const footer = el("div", "yfg-modal-footer"); const cancelBtn = btn("Cancel", "yfg-btn yfg-btn-secondary", () => overlay.remove()); const selectBtn = btn("βœ… Use This Directory", "yfg-btn yfg-btn-primary", () => { if (activePath) { onSelect(activePath); overlay.remove(); } }); selectBtn.disabled = !activePath; header.append(title); footer.append(cancelBtn, selectBtn); modal.append(header, breadcrumb, listWrap, footer); overlay.append(modal); document.body.append(overlay); overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); }); async function navigate(path) { listWrap.innerHTML = ""; listWrap.append(el("div", "yfg-spinner", "⏳ Loading…")); try { const url = path ? `/yfg/dir_browse?path=${encodeURIComponent(path)}` : `/yfg/dir_browse`; const data = await apiGet(url); activePath = data.path || path || ""; breadcrumb.textContent = activePath || "Filesystem Roots"; selectBtn.disabled = !activePath; listWrap.innerHTML = ""; if (data.parent !== null && data.parent !== undefined) { const upRow = el("div", "yfg-dir-item yfg-up"); upRow.innerHTML = `⬆️.. up one level`; upRow.title = data.parent; upRow.onclick = () => navigate(data.parent); listWrap.append(upRow); } if (!data.dirs || data.dirs.length === 0) { listWrap.append(el("div", "yfg-empty", "No subdirectories here.")); return; } for (const d of data.dirs) { const row = el("div", "yfg-dir-item"); row.title = d.path; row.innerHTML = `πŸ“`; row.querySelector(".yfg-dir-name").textContent = d.name; row.onclick = () => navigate(d.path); listWrap.append(row); } } catch (e) { listWrap.innerHTML = ""; listWrap.append(el("div", "yfg-err", `⚠️ ${e.message}`)); } } navigate(activePath || ""); } // ─────────────────────────── History Modal ─────────────────────────────────── function openHistoryModal(onSelect) { ensureStyles(); const overlay = el("div", "yfg-overlay"); const modal = el("div", "yfg-modal"); const header = el("div", "yfg-modal-header"); const title = el("span", "yfg-modal-title", "πŸ• Recent Directories"); const listWrap = el("div", "yfg-dir-list"); const footer = el("div", "yfg-modal-footer"); const closeBtn = btn("Close", "yfg-btn yfg-btn-secondary", () => overlay.remove()); header.append(title); footer.append(closeBtn); modal.append(header, listWrap, footer); overlay.append(modal); document.body.append(overlay); overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); }); async function render() { listWrap.innerHTML = ""; listWrap.append(el("div", "yfg-spinner", "⏳ Loading…")); try { const history = await apiGet("/yfg/dir_history"); listWrap.innerHTML = ""; if (!history || history.length === 0) { listWrap.append(el("div", "yfg-empty", "No history yet.\n\nDirectories are saved automatically\neach time the node runs.")); return; } for (const dir of history) { const row = el("div", "yfg-history-item"); const label = el("span", "yfg-history-label", dir); const del = el("span", "yfg-history-del", "βœ•"); label.title = dir; label.onclick = () => { onSelect(dir); overlay.remove(); }; del.title = "Remove from history"; del.onclick = async ev => { ev.stopPropagation(); await apiPost("/yfg/dir_history/remove", { directory: dir }); render(); }; row.append(label, del); listWrap.append(row); } const clearRow = el("div", "yfg-history-clear-row"); const clearBtn = btn("πŸ—‘ Clear All History", "yfg-btn yfg-btn-danger yfg-btn-sm", async () => { await apiPost("/yfg/dir_history/clear", {}); render(); }); clearRow.append(clearBtn); listWrap.append(clearRow); } catch (e) { listWrap.innerHTML = ""; listWrap.append(el("div", "yfg-err", `⚠️ ${e.message}`)); } } render(); } // ─────────────────────────── Output slot value display ─────────────────────── // Update the display-enabled output slots with their current values. // Uses slot.label (display-only, not reset by ComfyUI) instead of slot.name. // Wrapped in setTimeout to run after ComfyUI's post-execution node refresh. function applyOutputValues(node, output) { setTimeout(() => { for (const [key, slotIdx] of Object.entries(DISPLAY_SLOTS)) { const raw = output?.[key]; if (raw === undefined || raw === null) continue; const value = Array.isArray(raw) ? raw[0] : raw; if (value === undefined) continue; const slot = node.outputs?.[slotIdx]; if (!slot) continue; // origName: prefer stored name, else current label/name minus any prefix let origName = node._yfgOrigOutputNames?.[slotIdx]; if (!origName) { const src = slot.label || slot.name || ""; const parts = src.split(" "); origName = parts.length > 1 ? parts.slice(1).join(" ") : src; } const displayVal = (key === "yfg_index_previous" && value === -1) ? "-" : String(value); // Set label (display override) β€” ComfyUI doesn't reset this slot.label = `${displayVal} ${origName}`; // For index_current: also copy value into the INDEX input widget // so switching to by_index needs no manual typing. if (key === "yfg_index_current") { const idxWidget = node.widgets?.find(w => w.name === "index"); if (idxWidget) idxWidget.value = value; } } // Force full canvas redraw if (app.canvas?.draw) { app.canvas.draw(true, true); } else { node.setDirtyCanvas(true, true); } }, 100); } // ─────────────────────────── ComfyUI Extension ─────────────────────────────── app.registerExtension({ name: "YFG.RandomImageFromDirectory", // ── Listen for execution results and update output slot labels ──────────── async setup() { api.addEventListener("executed", ({ detail }) => { const nodeId = parseInt(detail.node ?? detail.nodeId); if (isNaN(nodeId)) return; const node = app.graph.getNodeById(nodeId); if (!node) return; if (node.type !== NODE_TYPE && node.comfyClass !== NODE_TYPE) return; const output = detail?.output; if (!output) return; applyOutputValues(node, output); }); }, async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name !== NODE_TYPE) return; console.log("[YFG] Hooking RandomImageFromDirectory_node…"); const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const result = onNodeCreated?.apply(this, arguments); const self = this; // ── Store original output names (once) for value prepending ─────── self._yfgOrigOutputNames = (self.outputs ?? []).map(o => o.name); // ── Browse / Recent buttons ─────────────────────────────────────── const dirWidget = self.widgets?.find(w => w.name === "image_directory"); if (!dirWidget) { console.warn("[YFG] image_directory widget not found on node."); return result; } function applyPath(selectedPath) { dirWidget.value = selectedPath; if (typeof dirWidget.callback === "function") { dirWidget.callback(selectedPath); } app.graph.setDirtyCanvas(true, true); } self.addWidget( "button", "πŸ“ Browse for Directory", "browse", () => openBrowseModal(dirWidget.value || "", applyPath) ); self.addWidget( "button", "πŸ• Recent Directories", "recent", () => openHistoryModal(applyPath) ); console.log("[YFG] βœ… Browse + Recent buttons added to node."); return result; }; }, });