251 lines
18 KiB
JavaScript
251 lines
18 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { api } from "../../scripts/api.js";
|
|
|
|
const THEME_LABELS = { female: "Female", male: "Male", group: "Group", people: "People", nsfw: "NSFW", animals: "Animals", scenery: "Scenery", objects: "Objects", other: "Other" };
|
|
const BUTTON = { cursor: "pointer", padding: "3px 10px", borderRadius: "4px", border: "1px solid var(--border-color, #555)", background: "var(--comfy-input-bg, #333)", color: "var(--input-text, #ddd)" };
|
|
const FIELD = { padding: "4px 6px", borderRadius: "4px", border: "1px solid var(--border-color, #555)", background: "var(--comfy-input-bg, #333)", color: "var(--input-text, #ddd)" };
|
|
|
|
export const thumb = (name, size) => api.apiURL(`/promptforge/image_thumb?name=${encodeURIComponent(name)}&size=${size}`);
|
|
export const nsfwShown = () => localStorage.getItem("promptforge.showNsfw") === "1";
|
|
export const setNsfwShown = (value) => localStorage.setItem("promptforge.showNsfw", value ? "1" : "0");
|
|
export const BLUR = "blur(22px) saturate(.6)";
|
|
|
|
function el(tag, style = {}, props = {}) {
|
|
const element = Object.assign(document.createElement(tag), props);
|
|
Object.assign(element.style, style);
|
|
return element;
|
|
}
|
|
|
|
const post = async (path, body = {}) => (await api.fetchApi(path, { method: "POST", body: JSON.stringify(body) })).json();
|
|
const get = async (path) => (await api.fetchApi(path)).json();
|
|
|
|
async function editImage(image, onChange) {
|
|
const info = await get("/promptforge/library/info?name=" + encodeURIComponent(image.name));
|
|
const note = (await get("/promptforge/image_description?name=" + encodeURIComponent(image.name))).description;
|
|
const backdrop = el("div", { position: "fixed", inset: "0", zIndex: 10002, background: "rgba(0,0,0,.6)", display: "flex", alignItems: "center", justifyContent: "center" });
|
|
const card = el("div", { width: "min(760px, 94vw)", maxHeight: "90vh", overflowY: "auto", display: "flex", flexDirection: "column", gap: "8px", padding: "14px", boxSizing: "border-box", background: "var(--comfy-menu-bg, #222)", color: "var(--input-text, #ddd)", border: "1px solid var(--border-color, #555)", borderRadius: "8px", fontSize: "13px" });
|
|
backdrop.dataset.pfNested = "1";
|
|
const close = () => { backdrop.remove(); document.removeEventListener("keydown", onKey, true); };
|
|
const onKey = (event) => { if (event.key === "Escape") { event.stopPropagation(); close(); } };
|
|
document.addEventListener("keydown", onKey, true);
|
|
backdrop.addEventListener("pointerdown", (event) => event.stopPropagation());
|
|
backdrop.addEventListener("click", (event) => { if (event.target === backdrop) close(); });
|
|
|
|
const picture = el("img", { maxWidth: "100%", maxHeight: "300px", objectFit: "contain", alignSelf: "center", borderRadius: "4px", filter: image.nsfw && !nsfwShown() ? BLUR : "none", cursor: "pointer" }, { src: thumb(image.name, 640), title: "Click to blur / show" });
|
|
picture.addEventListener("click", () => { picture.style.filter = picture.style.filter === "none" ? BLUR : "none"; });
|
|
const tagsBox = el("textarea", { ...FIELD, minHeight: "70px" }, { value: (info.tags || []).join(", "), placeholder: "keywords, comma separated" });
|
|
const caption = el("input", FIELD, { value: info.caption || "", placeholder: "caption" });
|
|
const theme = el("select", FIELD);
|
|
theme.append(...Object.entries(THEME_LABELS).map(([key, label]) => new Option(label, key)));
|
|
theme.value = info.theme || "other";
|
|
const nsfw = el("input", {}, { type: "checkbox", checked: !!info.nsfw });
|
|
const nsfwLabel = el("label", { display: "flex", gap: "6px", alignItems: "center" });
|
|
nsfwLabel.append(nsfw, "NSFW");
|
|
const description = el("textarea", { ...FIELD, minHeight: "60px" }, { value: note, placeholder: "description (output by the Load Image node)" });
|
|
const save = el("button", BUTTON, { textContent: "Save" });
|
|
const cancel = el("button", BUTTON, { textContent: "Close" });
|
|
const row = el("div", { display: "flex", gap: "8px", justifyContent: "flex-end" });
|
|
row.append(cancel, save);
|
|
const meta = el("div", { opacity: ".6", fontSize: "11px" }, { textContent: `${image.name}${info.model ? " · tagged by " + info.model : " · not tagged yet"}` });
|
|
const themeRow = el("div", { display: "flex", gap: "12px", alignItems: "center" });
|
|
themeRow.append("Theme", theme, nsfwLabel);
|
|
card.append(picture, meta, themeRow, tagsBox, caption, description, row);
|
|
backdrop.append(card);
|
|
document.body.append(backdrop);
|
|
cancel.addEventListener("click", close);
|
|
save.addEventListener("click", async () => {
|
|
await post("/promptforge/library/edit", { name: image.name, tags: tagsBox.value, caption: caption.value, nsfw: nsfw.checked, theme: theme.value });
|
|
await post("/promptforge/image_description", { name: image.name, description: description.value });
|
|
close();
|
|
onChange?.();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The image library: browse, search, tag and organise the input folder.
|
|
* `onPick(name)` makes a tile click choose that image (the Load Image picker); without it, clicking opens the editor.
|
|
*/
|
|
export function createLibrary(container, { onPick = null, current = "", extraControls = [] } = {}) {
|
|
const state = { images: [], search: "", folder: "*", theme: "*", select: false, selected: new Set(), models: [], tools: false };
|
|
let timer = null;
|
|
let polling = null;
|
|
|
|
const root = el("div", { display: "flex", flexDirection: "column", gap: "8px", height: "100%", minHeight: "0", color: "var(--input-text, #ddd)", fontSize: "13px" });
|
|
const bar = el("div", { display: "flex", gap: "6px", alignItems: "center", flexWrap: "wrap" });
|
|
const searchBox = el("input", { ...FIELD, flex: "1 1 180px", minWidth: "120px" }, { type: "search", placeholder: "Search names, keywords, descriptions..." });
|
|
const folderBox = el("select", { ...FIELD, maxWidth: "200px" });
|
|
const themeBox = el("select", { ...FIELD, maxWidth: "150px" });
|
|
const nsfwLabel = el("label", { display: "flex", gap: "4px", alignItems: "center", whiteSpace: "nowrap" }, { title: "NSFW images are blurred unless this is on" });
|
|
const nsfwBox = el("input", {}, { type: "checkbox", checked: nsfwShown() });
|
|
nsfwLabel.append(nsfwBox, "Show NSFW");
|
|
const selectButton = el("button", BUTTON, { textContent: "Select", title: "Pick several images for tagging or organising" });
|
|
const toolsButton = el("button", BUTTON, { textContent: "Tools ▾" });
|
|
const count = el("span", { opacity: ".7", whiteSpace: "nowrap" });
|
|
bar.append(searchBox, folderBox, themeBox, nsfwLabel, selectButton, toolsButton, count, ...extraControls);
|
|
|
|
const tools = el("div", { display: "none", flexDirection: "column", gap: "6px", padding: "8px", border: "1px solid var(--border-color, #555)", borderRadius: "6px" });
|
|
const modelRow = el("div", { display: "flex", gap: "6px", alignItems: "center", flexWrap: "wrap" });
|
|
const modelBox = el("select", { ...FIELD, flex: "1", minWidth: "180px" });
|
|
const tagUntagged = el("button", BUTTON, { textContent: "Tag untagged", title: "Tag every image that has no tags yet (or changed)" });
|
|
const tagSelected = el("button", BUTTON, { textContent: "Tag selected" });
|
|
const retagAll = el("button", BUTTON, { textContent: "Re-tag all" });
|
|
modelRow.append("Vision model", modelBox, tagUntagged, tagSelected, retagAll);
|
|
const folderRow = el("div", { display: "flex", gap: "6px", alignItems: "center", flexWrap: "wrap" });
|
|
const subfolders = el("input", {}, { type: "checkbox" });
|
|
const subLabel = el("label", { display: "flex", gap: "4px", alignItems: "center" });
|
|
subLabel.append(subfolders, "include images already in subfolders");
|
|
const organize = el("button", BUTTON, { textContent: "Organize into theme folders…", title: "Moves tagged images into female / male / group / nsfw / ... folders. Shows what will move first." });
|
|
const organizeSelected = el("button", BUTTON, { textContent: "Organize selected…" });
|
|
const undo = el("button", BUTTON, { textContent: "Undo last organize" });
|
|
folderRow.append(organize, organizeSelected, undo, subLabel);
|
|
const progress = el("div", { display: "none", flexDirection: "column", gap: "3px" });
|
|
const progressBar = el("div", { height: "8px", borderRadius: "4px", background: "rgba(255,255,255,.12)", overflow: "hidden" });
|
|
const progressFill = el("div", { height: "100%", width: "0%", background: "#4a90e2" });
|
|
progressBar.append(progressFill);
|
|
const progressText = el("div", { fontSize: "12px", opacity: ".85", whiteSpace: "pre-wrap" });
|
|
const cancel = el("button", { ...BUTTON, alignSelf: "flex-start" }, { textContent: "Cancel job" });
|
|
progress.append(progressBar, progressText, cancel);
|
|
const note = el("div", { fontSize: "11px", opacity: ".6" }, { textContent: "Tagging runs a local vision model on this machine and unloads ComfyUI's models while it runs; avoid generating at the same time. Organizing moves files, so workflows that point at the old path need the image picked again (Undo restores them)." });
|
|
tools.append(modelRow, folderRow, progress, note);
|
|
|
|
const grid = el("div", { flex: "1", minHeight: "0", overflowY: "auto", display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))", gap: "8px", alignContent: "start", padding: "2px" });
|
|
root.append(bar, tools, grid);
|
|
container.append(root);
|
|
|
|
const folderOf = (name) => (name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : "");
|
|
const matches = () => {
|
|
const words = state.search.toLowerCase().split(/\s+/).filter(Boolean);
|
|
return state.images
|
|
.filter((image) => (state.folder === "*" || folderOf(image.name) === state.folder) && (state.theme === "*" || (state.theme === "untagged" ? !image.tagged : image.theme === state.theme)))
|
|
.filter((image) => {
|
|
const haystack = `${image.name} ${image.description} ${(image.tags || []).join(" ")} ${image.caption || ""} ${image.theme || ""}`.toLowerCase();
|
|
return words.every((word) => haystack.includes(word));
|
|
})
|
|
.sort((a, b) => b.mtime - a.mtime);
|
|
};
|
|
|
|
function fillFilters() {
|
|
const folders = [...new Set(state.images.map((image) => folderOf(image.name)))].sort();
|
|
folderBox.replaceChildren(new Option("All folders", "*"), ...folders.map((name) => new Option(name || "(input root)", name)));
|
|
folderBox.value = folders.includes(state.folder) ? state.folder : "*";
|
|
state.folder = folderBox.value;
|
|
const counts = {};
|
|
state.images.forEach((image) => { counts[image.theme || "untagged"] = (counts[image.theme || "untagged"] || 0) + 1; });
|
|
themeBox.replaceChildren(new Option("All themes", "*"), ...[...Object.keys(THEME_LABELS), "untagged"].filter((key) => counts[key]).map((key) => new Option(`${THEME_LABELS[key] || "Untagged"} (${counts[key]})`, key)));
|
|
themeBox.value = state.theme === "*" || counts[state.theme] ? state.theme : "*";
|
|
state.theme = themeBox.value;
|
|
}
|
|
|
|
function render() {
|
|
const shown = matches();
|
|
count.textContent = `${shown.length} images` + (state.select ? ` · ${state.selected.size} selected` : "");
|
|
selectButton.style.background = state.select ? "#2a5d9a" : BUTTON.background;
|
|
grid.replaceChildren(...shown.map((image) => {
|
|
const hidden = image.nsfw && !nsfwBox.checked;
|
|
const selected = state.selected.has(image.name);
|
|
const tile = el("div", { cursor: "pointer", display: "flex", flexDirection: "column", gap: "3px", padding: "4px", borderRadius: "5px", position: "relative", border: selected ? "2px solid #4a90e2" : image.name === current ? "2px solid #6af" : "1px solid var(--border-color, #555)" });
|
|
const picture = el("img", { width: "100%", aspectRatio: "1 / 1", objectFit: "contain", borderRadius: "3px", background: "#111", filter: hidden ? BLUR : "none" }, { loading: "lazy", src: thumb(image.name, 220) });
|
|
const label = el("div", { fontSize: "11px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, { textContent: image.name.split("/").pop() });
|
|
tile.title = [image.name, image.caption, (image.tags || []).join(", "), image.description].filter(Boolean).join("\n\n");
|
|
tile.append(picture, label);
|
|
const badges = el("div", { position: "absolute", top: "8px", left: "8px", display: "flex", gap: "3px" });
|
|
if (image.theme) badges.append(el("span", { padding: "0 5px", borderRadius: "8px", background: image.nsfw ? "#a33" : "#264", color: "#fff", fontSize: "10px" }, { textContent: THEME_LABELS[image.theme] || image.theme }));
|
|
if (image.described) badges.append(el("span", { padding: "0 5px", borderRadius: "8px", background: "#26a", color: "#fff", fontSize: "10px" }, { textContent: "text" }));
|
|
tile.append(badges);
|
|
const edit = el("button", { ...BUTTON, position: "absolute", top: "6px", right: "6px", padding: "0 6px", borderRadius: "50%", fontStyle: "italic", fontWeight: "bold" }, { textContent: "i", title: "Edit tags, theme and description" });
|
|
edit.addEventListener("click", (event) => { event.stopPropagation(); editImage(image, reload); });
|
|
tile.append(edit);
|
|
tile.addEventListener("click", () => {
|
|
if (state.select) {
|
|
state.selected.has(image.name) ? state.selected.delete(image.name) : state.selected.add(image.name);
|
|
render();
|
|
} else if (onPick) onPick(image.name);
|
|
else editImage(image, reload);
|
|
});
|
|
return tile;
|
|
}));
|
|
}
|
|
|
|
async function reload() {
|
|
state.images = (await get("/promptforge/images")).images;
|
|
state.selected = new Set([...state.selected].filter((name) => state.images.some((image) => image.name === name)));
|
|
fillFilters();
|
|
render();
|
|
}
|
|
|
|
async function loadModels() {
|
|
state.models = (await get("/promptforge/library/models")).models;
|
|
const saved = localStorage.getItem("promptforge.visionModel");
|
|
modelBox.replaceChildren(...(state.models.length ? state.models.map((model) => new Option(model.label, model.id)) : [new Option("No vision GGUF with an mmproj found", "")]));
|
|
if (state.models.some((model) => model.id === saved)) modelBox.value = saved;
|
|
}
|
|
|
|
async function status() {
|
|
const job = await get("/promptforge/library/status");
|
|
progress.style.display = job.running || job.message ? "flex" : "none";
|
|
const fraction = job.total ? job.done / job.total : 0;
|
|
progressFill.style.width = `${Math.round(fraction * 100)}%`;
|
|
progressText.textContent = `${job.message}${job.total ? ` · ${job.done}/${job.total}` : ""}${job.current ? ` · ${job.current}` : ""}${job.failed.length ? `\n${job.failed.length} failed: ${job.failed.slice(-3).join("; ")}` : ""}`;
|
|
cancel.style.display = job.running ? "" : "none";
|
|
for (const button of [tagUntagged, tagSelected, retagAll, organize, organizeSelected, undo]) button.disabled = job.running;
|
|
if (job.running && !polling) polling = setInterval(status, 1000);
|
|
if (!job.running && polling) { clearInterval(polling); polling = null; await reload(); }
|
|
return job;
|
|
}
|
|
|
|
async function startTag(names, retag) {
|
|
if (!modelBox.value) return alert("No vision model is available. A vision model is a GGUF with an mmproj file next to it, in a text_encoders or LLM folder.");
|
|
localStorage.setItem("promptforge.visionModel", modelBox.value);
|
|
const answer = await post("/promptforge/library/tag", { model: modelBox.value, names, retag });
|
|
if (!answer.started) alert(answer.message || "Could not start.");
|
|
await status();
|
|
}
|
|
|
|
async function startOrganize(names) {
|
|
const body = { names, include_subfolders: subfolders.checked };
|
|
const plan = await post("/promptforge/library/organize", { ...body, dry_run: true });
|
|
if (!plan.total) return alert("Nothing to move. Tag the images first, or tick \"include images already in subfolders\".");
|
|
const lines = Object.entries(plan.counts).map(([theme, number]) => `${number} → ${theme}/`).join("\n");
|
|
if (!confirm(`Move ${plan.total} images into theme folders inside the input folder?\n\n${lines}\n\nWorkflows pointing at the old paths will need the image picked again. "Undo last organize" restores everything.`)) return;
|
|
const done = await post("/promptforge/library/organize", { ...body, dry_run: false });
|
|
progress.style.display = "flex";
|
|
progressText.textContent = `Moved ${done.moved} images.`;
|
|
await reload();
|
|
}
|
|
|
|
searchBox.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => { state.search = searchBox.value; render(); }, 120); });
|
|
folderBox.addEventListener("change", () => { state.folder = folderBox.value; render(); });
|
|
themeBox.addEventListener("change", () => { state.theme = themeBox.value; render(); });
|
|
nsfwBox.addEventListener("change", () => { setNsfwShown(nsfwBox.checked); render(); });
|
|
selectButton.addEventListener("click", () => { state.select = !state.select; if (!state.select) state.selected.clear(); render(); });
|
|
toolsButton.addEventListener("click", () => { state.tools = !state.tools; tools.style.display = state.tools ? "flex" : "none"; toolsButton.textContent = state.tools ? "Tools ▴" : "Tools ▾"; if (state.tools) { loadModels(); status(); } });
|
|
tagUntagged.addEventListener("click", () => startTag("all", false));
|
|
retagAll.addEventListener("click", () => confirm("Tag every image again? This can take a while.") && startTag("all", true));
|
|
tagSelected.addEventListener("click", () => (state.selected.size ? startTag([...state.selected], true) : alert("Press Select, then click the images to tag.")));
|
|
organize.addEventListener("click", () => startOrganize("all"));
|
|
organizeSelected.addEventListener("click", () => (state.selected.size ? startOrganize([...state.selected]) : alert("Press Select, then click the images to organize.")));
|
|
undo.addEventListener("click", async () => { const answer = await post("/promptforge/library/undo"); progress.style.display = "flex"; progressText.textContent = `Restored ${answer.restored} images.`; await reload(); });
|
|
cancel.addEventListener("click", () => post("/promptforge/library/cancel"));
|
|
|
|
reload();
|
|
status();
|
|
return { reload, focus: () => searchBox.focus(), destroy: () => { clearInterval(polling); root.remove(); } };
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "PromptForge.ImageLibrary",
|
|
async setup() {
|
|
app.extensionManager?.registerSidebarTab?.({
|
|
id: "promptforge-images",
|
|
icon: "pi pi-images",
|
|
title: "Images",
|
|
tooltip: "PromptForge image library: tag, search and organize the input folder",
|
|
type: "custom",
|
|
render: (container) => {
|
|
Object.assign(container.style, { height: "100%", padding: "8px", boxSizing: "border-box" });
|
|
createLibrary(container);
|
|
},
|
|
});
|
|
},
|
|
});
|