Files
olivv-cs-ComfyUI-FunPack/web/funpack_scene_builder.js
T

1463 lines
45 KiB
JavaScript
Raw 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.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const NODE_NAME = "FunPackSceneBuilder";
const NONE_SCENE = "-None-";
const HIDDEN_WIDGETS = new Set([
"scene_name",
"mode",
"scene",
"aliases",
"action",
"scene_positive",
"scene_negative",
"refinement_key",
"scene_payload",
]);
const GROUP_ORDER = ["subject", "appearance", "action", "camera", "environment", "style", "quality", "details", "negative"];
let sceneData = null;
let activePanel = null;
const trackedNodes = new Set();
function widgetByName(node, name) {
return (node.widgets || []).find((widget) => widget.name === name);
}
function hideWidget(widget) {
if (!widget) {
return;
}
if (widget.linkedWidgets) {
for (const linked of widget.linkedWidgets) {
hideWidget(linked);
}
}
widget.__funpackHidden = true;
widget.hidden = true;
widget.options = widget.options || {};
widget.options.hidden = true;
widget.computeSize = () => [0, -4];
widget.computedHeight = 0;
widget.type = "hidden";
for (const key of ["element", "inputEl", "textElement", "parentEl"]) {
const element = widget[key];
if (element?.style) {
element.style.display = "none";
element.style.visibility = "hidden";
element.style.pointerEvents = "none";
}
if (element) {
element.hidden = true;
}
}
}
function getWidgetValue(node, name, fallback = "") {
const widget = widgetByName(node, name);
return widget ? widget.value : fallback;
}
function setWidgetValue(node, name, value) {
const widget = widgetByName(node, name);
if (widget) {
widget.value = value;
}
}
function setDirty(node) {
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
}
function normalizeRefinementKey(value) {
const clean = String(value || "").trim();
return clean && clean !== NONE_SCENE ? clean : "";
}
function fitString(ctx, text, maxWidth) {
text = String(text ?? "");
if (ctx.measureText(text).width <= maxWidth) {
return text;
}
const ellipsis = "...";
let low = 0;
let high = text.length;
while (low < high) {
const mid = Math.ceil((low + high) / 2);
if (ctx.measureText(text.slice(0, mid) + ellipsis).width <= maxWidth) {
low = mid;
} else {
high = mid - 1;
}
}
return text.slice(0, low) + ellipsis;
}
function roundRect(ctx, x, y, width, height, radius = 5) {
ctx.beginPath();
ctx.roundRect(x, y, width, height, [radius]);
}
function button(ctx, widget, key, label, x, y, width, height) {
widget.hitAreas[key] = [x, y, width, height];
roundRect(ctx, x, y, width, height, 5);
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.fill();
ctx.stroke();
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(fitString(ctx, label, width - 8), x + width / 2, y + height / 2);
}
function currentRefinementKey(node) {
const linked = linkedRefinementKey(node);
if (linked) {
return linked;
}
return normalizeRefinementKey(getWidgetValue(node, "refinement_key", ""));
}
function linkedInputNode(node, inputName) {
const input = (node.inputs || []).find((item) => item.name === inputName);
const linkId = Array.isArray(input?.link) ? input.link[0] : input?.link;
if (linkId == null) {
return null;
}
const link = app.graph?.links?.[linkId];
return link ? app.graph?.getNodeById?.(link.origin_id) : null;
}
function linkedRefinementKey(node) {
const source = linkedInputNode(node, "refinement_key_input");
if (!source) {
return "";
}
const selected = normalizeRefinementKey(getWidgetValue(source, "refinement_key", ""));
const typed = normalizeRefinementKey(getWidgetValue(source, "key_name", ""));
return selected || typed;
}
function sceneNames() {
const names = sceneData?.scenes;
return Array.isArray(names) && names.length ? names : [NONE_SCENE];
}
function sceneByName(name) {
return sceneData?.data?.scenes?.[name] || null;
}
function savedSceneNames() {
return sceneNames().filter((item) => item !== NONE_SCENE);
}
function activeSceneName(node) {
const typed = String(getWidgetValue(node, "scene_name", "") || "").trim();
const selected = String(getWidgetValue(node, "scene", "") || "").trim();
if (typed) {
return typed;
}
return selected && selected !== NONE_SCENE ? selected : "";
}
function needsSceneCreation(node) {
return !activeSceneName(node) && !savedSceneNames().length;
}
function memoryItems() {
return Array.isArray(sceneData?.memory) ? sceneData.memory : [];
}
function normalizeMemoryMap(items) {
const output = {};
for (const item of items || []) {
const text = String(item.text || item.key || "").trim();
if (!text) {
continue;
}
const key = String(item.key || text).toLowerCase().replace(/[^\w'’]+/g, " ").replace(/\s+/g, " ").trim() || text.toLowerCase();
output[key] = {
text,
key,
source: item.source === "negative" || item.category === "negative" ? "negative" : "positive",
category: GROUP_ORDER.includes(item.category) ? item.category : "details",
tokens: Array.isArray(item.tokens) ? item.tokens : [],
count: Number.isFinite(Number(item.count)) ? Number(item.count) : 0,
created_at: item.created_at || new Date().toISOString(),
updated_at: new Date().toISOString(),
wildcard: Boolean(item.wildcard || String(item.wildcard_group || "").trim()),
};
}
return output;
}
async function fetchScenes(refinementKey = "") {
try {
const params = new URLSearchParams({ cache_bust: String(Date.now()) });
if (refinementKey) {
params.set("key", refinementKey);
}
const response = await api.fetchApi(`/funpack/scenes?${params.toString()}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
sceneData = await response.json();
} catch (error) {
console.warn("FunPack: failed to refresh scenes", error);
}
return sceneData;
}
function updateSceneCombo(node) {
const widget = widgetByName(node, "scene");
if (!widget) {
return;
}
const values = sceneNames();
const current = widget.value;
widget.options.values = current && !values.includes(current) ? [...values, current] : values;
if (!widget.value || !widget.options.values.includes(widget.value)) {
widget.value = widget.options.values[0] || NONE_SCENE;
}
}
async function refreshNode(node) {
await fetchScenes(currentRefinementKey(node));
updateSceneCombo(node);
setDirty(node);
}
async function refreshTracked() {
for (const node of [...trackedNodes]) {
if (!node?.graph) {
trackedNodes.delete(node);
continue;
}
await refreshNode(node);
}
}
function scheduleRefresh() {
window.setTimeout(() => void refreshTracked(), 100);
}
function loadSceneIntoNode(node, name) {
const scene = sceneByName(name);
if (!scene || name === NONE_SCENE) {
return;
}
setWidgetValue(node, "scene", name);
setWidgetValue(node, "scene_name", scene.name || name);
setWidgetValue(node, "aliases", Array.isArray(scene.aliases) ? scene.aliases.join(", ") : "");
setWidgetValue(node, "mode", scene.output_mode || "Manual");
setWidgetValue(node, "refinement_key", scene.refinement_key || "");
setWidgetValue(node, "scene_positive", scene.positive_text || (scene.positive_phrases || []).join(", "));
setWidgetValue(node, "scene_negative", scene.negative_text || (scene.negative_phrases || []).join(", "));
setDirty(node);
}
async function saveSceneFromNode(node) {
const name = String(getWidgetValue(node, "scene_name", "") || "").trim();
if (!name) {
throw new Error("Scene name is required.");
}
const params = new URLSearchParams();
const key = currentRefinementKey(node);
if (key) {
params.set("key", key);
}
const response = await api.fetchApi(`/funpack/scenes/scene${params.toString() ? `?${params.toString()}` : ""}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "save",
name,
aliases: getWidgetValue(node, "aliases", ""),
mode: getWidgetValue(node, "mode", "Manual"),
positive_text: getWidgetValue(node, "scene_positive", ""),
negative_text: getWidgetValue(node, "scene_negative", ""),
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Save failed with HTTP ${response.status}`);
}
await refreshNode(node);
setWidgetValue(node, "scene", name);
}
async function deleteSceneFromNode(node) {
const name = String(getWidgetValue(node, "scene_name", "") || "").trim();
if (!name) {
throw new Error("Scene name is required.");
}
const params = new URLSearchParams();
const key = currentRefinementKey(node);
if (key) {
params.set("key", key);
}
const response = await api.fetchApi(`/funpack/scenes/scene${params.toString() ? `?${params.toString()}` : ""}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", name }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Delete failed with HTTP ${response.status}`);
}
setWidgetValue(node, "scene", NONE_SCENE);
setWidgetValue(node, "scene_name", "");
setWidgetValue(node, "scene_positive", "");
setWidgetValue(node, "scene_negative", "");
await refreshNode(node);
}
async function saveDatabase(node, items) {
const params = new URLSearchParams();
const key = currentRefinementKey(node);
if (key) {
params.set("key", key);
}
const response = await api.fetchApi(`/funpack/scenes/database${params.toString() ? `?${params.toString()}` : ""}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ universal_memory: normalizeMemoryMap(items) }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Database save failed with HTTP ${response.status}`);
}
await refreshNode(node);
}
async function exportScenes(node) {
const params = new URLSearchParams({ cache_bust: String(Date.now()) });
const key = currentRefinementKey(node);
if (key) {
params.set("key", key);
}
const response = await api.fetchApi(`/funpack/scenes/export?${params.toString()}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Export failed with HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "funpack_scenes.json";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function importScenes(node) {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json,application/json";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) {
return;
}
try {
const data = JSON.parse(await file.text());
const params = new URLSearchParams();
const key = currentRefinementKey(node);
if (key) {
params.set("key", key);
}
const response = await api.fetchApi(`/funpack/scenes/import${params.toString() ? `?${params.toString()}` : ""}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Import failed with HTTP ${response.status}`);
}
await refreshNode(node);
renderPanel(activePanel, node, "menu");
} catch (error) {
showPanelError(error);
}
};
input.click();
}
function closePanel() {
if (activePanel?.root) {
activePanel.root.remove();
}
activePanel = null;
}
function showPanelError(error) {
const target = activePanel?.root?.querySelector("[data-role='error']");
if (target) {
target.textContent = error.message || String(error);
} else {
console.warn("FunPack Scene Builder:", error);
}
}
function panelButton(label, className = "") {
const element = document.createElement("button");
element.type = "button";
element.textContent = label;
element.className = `funpack-scene-button ${className}`.trim();
return element;
}
function panelTextInput(value, placeholder) {
const element = document.createElement("input");
element.type = "text";
element.value = String(value || "");
element.placeholder = placeholder;
return element;
}
function panelSelect(values, selected) {
const element = document.createElement("select");
for (const value of values) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
option.selected = value === selected;
element.append(option);
}
return element;
}
function panelCheckbox(checked, label) {
const wrapper = document.createElement("label");
wrapper.className = "funpack-scene-checkbox";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = Boolean(checked);
const span = document.createElement("span");
span.textContent = label;
wrapper.append(input, span);
return { wrapper, input };
}
function editableTextLabel(value, onCommit) {
const wrapper = document.createElement("div");
wrapper.className = "funpack-scene-editable";
const label = document.createElement("button");
label.type = "button";
label.textContent = value || "Unnamed";
label.title = "Double-click to edit";
wrapper.append(label);
const beginEdit = () => {
const editor = document.createElement("div");
editor.className = "funpack-scene-inline-editor";
const input = document.createElement("input");
const original = label.textContent || "";
input.type = "text";
input.value = original;
const ok = panelButton("OK", "compact primary");
const cancel = panelButton("Cancel", "compact");
let committed = false;
const commit = () => {
if (committed) {
return;
}
committed = true;
const next = input.value.trim();
const value = next || original;
onCommit(value);
label.textContent = value;
editor.replaceWith(label);
};
const cancelEdit = () => {
if (committed) {
return;
}
committed = true;
editor.replaceWith(label);
};
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
} else if (event.key === "Escape") {
event.preventDefault();
cancelEdit();
}
});
ok.addEventListener("click", commit);
cancel.addEventListener("click", cancelEdit);
editor.append(input, ok, cancel);
label.replaceWith(editor);
input.focus();
input.select();
};
label.addEventListener("dblclick", beginEdit);
label.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === "F2") {
event.preventDefault();
beginEdit();
}
});
return wrapper;
}
function phraseButton(text, onInsert) {
const element = document.createElement("button");
element.type = "button";
element.className = "funpack-scene-chip";
element.textContent = text;
element.title = text;
element.draggable = true;
element.addEventListener("click", () => onInsert(text));
element.addEventListener("dragstart", (event) => {
event.dataTransfer?.setData("text/plain", text);
});
return element;
}
function insertAtCursor(textarea, text) {
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? textarea.value.length;
const prefix = textarea.value.slice(0, start);
const suffix = textarea.value.slice(end);
const needsComma = prefix.trim() && !/[,\s.;\n]$/.test(prefix);
const insertion = `${needsComma ? ", " : ""}${text}`;
textarea.value = `${prefix}${insertion}${suffix}`;
const cursor = prefix.length + insertion.length;
textarea.focus();
textarea.setSelectionRange(cursor, cursor);
}
function promptLookupKey(value) {
return String(value || "")
.toLowerCase()
.replace(/[^\w'’.-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function promptContainsPhrase(prompt, phrase) {
const promptKey = promptLookupKey(prompt);
const phraseKey = promptLookupKey(phrase);
if (!promptKey || !phraseKey) {
return false;
}
return ` ${promptKey} `.includes(` ${phraseKey} `);
}
function shell(root, title, view) {
root.replaceChildren();
const header = document.createElement("div");
header.className = "funpack-scene-panel-header";
const h = document.createElement("div");
h.textContent = title;
const close = panelButton("Close");
close.addEventListener("click", closePanel);
header.append(h, close);
const error = document.createElement("div");
error.dataset.role = "error";
error.className = "funpack-scene-error";
const body = document.createElement("div");
body.className = `funpack-scene-body funpack-scene-${view}`;
root.append(header, error, body);
return body;
}
function renderPanel(panel, node, view, options = {}) {
if (!panel?.root) {
return;
}
if (options.cancelTarget) {
panel.cancelTarget = options.cancelTarget;
}
panel.currentView = view;
if (view === "positive" || view === "negative") {
renderPromptEditor(panel, node, view);
} else if (view === "database") {
renderDatabaseEditor(panel, node);
} else if (view === "create") {
renderCreateScene(panel, node, options.nextView || "menu");
} else {
renderMenu(panel, node);
}
}
function navigatePanel(panel, node, view, options = {}) {
if (["menu", "positive", "negative", "database"].includes(view) && needsSceneCreation(node)) {
renderPanel(panel, node, "create", { ...options, nextView: view });
return;
}
renderPanel(panel, node, view, options);
}
function cancelEditor(panel, node) {
if (panel?.cancelTarget === "close") {
closePanel();
return;
}
renderPanel(panel, node, "menu");
}
function renderCreateScene(panel, node, nextView = "menu") {
const body = shell(panel.root, "Create scene", "create");
const message = document.createElement("div");
message.className = "funpack-scene-muted";
message.textContent = "Create a scene before editing prompts or database choices.";
body.append(message);
const row = document.createElement("label");
row.className = "funpack-scene-create-row";
const label = document.createElement("span");
label.textContent = "Scene name";
const input = panelTextInput(getWidgetValue(node, "scene_name", ""), "Scene name");
row.append(label, input);
body.append(row);
const footer = document.createElement("div");
footer.className = "funpack-scene-footer";
const cancel = panelButton("Cancel");
cancel.addEventListener("click", closePanel);
const create = panelButton("Create scene", "primary");
create.addEventListener("click", () => {
const name = input.value.trim();
if (!name) {
showPanelError(new Error("Scene name is required."));
input.focus();
return;
}
setWidgetValue(node, "scene_name", name);
setWidgetValue(node, "scene", NONE_SCENE);
setWidgetValue(node, "mode", getWidgetValue(node, "mode", "Manual") || "Manual");
setDirty(node);
renderPanel(panel, node, nextView, {
cancelTarget: nextView === "menu" ? "menu" : panel.cancelTarget,
});
});
input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
create.click();
}
});
footer.append(cancel, create);
body.append(footer);
input.focus();
}
function renderMenu(panel, node) {
const body = shell(panel.root, "FunPack Scene Builder", "menu");
const controls = document.createElement("div");
controls.className = "funpack-scene-controls";
const sceneSelect = panelSelect(sceneNames(), String(getWidgetValue(node, "scene", NONE_SCENE) || NONE_SCENE));
sceneSelect.addEventListener("change", () => {
const name = sceneSelect.value;
if (name === NONE_SCENE) {
setWidgetValue(node, "scene", NONE_SCENE);
setWidgetValue(node, "scene_name", "");
setWidgetValue(node, "aliases", "");
setWidgetValue(node, "scene_positive", "");
setWidgetValue(node, "scene_negative", "");
setDirty(node);
renderPanel(panel, node, "menu");
return;
}
loadSceneIntoNode(node, name);
renderPanel(panel, node, "menu");
});
const sceneName = panelTextInput(getWidgetValue(node, "scene_name", ""), "Scene name");
sceneName.addEventListener("input", () => {
setWidgetValue(node, "scene_name", sceneName.value);
setDirty(node);
});
const mode = panelSelect(["Manual", "Auto", "Learning"], String(getWidgetValue(node, "mode", "Manual") || "Manual"));
mode.addEventListener("change", () => {
setWidgetValue(node, "mode", mode.value);
setDirty(node);
});
const aliases = panelTextInput(getWidgetValue(node, "aliases", ""), "Aliases, comma separated");
aliases.addEventListener("input", () => {
setWidgetValue(node, "aliases", aliases.value);
setDirty(node);
});
for (const [label, element] of [
["Saved scene", sceneSelect],
["Scene name", sceneName],
["Mode", mode],
["Aliases", aliases],
]) {
const row = document.createElement("label");
const span = document.createElement("span");
span.textContent = label;
row.append(span, element);
controls.append(row);
}
body.append(controls);
const current = document.createElement("div");
current.className = "funpack-scene-current";
const positive = String(getWidgetValue(node, "scene_positive", "") || "").trim();
const negative = String(getWidgetValue(node, "scene_negative", "") || "").trim();
for (const [label, value] of [
["Positive", positive || "empty"],
["Negative", negative || "empty"],
]) {
const row = document.createElement("div");
const b = document.createElement("b");
b.textContent = label;
const span = document.createElement("span");
span.textContent = value;
row.append(b, span);
current.append(row);
}
body.append(current);
const menu = document.createElement("div");
menu.className = "funpack-scene-menu-buttons";
for (const [label, target] of [["Positive prompt", "positive"], ["Negative prompt", "negative"], ["Database", "database"]]) {
const item = panelButton(label, "large");
item.addEventListener("click", () => navigatePanel(panel, node, target, { cancelTarget: "menu" }));
menu.append(item);
}
body.append(menu);
const savedHeader = document.createElement("div");
savedHeader.className = "funpack-scene-section-title";
savedHeader.textContent = "Saved scenes";
body.append(savedHeader);
const saved = document.createElement("div");
saved.className = "funpack-scene-saved";
for (const name of savedSceneNames()) {
const item = panelButton(name);
item.title = name;
item.addEventListener("click", () => {
loadSceneIntoNode(node, name);
renderPanel(panel, node, "menu");
});
saved.append(item);
}
if (!saved.children.length) {
const empty = document.createElement("div");
empty.className = "funpack-scene-muted";
empty.textContent = "No saved scenes yet.";
saved.append(empty);
}
body.append(saved);
const footer = document.createElement("div");
footer.className = "funpack-scene-footer";
const refresh = panelButton("Refresh");
refresh.addEventListener("click", async () => {
await refreshNode(node);
renderPanel(panel, node, "menu");
});
const save = panelButton("Save scene", "primary");
save.addEventListener("click", async () => {
try {
await saveSceneFromNode(node);
renderPanel(panel, node, "menu");
} catch (error) {
showPanelError(error);
}
});
const del = panelButton("Delete scene", "danger");
del.addEventListener("click", async () => {
try {
await deleteSceneFromNode(node);
renderPanel(panel, node, "menu");
} catch (error) {
showPanelError(error);
}
});
const imp = panelButton("Import");
imp.addEventListener("click", () => importScenes(node));
const exp = panelButton("Export");
exp.addEventListener("click", async () => {
try {
await exportScenes(node);
} catch (error) {
showPanelError(error);
}
});
footer.append(refresh, save, del, imp, exp);
body.append(footer);
}
function renderPromptEditor(panel, node, kind) {
const isNegative = kind === "negative";
const widgetName = isNegative ? "scene_negative" : "scene_positive";
const snapshotKey = `${kind}_snapshot`;
if (!(snapshotKey in panel.snapshots)) {
panel.snapshots[snapshotKey] = String(getWidgetValue(node, widgetName, "") || "");
}
const body = shell(panel.root, isNegative ? "Negative prompt" : "Positive prompt", "prompt");
const textarea = document.createElement("textarea");
textarea.className = "funpack-scene-textarea";
textarea.spellcheck = false;
textarea.value = String(getWidgetValue(node, widgetName, "") || "");
textarea.placeholder = isNegative ? "Write negative prompt words and phrases here." : "Write positive prompt words and phrases here.";
textarea.addEventListener("input", () => {
setWidgetValue(node, widgetName, textarea.value);
setDirty(node);
updateUsedChips();
});
textarea.addEventListener("dragover", (event) => event.preventDefault());
textarea.addEventListener("drop", (event) => {
event.preventDefault();
const text = event.dataTransfer?.getData("text/plain");
if (text) {
insertAtCursor(textarea, text);
textarea.dispatchEvent(new Event("input"));
}
});
body.append(textarea);
const search = document.createElement("input");
search.type = "search";
search.placeholder = "Search phrase bank";
search.className = "funpack-scene-search";
body.append(search);
const bank = document.createElement("div");
bank.className = "funpack-scene-bank";
body.append(bank);
const updateUsedChips = () => {
for (const chip of bank.querySelectorAll(".funpack-scene-chip[data-phrase]")) {
const used = promptContainsPhrase(textarea.value, chip.dataset.phrase || "");
chip.classList.toggle("used", used);
chip.title = used ? `Already used: ${chip.dataset.phrase}` : (chip.dataset.phrase || chip.textContent || "");
}
};
const renderBank = () => {
const query = search.value.toLowerCase().trim();
bank.replaceChildren();
const items = memoryItems()
.filter((item) => isNegative ? item.source === "negative" || item.category === "negative" : item.source !== "negative" && item.category !== "negative")
.filter((item) => !query || String(item.text || "").toLowerCase().includes(query));
const byGroup = new Map();
for (const item of items) {
const group = item.category || (item.source === "negative" ? "negative" : "details");
if (!byGroup.has(group)) {
byGroup.set(group, []);
}
byGroup.get(group).push(item);
}
for (const group of GROUP_ORDER) {
const groupItems = byGroup.get(group) || [];
if (!groupItems.length) {
continue;
}
const title = document.createElement("div");
title.className = "funpack-scene-category";
title.textContent = group;
bank.append(title);
const chips = document.createElement("div");
chips.className = "funpack-scene-chip-row";
for (const item of groupItems) {
const phrase = item.text || item.key;
const chip = phraseButton(phrase, (text) => {
insertAtCursor(textarea, text);
textarea.dispatchEvent(new Event("input"));
});
chip.dataset.phrase = phrase;
chips.append(chip);
}
bank.append(chips);
}
updateUsedChips();
};
search.addEventListener("input", renderBank);
renderBank();
const footer = document.createElement("div");
footer.className = "funpack-scene-footer";
const back = panelButton("Back");
back.addEventListener("click", () => renderPanel(panel, node, "menu"));
const cancel = panelButton("Cancel");
cancel.addEventListener("click", () => {
setWidgetValue(node, widgetName, panel.snapshots[snapshotKey]);
setDirty(node);
delete panel.snapshots[snapshotKey];
cancelEditor(panel, node);
});
const confirm = panelButton("Confirm", "primary");
confirm.addEventListener("click", async () => {
try {
if (!activeSceneName(node)) {
renderPanel(panel, node, "create", { nextView: kind, cancelTarget: panel.cancelTarget });
return;
}
setWidgetValue(node, widgetName, textarea.value);
await saveSceneFromNode(node);
delete panel.snapshots[snapshotKey];
renderPanel(panel, node, "menu");
} catch (error) {
showPanelError(error);
}
});
footer.append(back, cancel, confirm);
body.append(footer);
textarea.focus();
}
function renderDatabaseEditor(panel, node) {
if (!("database_snapshot" in panel.snapshots)) {
panel.snapshots.database_snapshot = JSON.parse(JSON.stringify(memoryItems()));
panel.databaseItems = JSON.parse(JSON.stringify(memoryItems()));
}
const body = shell(panel.root, "Database", "database");
const tools = document.createElement("div");
tools.className = "funpack-scene-db-tools";
const addText = document.createElement("input");
addText.placeholder = "Add word or phrase";
const addCategory = document.createElement("select");
for (const category of GROUP_ORDER) {
const option = document.createElement("option");
option.value = category;
option.textContent = category;
addCategory.append(option);
}
const addWildcard = panelCheckbox(false, "Wildcard");
const add = panelButton("Add", "primary");
add.addEventListener("click", () => {
const text = addText.value.trim();
if (!text) {
return;
}
panel.databaseItems.unshift({
text,
key: text.toLowerCase(),
source: addCategory.value === "negative" ? "negative" : "positive",
category: addCategory.value,
count: 0,
wildcard: addWildcard.input.checked,
});
addText.value = "";
addWildcard.input.checked = false;
renderPanel(panel, node, "database");
});
tools.append(addText, addCategory, addWildcard.wrapper, add);
body.append(tools);
const search = document.createElement("input");
search.type = "search";
search.placeholder = "Search database";
search.className = "funpack-scene-search";
body.append(search);
const list = document.createElement("div");
list.className = "funpack-scene-db-list";
body.append(list);
const renderList = () => {
const query = search.value.toLowerCase().trim();
list.replaceChildren();
const items = panel.databaseItems.filter((item) => !query || String(item.text || "").toLowerCase().includes(query));
items.forEach((item) => {
const row = document.createElement("div");
row.className = "funpack-scene-db-row";
const text = editableTextLabel(item.text || "", (value) => {
item.text = value;
item.key = value.toLowerCase();
});
const category = document.createElement("select");
for (const categoryName of GROUP_ORDER) {
const option = document.createElement("option");
option.value = categoryName;
option.textContent = categoryName;
option.selected = categoryName === item.category;
category.append(option);
}
category.addEventListener("change", () => {
item.category = category.value;
item.source = category.value === "negative" ? "negative" : "positive";
});
const wildcard = panelCheckbox(Boolean(item.wildcard || item.wildcard_group), "Wildcard");
wildcard.input.addEventListener("change", () => {
item.wildcard = wildcard.input.checked;
});
const del = panelButton("Delete", "danger");
del.addEventListener("click", () => {
panel.databaseItems = panel.databaseItems.filter((candidate) => candidate !== item);
renderList();
});
row.append(text, category, wildcard.wrapper, del);
list.append(row);
});
};
search.addEventListener("input", renderList);
renderList();
const footer = document.createElement("div");
footer.className = "funpack-scene-footer";
const back = panelButton("Back");
back.addEventListener("click", () => renderPanel(panel, node, "menu"));
const cancel = panelButton("Cancel");
cancel.addEventListener("click", () => {
panel.databaseItems = JSON.parse(JSON.stringify(panel.snapshots.database_snapshot));
delete panel.snapshots.database_snapshot;
cancelEditor(panel, node);
});
const confirm = panelButton("Confirm", "primary");
confirm.addEventListener("click", async () => {
try {
await saveDatabase(node, panel.databaseItems);
delete panel.snapshots.database_snapshot;
renderPanel(panel, node, "menu");
} catch (error) {
showPanelError(error);
}
});
footer.append(back, cancel, confirm);
body.append(footer);
}
function openPanel(node, view = "menu") {
closePanel();
injectStyles();
const root = document.createElement("div");
root.className = "funpack-scene-panel";
document.body.append(root);
activePanel = {
root,
snapshots: {},
databaseItems: [],
cancelTarget: view === "menu" ? "menu" : "close",
};
const viewportWidth = Number(window.innerWidth);
const viewportHeight = Number(window.innerHeight);
if (Number.isFinite(viewportWidth) && Number.isFinite(viewportHeight) && viewportWidth > 0 && viewportHeight > 0) {
root.style.left = "50%";
root.style.top = "50%";
root.style.transform = "translate(-50%, -50%)";
} else {
const canvasRect = app.canvas?.canvas?.getBoundingClientRect?.();
const scale = app.canvas?.ds?.scale || 1;
const offset = app.canvas?.ds?.offset || [0, 0];
const screenX = (canvasRect?.left || 0) + (node.pos[0] + node.size[0]) * scale + offset[0];
const screenY = (canvasRect?.top || 0) + node.pos[1] * scale + offset[1];
const left = Math.min(Math.max(12, window.innerWidth - 460), Math.max(12, screenX + 16));
const top = Math.min(Math.max(12, window.innerHeight - 620), Math.max(12, screenY));
root.style.left = `${left}px`;
root.style.top = `${top}px`;
root.style.transform = "";
}
void refreshNode(node).catch((error) => {
console.warn("FunPack: failed to refresh scenes before opening Scene Builder", error);
}).finally(() => {
if (activePanel?.root === root) {
navigatePanel(activePanel, node, view, { cancelTarget: activePanel.cancelTarget });
}
});
}
let stylesInjected = false;
function injectStyles() {
if (stylesInjected) {
return;
}
stylesInjected = true;
const style = document.createElement("style");
style.textContent = `
.funpack-scene-panel {
position: fixed;
z-index: 10000;
width: min(448px, calc(100vw - 24px));
max-height: min(620px, calc(100vh - 24px));
display: flex;
flex-direction: column;
padding: 10px;
border: 1px solid rgba(180, 190, 200, 0.35);
border-radius: 8px;
background: rgba(30, 32, 36, 0.98);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.42);
color: #ddd;
font: 12px sans-serif;
box-sizing: border-box;
}
.funpack-scene-panel * { box-sizing: border-box; }
.funpack-scene-panel-header,
.funpack-scene-footer {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
flex-wrap: wrap;
}
.funpack-scene-panel-header {
font-weight: 700;
font-size: 14px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.funpack-scene-body {
overflow: auto;
padding-top: 8px;
min-height: 0;
}
.funpack-scene-error {
min-height: 16px;
color: #ff9f9f;
padding-top: 5px;
}
.funpack-scene-button {
min-height: 26px;
padding: 4px 10px;
border: 1px solid rgba(180, 190, 200, 0.35);
border-radius: 5px;
background: #22252a;
color: #eee;
cursor: pointer;
white-space: nowrap;
}
.funpack-scene-button:hover { background: #2b3037; }
.funpack-scene-button.primary { border-color: rgba(100, 210, 140, 0.6); background: #244832; }
.funpack-scene-button.danger { border-color: rgba(255, 130, 130, 0.45); background: #472626; }
.funpack-scene-button.compact {
min-height: 28px;
padding: 4px 7px;
}
.funpack-scene-button.large {
width: 100%;
height: 42px;
text-align: left;
font-weight: 700;
}
.funpack-scene-current {
display: grid;
gap: 5px;
margin-bottom: 10px;
}
.funpack-scene-controls {
display: grid;
gap: 7px;
margin-bottom: 10px;
}
.funpack-scene-controls label {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.funpack-scene-create-row {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 8px;
align-items: center;
margin-top: 8px;
}
.funpack-scene-controls span {
color: #b8c0ca;
}
.funpack-scene-current div {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 8px;
}
.funpack-scene-current span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #b8c0ca;
}
.funpack-scene-menu-buttons,
.funpack-scene-saved {
display: grid;
gap: 7px;
}
.funpack-scene-section-title,
.funpack-scene-category {
margin: 12px 0 6px;
color: #58a6d6;
font-weight: 700;
text-transform: uppercase;
}
.funpack-scene-textarea {
width: 100%;
min-height: 156px;
resize: vertical;
padding: 8px;
border: 1px solid rgba(180, 190, 200, 0.35);
border-radius: 6px;
background: #17191d;
color: #f4f4f4;
line-height: 1.35;
outline: none;
}
.funpack-scene-search,
.funpack-scene-controls input,
.funpack-scene-controls select,
.funpack-scene-create-row input,
.funpack-scene-db-tools input,
.funpack-scene-db-tools select,
.funpack-scene-editable input,
.funpack-scene-db-row input,
.funpack-scene-db-row select {
width: 100%;
min-height: 28px;
padding: 5px 7px;
border: 1px solid rgba(180, 190, 200, 0.28);
border-radius: 5px;
background: #17191d;
color: #f2f2f2;
outline: none;
}
.funpack-scene-db-tools .funpack-scene-checkbox input,
.funpack-scene-db-row .funpack-scene-checkbox input {
width: auto;
min-height: 0;
padding: 0;
}
.funpack-scene-checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 28px;
color: #d9dee5;
white-space: nowrap;
}
.funpack-scene-editable {
min-width: 0;
}
.funpack-scene-inline-editor {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 5px;
align-items: center;
}
.funpack-scene-editable button {
width: 100%;
min-height: 28px;
padding: 5px 7px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: #f2f2f2;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: default;
}
.funpack-scene-editable button:hover,
.funpack-scene-editable button:focus {
border-color: rgba(180, 190, 200, 0.28);
background: #17191d;
outline: none;
}
.funpack-scene-search { margin: 8px 0; }
.funpack-scene-bank,
.funpack-scene-db-list {
max-height: 270px;
overflow: auto;
padding-right: 3px;
}
.funpack-scene-chip-row {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.funpack-scene-chip {
max-width: 100%;
padding: 4px 8px;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 6px;
background: rgba(255,255,255,0.07);
color: #eee;
cursor: grab;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.funpack-scene-chip.used {
border-color: rgba(100, 210, 140, 0.75);
background: rgba(58, 132, 86, 0.42);
color: #eaffef;
}
.funpack-scene-chip.used::after {
content: " used";
color: #a8efbf;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
}
.funpack-scene-db-tools {
display: grid;
grid-template-columns: minmax(0, 1.6fr) 110px auto auto;
gap: 6px;
align-items: center;
}
.funpack-scene-db-row {
display: grid;
grid-template-columns: minmax(0, 1.7fr) 110px auto auto;
gap: 6px;
align-items: center;
padding: 5px 0;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.funpack-scene-footer {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid rgba(255,255,255,0.08);
justify-content: flex-end;
}
.funpack-scene-muted { color: #9da6b0; }
`;
document.head.append(style);
}
class SceneBuilderWidget {
constructor(node) {
this.node = node;
this.name = "funpack_scene_builder";
this.type = "custom";
this.options = { serialize: false };
this.value = "";
this.hitAreas = {};
}
computeSize(width) {
return [width, 82];
}
draw(ctx, node, width, y) {
this.hitAreas = {};
const margin = 10;
let cy = y + 4;
const innerWidth = width - margin * 2;
const mode = getWidgetValue(node, "mode", "Manual");
const scene = String(getWidgetValue(node, "scene_name", "") || "Unnamed scene").trim();
ctx.save();
ctx.beginPath();
ctx.rect(0, y, width, this.computeSize(width)[1]);
ctx.clip();
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.font = "12px sans-serif";
ctx.fillText(fitString(ctx, `Scene Builder: ${scene}`, width - 134), margin, cy + 9);
button(ctx, this, "open:menu", "Open Editor", width - 112, cy, 92, 20);
cy += 28;
const third = Math.floor((innerWidth - 12) / 3);
button(ctx, this, "open:positive", "Positive", margin, cy, third, 28);
button(ctx, this, "open:negative", "Negative", margin + third + 6, cy, third, 28);
button(ctx, this, "open:database", "Database", margin + (third + 6) * 2, cy, innerWidth - (third + 6) * 2, 28);
ctx.globalAlpha = app.canvas.editor_alpha * 0.62;
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.fillText(`${mode}. ${memoryItems().length} database phrase(s). Connected prompts learn on queue.`, margin, cy + 42);
ctx.globalAlpha = app.canvas.editor_alpha;
ctx.restore();
}
mouse(event, pos, node) {
if (event.type !== "pointerup") {
return event.type === "pointerdown" && this.hitAt(pos);
}
const hit = Object.entries(this.hitAreas).find(([, bounds]) => (
pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[2] &&
pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3]
));
if (!hit) {
return false;
}
const [, view] = hit[0].split(":");
openPanel(node, view || "menu");
return true;
}
hitAt(pos) {
return Object.values(this.hitAreas).some((bounds) => (
pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[2] &&
pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3]
));
}
}
function removeSceneBuilderWidget(node) {
node.widgets = (node.widgets || []).filter((widget) => widget.name !== "funpack_scene_builder");
}
function setupSceneBuilderNode(node) {
trackedNodes.add(node);
const hideInternalWidgets = () => {
for (const widget of node.widgets || []) {
if (HIDDEN_WIDGETS.has(widget.name)) {
hideWidget(widget);
}
}
setDirty(node);
};
hideInternalWidgets();
window.requestAnimationFrame?.(hideInternalWidgets);
window.setTimeout(hideInternalWidgets, 0);
window.setTimeout(hideInternalWidgets, 250);
removeSceneBuilderWidget(node);
node.addCustomWidget(new SceneBuilderWidget(node));
updateSceneCombo(node);
void fetchScenes(currentRefinementKey(node)).then(() => {
updateSceneCombo(node);
setDirty(node);
});
}
function wrapRefreshFunction(functionName) {
const original = app[functionName];
if (typeof original !== "function" || original.__funpackSceneBuilderWrapped) {
return;
}
const wrapped = function () {
const result = original.apply(this, arguments);
Promise.resolve(result).finally(() => scheduleRefresh());
return result;
};
wrapped.__funpackSceneBuilderWrapped = true;
app[functionName] = wrapped;
}
function hookComfyRefreshControls() {
for (const functionName of ["refreshComboInNodes", "refreshComboInNode", "refreshNodeDefs"]) {
wrapRefreshFunction(functionName);
}
}
app.registerExtension({
name: "funpack.sceneBuilder",
setup() {
hookComfyRefreshControls();
window.setTimeout(hookComfyRefreshControls, 0);
window.setTimeout(hookComfyRefreshControls, 1000);
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_NAME) {
return;
}
hookComfyRefreshControls();
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
originalOnNodeCreated?.apply(this, arguments);
setupSceneBuilderNode(this);
};
const originalConfigure = nodeType.prototype.configure;
nodeType.prototype.configure = function (info) {
originalConfigure?.apply(this, arguments);
setupSceneBuilderNode(this);
};
},
});