Files
ausboss-ComfyUI-AusBoss/js/shared/media_picker.mjs
ausboss 4fe448ccbe Video Crop + Rotate + Pad: pick an output video from a list, nothing is typed
The second source tab was Server file, a box for a typed path. It is now
Outputs: the videos in ComfyUI's output folder, newest first, in the same
list with a preview that the Uploads tab uses. The list asks the server
each time it opens (/ausboss/transform/video/outputs, no parameters), so a
video rendered a moment ago is in it.

The picked video is saved the way ComfyUI names a file in one of its
folders ("clip.mp4 [output]"). resolve_server_file reads that form and
nothing else: a whole path, a drive, "~", ".." and a network path are
refused from the text alone, and a link that leads out is refused once the
path is resolved. A workflow saved with a typed path shows nothing picked
and a line that asks for the pick.
2026-10-13 11:45:31 -05:00

285 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Media picker: the Source list of the nodes that load a file from
// ComfyUI's input folder (Load Image + Pad, Load Video, the crop / rotate /
// pad editors). A native <select> draws its option list itself, so hovering
// a name there has no events and nothing to show - you pick by filename
// alone. This list previews the file under the pointer (or the arrow keys)
// beside it, a picture for images and a muted loop for videos, and narrows
// as you type.
//
// The combo widget stays the single source of truth: the picker reads its
// options when it opens and hands the chosen value back through onChange.
// Pure decisions live in media_list.mjs for node:test; this file is the DOM.
import { BRAND } from "./index.mjs";
import { isAppShortcut, keepKeyInField } from "./canvas_passthrough.mjs";
import { filterMedia, mediaLabel, moveHighlight } from "./media_list.mjs";
const CSS_ID = "ausboss-media-picker-css";
const LIST_CAP = 400; // a crowded input folder shows this many; typing narrows it
const PREVIEW_DELAY_MS = 70; // skimming down the list does not start a load per row
const MONO = 'ui-monospace,"SF Mono",Menlo,Consolas,monospace';
let openMenu = null;
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-media-pick{display:flex;align-items:center;min-width:0;text-align:left;cursor:pointer}
.ausboss-media-pick-text{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
.ausboss-media-pick-text.empty{color:#6f8886;font-weight:500}
.ausboss-media-pick.open{border-color:${BRAND}}
.ausboss-media-menu{position:fixed;z-index:10000;display:flex;gap:8px;padding:6px;border:1px solid #3a4047;border-radius:8px;background:#1c1f23;box-shadow:0 10px 32px rgba(0,0,0,.55);color:#d7dde2;font:12px/1.3 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.ausboss-media-col{display:flex;flex-direction:column;gap:6px;width:280px;min-width:0}
.ausboss-media-filter{box-sizing:border-box;width:100%;height:28px;padding:0 8px;border:1px solid #2a3437;border-radius:6px;outline:0;background:#0b0f10;color:#eef7f6;font:12px ${MONO}}
.ausboss-media-filter:focus{border-color:${BRAND}}
.ausboss-media-list{max-height:min(320px,50vh);overflow:auto;overscroll-behavior:contain}
.ausboss-media-item{display:flex;min-width:0;padding:5px 8px;border-radius:5px;cursor:pointer;white-space:nowrap;font:12px ${MONO}}
.ausboss-media-item.hot{background:#2c3238;color:#fff}
.ausboss-media-item.current .ausboss-media-name{color:${BRAND}}
.ausboss-media-folder{flex:none;color:#6f8886}
.ausboss-media-name{min-width:0;overflow:hidden;text-overflow:ellipsis}
.ausboss-media-more{padding:6px 8px;color:#6f8886;font-size:11px}
.ausboss-media-pane{display:flex;flex-direction:column;gap:6px;width:240px}
.ausboss-media-stage{display:flex;align-items:center;justify-content:center;height:240px;overflow:hidden;border:1px solid #2a3437;border-radius:6px;background:#0b0f10 repeating-conic-gradient(#151a1c 0 25%,#0b0f10 0 50%) 0 0/16px 16px}
.ausboss-media-stage img,.ausboss-media-stage video{display:block;max-width:100%;max-height:100%;object-fit:contain}
.ausboss-media-caption{min-height:14px;color:#8ba3a1;font:11px ${MONO};text-align:center}
.ausboss-media-note{padding:0 12px;color:#6f8886;font-size:11px;text-align:center}
`;
document.head.appendChild(style);
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
export function closeMediaMenu() {
openMenu?.close();
}
/**
* @param {object} options
* @param {"image"|"video"} options.kind what the list holds; videos preview as a muted loop
* @param {string} [options.className] extra classes for the button, e.g. the look of the select it replaces
* @param {string} options.placeholder button text when nothing is chosen
* @param {(value: string) => string} options.viewUrl URL that serves one value (ComfyUI's /view)
* @param {() => unknown[]} options.getOptions the combo's current values, read on open
* @param {() => unknown} options.getValue the chosen value
* @param {(value: string) => void} options.onChange writes a newly chosen value back to the widget
* @param {string} [options.label] accessible name
* @param {() => Promise<unknown[]>} [options.loadOptions] a fresh list, asked for each time the list opens;
* the list opens with getOptions() and is drawn again when this answers
* @param {string} [options.folder] the folder the list holds, for its "nothing here" lines
* @param {string} [options.emptyNote] the line an empty list shows
*/
export function createMediaPicker({ kind = "image", className = "", placeholder, viewUrl, getOptions, getValue, onChange, label, loadOptions, folder = "input", emptyNote }) {
ensureCss();
const noun = kind === "video" ? "video" : "image";
const button = el("button", `ausboss-media-pick ${className}`.trim());
button.type = "button";
button.setAttribute("aria-haspopup", "listbox");
if (label) button.setAttribute("aria-label", label);
const text = el("span", "ausboss-media-pick-text");
button.append(text);
// Inside the output folder's own list, the " [output]" at the end of every
// name says nothing: it is left off what is shown.
const tail = folder === "input" ? "" : ` [${folder}]`;
const bare = (value) => (tail && value.endsWith(tail) ? value.slice(0, -tail.length) : value);
const refresh = (value = getValue()) => {
const shown = value == null ? "" : bare(String(value));
text.textContent = shown || placeholder;
text.classList.toggle("empty", !shown);
button.title = shown ? `${shown}\nClick to choose another ${noun}` : placeholder;
};
const open = () => {
closeMediaMenu();
let all = (getOptions() || []).map(String);
const current = String(getValue() ?? "");
const menu = el("div", "ausboss-media-menu");
const column = el("div", "ausboss-media-col");
const filter = el("input", "ausboss-media-filter");
filter.type = "text";
filter.spellcheck = false;
const nameFilter = () => {
filter.placeholder = all.length ? `Filter ${all.length} ${noun}${all.length === 1 ? "" : "s"}…` : `No ${noun}s in the ${folder} folder`;
};
nameFilter();
filter.setAttribute("aria-label", `Filter ${noun}s`);
const list = el("div", "ausboss-media-list");
list.setAttribute("role", "listbox");
column.append(filter, list);
const pane = el("div", "ausboss-media-pane");
const stage = el("div", "ausboss-media-stage");
const caption = el("div", "ausboss-media-caption");
pane.append(stage, caption);
menu.append(column, pane);
let shown = [];
const items = [];
let highlight = -1;
let timer = null;
let serial = 0;
const showPreview = (value) => {
const mine = ++serial;
stage.replaceChildren();
caption.textContent = "";
if (value == null) return;
const failed = () => {
if (mine !== serial) return;
stage.replaceChildren(el("div", "ausboss-media-note", `Can't preview this ${noun}`));
};
if (kind === "video") {
const video = document.createElement("video");
video.muted = true;
video.loop = true;
video.autoplay = true;
video.playsInline = true;
video.preload = "auto";
video.addEventListener("loadedmetadata", () => {
if (mine !== serial) return;
const seconds = Number.isFinite(video.duration) ? ` · ${video.duration.toFixed(1)} s` : "";
caption.textContent = `${video.videoWidth} × ${video.videoHeight}${seconds}`;
});
video.addEventListener("error", failed);
video.src = viewUrl(value);
stage.append(video);
} else {
const image = new Image();
image.decoding = "async";
image.alt = "";
image.addEventListener("load", () => {
if (mine === serial) caption.textContent = `${image.naturalWidth} × ${image.naturalHeight}`;
});
image.addEventListener("error", failed);
image.src = viewUrl(value);
stage.append(image);
}
};
const setHighlight = (index, scroll) => {
if (index === highlight) return;
items[highlight]?.classList.remove("hot");
highlight = index;
clearTimeout(timer);
const item = items[index];
if (!item) {
showPreview(null);
return;
}
item.classList.add("hot");
if (scroll) item.scrollIntoView({ block: "nearest" });
const value = shown[index];
timer = setTimeout(() => showPreview(value), PREVIEW_DELAY_MS);
};
const render = () => {
shown = filterMedia(all, filter.value);
list.replaceChildren();
items.length = 0;
highlight = -1;
for (const value of shown.slice(0, LIST_CAP)) {
const { folder: under, name } = mediaLabel(bare(value));
const item = el("div", "ausboss-media-item");
item.setAttribute("role", "option");
item.title = bare(value);
if (value === current) {
item.classList.add("current");
item.setAttribute("aria-selected", "true");
}
if (under) item.append(el("span", "ausboss-media-folder", under));
item.append(el("span", "ausboss-media-name", name));
const index = items.length;
item.addEventListener("mousemove", () => setHighlight(index, false));
item.addEventListener("click", () => choose(value));
list.append(item);
items.push(item);
}
if (shown.length > LIST_CAP) list.append(el("div", "ausboss-media-more", `${shown.length - LIST_CAP} more · type to narrow`));
if (!shown.length) list.append(el("div", "ausboss-media-more", all.length ? "No matches" : emptyNote || `Upload ${noun === "video" ? "a video" : "an image"} and it shows up here`));
const start = shown.indexOf(current);
setHighlight(start >= 0 && start < items.length ? start : items.length ? 0 : -1, true);
if (!items.length) showPreview(null);
};
const abort = new AbortController();
const close = () => {
clearTimeout(timer);
abort.abort();
menu.querySelector("video")?.pause();
menu.remove();
button.classList.remove("open");
if (openMenu?.menu === menu) openMenu = null;
};
const choose = (value) => {
close();
if (value !== String(getValue() ?? "")) onChange(value);
refresh(value);
button.focus();
};
filter.addEventListener("input", render);
filter.addEventListener("keydown", (event) => {
keepKeyInField(event); // typing and editing keys stay in the filter (Delete, Ctrl+A…)
if (isAppShortcut(event)) return; // Ctrl + Enter runs the workflow, it does not pick
const step = { ArrowDown: 1, ArrowUp: -1, PageDown: 8, PageUp: -8 }[event.key];
if (step) {
event.preventDefault();
setHighlight(moveHighlight(highlight, step, items.length), true);
} else if (event.key === "Enter") {
event.preventDefault();
if (highlight >= 0) choose(shown[highlight]);
} else if (event.key === "Escape") {
event.preventDefault();
close();
button.focus();
}
});
menu.addEventListener("pointerdown", (event) => event.stopPropagation());
const outside = (event) => !menu.contains(event.target) && !button.contains(event.target);
window.addEventListener("pointerdown", (event) => { if (outside(event)) close(); }, { capture: true, signal: abort.signal });
window.addEventListener("wheel", (event) => { if (outside(event)) close(); }, { capture: true, passive: true, signal: abort.signal });
window.addEventListener("resize", close, { signal: abort.signal });
window.addEventListener("blur", close, { signal: abort.signal });
openMenu = { owner: button, menu, close };
button.classList.add("open");
document.body.append(menu);
render();
if (loadOptions) {
Promise.resolve().then(loadOptions).then((values) => {
const fresh = (values || []).map(String);
if (abort.signal.aborted || (fresh.length === all.length && fresh.every((value, index) => value === all[index]))) return;
all = fresh;
nameFilter();
render();
}).catch(() => {});
}
// Under the button, or above it when the window runs out; kept on screen.
const box = button.getBoundingClientRect();
const width = menu.offsetWidth;
const height = menu.offsetHeight;
let top = box.bottom + 4;
if (top + height > window.innerHeight - 8 && box.top - height - 4 >= 8) top = box.top - height - 4;
menu.style.left = `${Math.max(8, Math.min(box.left, window.innerWidth - width - 8))}px`;
menu.style.top = `${Math.max(8, top)}px`;
filter.focus();
};
button.addEventListener("pointerdown", (event) => event.stopPropagation());
button.addEventListener("click", () => (openMenu?.owner === button ? openMenu.close() : open()));
refresh();
const closeOwn = () => {
if (openMenu?.owner === button) openMenu.close();
};
return { element: button, refresh, close: closeOwn, dispose: closeOwn };
}