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

1053 lines
33 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",
"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 || widget.__funpackHidden) {
return;
}
widget.__funpackHidden = true;
widget.computeSize = () => [0, -4];
widget.type = "hidden";
}
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 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) {
return String(getWidgetValue(node, "refinement_key", "") || "").trim();
}
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 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_group: 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 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 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) {
if (!panel?.root) {
return;
}
if (view === "positive" || view === "negative") {
renderPromptEditor(panel, node, view);
} else if (view === "database") {
renderDatabaseEditor(panel, node);
} else {
renderMenu(panel, node);
}
}
function renderMenu(panel, node) {
const body = shell(panel.root, "FunPack Scene Builder", "menu");
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 [
["Scene", String(getWidgetValue(node, "scene_name", "") || "Unnamed")],
["Mode", String(getWidgetValue(node, "mode", "Manual"))],
["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", () => renderPanel(panel, node, target));
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 sceneNames().filter((item) => item !== NONE_SCENE)) {
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);
});
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 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) {
chips.append(phraseButton(item.text || item.key, (text) => {
insertAtCursor(textarea, text);
textarea.dispatchEvent(new Event("input"));
}));
}
bank.append(chips);
}
};
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];
renderPanel(panel, node, "menu");
});
const confirm = panelButton("Confirm", "primary");
confirm.addEventListener("click", async () => {
try {
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 = document.createElement("input");
addWildcard.placeholder = "Wildcard group";
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_group: addWildcard.value.trim(),
});
addText.value = "";
renderPanel(panel, node, "database");
});
tools.append(addText, addCategory, addWildcard, 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 = document.createElement("input");
text.value = item.text || "";
text.addEventListener("input", () => {
item.text = text.value;
item.key = text.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 = document.createElement("input");
wildcard.value = item.wildcard_group || "";
wildcard.placeholder = "Wildcard";
wildcard.addEventListener("input", () => {
item.wildcard_group = wildcard.value.trim();
});
const del = panelButton("Delete", "danger");
del.addEventListener("click", () => {
panel.databaseItems = panel.databaseItems.filter((candidate) => candidate !== item);
renderList();
});
row.append(text, category, wildcard, 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;
renderPanel(panel, node, "menu");
});
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: [] };
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`;
renderPanel(activePanel, node, view);
}
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.large {
width: 100%;
height: 42px;
text-align: left;
font-weight: 700;
}
.funpack-scene-current {
display: grid;
gap: 5px;
margin-bottom: 10px;
}
.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-db-tools input,
.funpack-scene-db-tools select,
.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-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-db-tools {
display: grid;
grid-template-columns: minmax(0, 1.6fr) 110px minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.funpack-scene-db-row {
display: grid;
grid-template-columns: minmax(0, 1.7fr) 110px minmax(0, 0.9fr) 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, 142];
}
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 positive = String(getWidgetValue(node, "scene_positive", "") || "").trim();
const negative = String(getWidgetValue(node, "scene_negative", "") || "").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(`Scene Builder: ${mode}`, 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);
cy += 42;
ctx.fillStyle = "#58a6d6";
ctx.fillText(`+ ${fitString(ctx, positive || "empty positive prompt", innerWidth - 12)}`, margin, cy);
cy += 20;
ctx.fillStyle = "#d67b7b";
ctx.fillText(`- ${fitString(ctx, negative || "empty negative prompt", innerWidth - 12)}`, margin, cy);
cy += 20;
ctx.globalAlpha = app.canvas.editor_alpha * 0.62;
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.fillText(`${memoryItems().length} database phrase(s). Connected prompts learn on queue.`, margin, cy);
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);
for (const widget of node.widgets || []) {
if (HIDDEN_WIDGETS.has(widget.name)) {
hideWidget(widget);
}
}
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);
};
},
});