741 lines
24 KiB
JavaScript
741 lines
24 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { api } from "../../scripts/api.js";
|
|
|
|
const NODE_NAME = "FunPackSceneBuilder";
|
|
const NONE_SCENE = "-None-";
|
|
const PAYLOAD_WIDGET = "scene_payload";
|
|
const ACTION_WIDGET = "action";
|
|
const LORA_TYPES = ["general", "action", "style", "quality", "character"];
|
|
const GROUP_ORDER = ["subject", "appearance", "action", "camera", "environment", "style", "quality", "details", "negative"];
|
|
|
|
let sceneData = null;
|
|
let loraValues = ["None"];
|
|
let activePicker = 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 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 normalizePayload(payload) {
|
|
if (typeof payload === "string") {
|
|
try {
|
|
payload = JSON.parse(payload);
|
|
} catch (_error) {
|
|
payload = {};
|
|
}
|
|
}
|
|
if (!payload || typeof payload !== "object") {
|
|
payload = {};
|
|
}
|
|
return {
|
|
positive_phrases: Array.isArray(payload.positive_phrases) ? payload.positive_phrases.filter(Boolean) : [],
|
|
negative_phrases: Array.isArray(payload.negative_phrases) ? payload.negative_phrases.filter(Boolean) : [],
|
|
loras: Array.isArray(payload.loras) ? payload.loras.map(normalizeLoraRow) : [],
|
|
};
|
|
}
|
|
|
|
function payloadFromNode(node) {
|
|
return normalizePayload(getWidgetValue(node, PAYLOAD_WIDGET, "{}"));
|
|
}
|
|
|
|
function writePayload(node, payload) {
|
|
setWidgetValue(node, PAYLOAD_WIDGET, JSON.stringify(normalizePayload(payload)));
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
function sceneNames() {
|
|
const names = sceneData?.scenes;
|
|
return Array.isArray(names) && names.length ? names : [NONE_SCENE];
|
|
}
|
|
|
|
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 fetchScenes() {
|
|
try {
|
|
const response = await api.fetchApi(`/funpack/scenes?cache_bust=${Date.now()}`, { 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;
|
|
}
|
|
|
|
async function fetchLoras() {
|
|
try {
|
|
const response = await api.fetchApi(`/funpack/loras?cache_bust=${Date.now()}`, { cache: "no-store" });
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const values = await response.json();
|
|
if (Array.isArray(values) && values.length) {
|
|
loraValues = values;
|
|
}
|
|
} catch (error) {
|
|
console.warn("FunPack: failed to refresh LoRAs", error);
|
|
}
|
|
return loraValues;
|
|
}
|
|
|
|
async function refreshTracked() {
|
|
await Promise.all([fetchScenes(), fetchLoras()]);
|
|
for (const node of [...trackedNodes]) {
|
|
if (!node?.graph) {
|
|
trackedNodes.delete(node);
|
|
continue;
|
|
}
|
|
updateSceneCombo(node);
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
}
|
|
|
|
function scheduleRefresh() {
|
|
window.setTimeout(() => void refreshTracked(), 100);
|
|
}
|
|
|
|
async function queueGraph() {
|
|
if (typeof app.queuePrompt === "function") {
|
|
return await app.queuePrompt(0);
|
|
}
|
|
const button = document.querySelector("#queue-button, .queue-button, button[title='Queue Prompt']");
|
|
if (button) {
|
|
button.click();
|
|
}
|
|
}
|
|
|
|
async function runSceneAction(node, action) {
|
|
setWidgetValue(node, ACTION_WIDGET, action);
|
|
node.setDirtyCanvas(true, true);
|
|
try {
|
|
await queueGraph();
|
|
} finally {
|
|
window.setTimeout(() => {
|
|
setWidgetValue(node, ACTION_WIDGET, "load");
|
|
scheduleRefresh();
|
|
node.setDirtyCanvas(true, true);
|
|
}, 250);
|
|
}
|
|
}
|
|
|
|
async function exportScenes() {
|
|
const response = await api.fetchApi(`/funpack/scenes/export?cache_bust=${Date.now()}`, { 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() {
|
|
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 response = await api.fetchApi("/funpack/scenes/import", {
|
|
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 refreshTracked();
|
|
} catch (error) {
|
|
console.warn("FunPack: scene import failed", error);
|
|
app.canvas?.prompt?.("Scene import failed", error.message || String(error), () => {});
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
function closePicker() {
|
|
if (activePicker) {
|
|
activePicker.remove();
|
|
activePicker = null;
|
|
}
|
|
}
|
|
|
|
function searchablePicker(event, values, currentValue, onSelect, title = "Search") {
|
|
closePicker();
|
|
const root = document.createElement("div");
|
|
Object.assign(root.style, {
|
|
position: "fixed",
|
|
zIndex: 10000,
|
|
minWidth: "320px",
|
|
maxWidth: "520px",
|
|
maxHeight: "420px",
|
|
padding: "8px",
|
|
border: "1px solid rgba(180, 190, 200, 0.35)",
|
|
borderRadius: "8px",
|
|
background: "rgba(30, 32, 36, 0.98)",
|
|
boxShadow: "0 16px 40px rgba(0, 0, 0, 0.35)",
|
|
color: "#ddd",
|
|
font: "12px sans-serif",
|
|
});
|
|
const input = document.createElement("input");
|
|
input.type = "search";
|
|
input.placeholder = title;
|
|
input.value = currentValue && currentValue !== "None" ? currentValue : "";
|
|
Object.assign(input.style, {
|
|
boxSizing: "border-box",
|
|
width: "100%",
|
|
margin: "0 0 6px 0",
|
|
padding: "7px 8px",
|
|
border: "1px solid rgba(180, 190, 200, 0.35)",
|
|
borderRadius: "5px",
|
|
background: "#15171a",
|
|
color: "#fff",
|
|
outline: "none",
|
|
});
|
|
const list = document.createElement("div");
|
|
Object.assign(list.style, { maxHeight: "350px", overflowY: "auto" });
|
|
const render = () => {
|
|
const parts = input.value.toLowerCase().split(/\s+/).filter(Boolean);
|
|
const filtered = values.filter((value) => {
|
|
const haystack = String(value).toLowerCase().replace(/[_\-./\\]+/g, " ");
|
|
return parts.every((part) => haystack.includes(part));
|
|
}).slice(0, 160);
|
|
list.replaceChildren();
|
|
for (const value of filtered) {
|
|
const row = document.createElement("button");
|
|
row.type = "button";
|
|
row.textContent = value;
|
|
row.title = value;
|
|
Object.assign(row.style, {
|
|
display: "block",
|
|
width: "100%",
|
|
padding: "6px 8px",
|
|
border: "0",
|
|
borderRadius: "4px",
|
|
background: value === currentValue ? "rgba(120, 150, 170, 0.32)" : "transparent",
|
|
color: "#ddd",
|
|
textAlign: "left",
|
|
cursor: "pointer",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
});
|
|
row.addEventListener("click", () => {
|
|
onSelect(value);
|
|
closePicker();
|
|
});
|
|
list.append(row);
|
|
}
|
|
};
|
|
input.addEventListener("input", render);
|
|
input.addEventListener("keydown", (keyEvent) => {
|
|
if (keyEvent.key === "Escape") {
|
|
closePicker();
|
|
keyEvent.stopPropagation();
|
|
} else if (keyEvent.key === "Enter") {
|
|
const first = list.querySelector("button");
|
|
if (first) {
|
|
onSelect(first.textContent);
|
|
closePicker();
|
|
}
|
|
keyEvent.preventDefault();
|
|
keyEvent.stopPropagation();
|
|
}
|
|
});
|
|
root.append(input, list);
|
|
document.body.append(root);
|
|
activePicker = root;
|
|
const rect = root.getBoundingClientRect();
|
|
root.style.left = `${Math.min(window.innerWidth - rect.width - 12, Math.max(12, event.clientX ?? 12))}px`;
|
|
root.style.top = `${Math.min(window.innerHeight - rect.height - 12, Math.max(12, event.clientY ?? 12))}px`;
|
|
window.setTimeout(() => {
|
|
document.addEventListener("pointerdown", function outsideClick(clickEvent) {
|
|
if (!root.contains(clickEvent.target)) {
|
|
closePicker();
|
|
document.removeEventListener("pointerdown", outsideClick, true);
|
|
}
|
|
}, true);
|
|
}, 0);
|
|
render();
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
|
|
function normalizeLoraRow(row = {}) {
|
|
const strength = Number(row.strength ?? row.base_model_weight ?? 1.0);
|
|
const type = row.type === "concept" ? "action" : row.type;
|
|
return {
|
|
on: row.on !== false,
|
|
lora: row.lora || row.name || "None",
|
|
type: LORA_TYPES.includes(type) ? type : "general",
|
|
strength: Number.isFinite(strength) ? strength : 1.0,
|
|
};
|
|
}
|
|
|
|
function scenePayloadFromStoredScene(scene) {
|
|
return normalizePayload({
|
|
positive_phrases: scene?.positive_phrases || [],
|
|
negative_phrases: scene?.negative_phrases || [],
|
|
loras: scene?.loras || [],
|
|
});
|
|
}
|
|
|
|
function storedScene(node) {
|
|
const name = getWidgetValue(node, "scene", NONE_SCENE);
|
|
return sceneData?.data?.scenes?.[name] || null;
|
|
}
|
|
|
|
function loadStoredSceneIntoNode(node) {
|
|
const name = getWidgetValue(node, "scene", NONE_SCENE);
|
|
const scene = storedScene(node);
|
|
if (!scene || name === NONE_SCENE) {
|
|
return;
|
|
}
|
|
if (node.__funpackLoadedScene === name) {
|
|
return;
|
|
}
|
|
node.__funpackLoadedScene = name;
|
|
setWidgetValue(node, "scene_name", scene.name || name);
|
|
setWidgetValue(node, "aliases", Array.isArray(scene.aliases) ? scene.aliases.join(", ") : "");
|
|
setWidgetValue(node, "output_mode", scene.output_mode || "Manual");
|
|
setWidgetValue(node, "mode", scene.mode || "ltx2");
|
|
setWidgetValue(node, "per_block", scene.per_block === true);
|
|
setWidgetValue(node, "refinement_key", scene.refinement_key || "");
|
|
writePayload(node, scenePayloadFromStoredScene(scene));
|
|
}
|
|
|
|
function button(ctx, widget, key, label, x, y, width, height) {
|
|
widget.hitAreas[key] = [x, y, width, height];
|
|
roundRect(ctx, x, y, width, height, 4);
|
|
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 drawChip(ctx, widget, key, text, x, y, selected, maxWidth) {
|
|
const width = Math.min(maxWidth, Math.max(34, ctx.measureText(text).width + 18));
|
|
widget.hitAreas[key] = [x, y, width, 19];
|
|
roundRect(ctx, x, y, width, 19, 5);
|
|
ctx.fillStyle = selected ? "rgba(72, 178, 112, 0.85)" : "rgba(255,255,255,0.065)";
|
|
ctx.strokeStyle = selected ? "rgba(150, 230, 170, 0.75)" : "rgba(255,255,255,0.13)";
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = selected ? "#06140b" : LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(fitString(ctx, text, width - 10), x + width / 2, y + 9.5);
|
|
return width;
|
|
}
|
|
|
|
function selectedSet(list) {
|
|
return new Set((list || []).map((item) => String(item).toLowerCase()));
|
|
}
|
|
|
|
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) {
|
|
const payload = payloadFromNode(this.node);
|
|
const loraRows = Math.max(1, payload.loras.length);
|
|
return [width, 330 + loraRows * 22];
|
|
}
|
|
|
|
draw(ctx, node, width, y) {
|
|
loadStoredSceneIntoNode(node);
|
|
this.hitAreas = {};
|
|
const payload = payloadFromNode(node);
|
|
const memory = Array.isArray(sceneData?.memory) ? sceneData.memory : [];
|
|
const margin = 10;
|
|
let cy = y + 4;
|
|
const innerWidth = width - margin * 2;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textBaseline = "middle";
|
|
ctx.textAlign = "left";
|
|
ctx.font = "12px sans-serif";
|
|
ctx.fillText(`Scene Builder: ${getWidgetValue(node, "output_mode", "Manual")}`, margin, cy + 9);
|
|
button(ctx, this, "refresh", "Refresh", width - 244, cy, 56, 20);
|
|
button(ctx, this, "save", "Save", width - 184, cy, 44, 20);
|
|
button(ctx, this, "update", "Update", width - 136, cy, 56, 20);
|
|
button(ctx, this, "delete", "Delete", width - 76, cy, 56, 20);
|
|
cy += 27;
|
|
button(ctx, this, "import", "Import", width - 136, cy, 56, 20);
|
|
button(ctx, this, "export", "Export", width - 76, cy, 56, 20);
|
|
const intent = String(getWidgetValue(node, "intent_prompt", "") || "").trim();
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.72;
|
|
ctx.fillText(intent ? `Intent: ${fitString(ctx, intent, innerWidth - 150)}` : "Intent: empty", margin, cy + 10);
|
|
ctx.globalAlpha = app.canvas.editor_alpha;
|
|
cy += 29;
|
|
|
|
const preview = payload.positive_phrases.join(", ");
|
|
ctx.fillStyle = "#58a6d6";
|
|
ctx.fillText(`Preview: ${fitString(ctx, preview || "no positive scene phrases selected", innerWidth)}`, margin, cy + 8);
|
|
cy += 25;
|
|
|
|
cy = this.drawSelectedSection(ctx, payload, "positive_phrases", "Selected Positive", margin, cy, innerWidth, node);
|
|
cy = this.drawSelectedSection(ctx, payload, "negative_phrases", "Selected Negative", margin, cy + 2, innerWidth, node);
|
|
cy = this.drawLoraSection(ctx, payload, margin, cy + 4, innerWidth, node);
|
|
this.drawMemoryBank(ctx, memory, payload, margin, cy + 8, innerWidth, node);
|
|
ctx.restore();
|
|
}
|
|
|
|
drawSelectedSection(ctx, payload, field, title, x, y, width, node) {
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textAlign = "left";
|
|
ctx.fillText(title, x, y + 8);
|
|
let cx = x;
|
|
let cy = y + 18;
|
|
const items = payload[field] || [];
|
|
if (!items.length) {
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
|
|
ctx.fillText("none", x, cy + 9);
|
|
ctx.globalAlpha = app.canvas.editor_alpha;
|
|
return cy + 24;
|
|
}
|
|
for (const item of items.slice(0, 18)) {
|
|
const key = `${field}:remove:${item}`;
|
|
const chipWidth = Math.min(width, Math.max(34, ctx.measureText(item).width + 18));
|
|
if (cx + chipWidth > x + width) {
|
|
cx = x;
|
|
cy += 23;
|
|
}
|
|
const used = drawChip(ctx, this, key, item, cx, cy, true, width);
|
|
cx += used + 5;
|
|
}
|
|
return cy + 25;
|
|
}
|
|
|
|
drawLoraSection(ctx, payload, x, y, width, node) {
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textAlign = "left";
|
|
ctx.fillText("Scene LoRAs", x, y + 8);
|
|
button(ctx, this, "lora:add", "+ Add LoRA", x + width - 90, y, 90, 20);
|
|
let cy = y + 23;
|
|
const rows = payload.loras.length ? payload.loras : [];
|
|
if (!rows.length) {
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
|
|
ctx.fillText("none", x, cy + 9);
|
|
ctx.globalAlpha = app.canvas.editor_alpha;
|
|
return cy + 24;
|
|
}
|
|
for (let index = 0; index < rows.length; index++) {
|
|
const row = rows[index];
|
|
button(ctx, this, `lora:toggle:${index}`, row.on === false ? "OFF" : "ON", x, cy, 38, 19);
|
|
button(ctx, this, `lora:name:${index}`, row.lora || "None", x + 43, cy, Math.max(80, width - 190), 19);
|
|
button(ctx, this, `lora:type:${index}`, row.type || "general", x + width - 142, cy, 64, 19);
|
|
button(ctx, this, `lora:strength:${index}`, Number(row.strength ?? 1).toFixed(2), x + width - 73, cy, 48, 19);
|
|
button(ctx, this, `lora:delete:${index}`, "x", x + width - 20, cy, 20, 19);
|
|
cy += 22;
|
|
}
|
|
return cy;
|
|
}
|
|
|
|
drawMemoryBank(ctx, memory, payload, x, y, width, node) {
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.textAlign = "left";
|
|
ctx.fillText("Universal Phrase Bank", x, y + 8);
|
|
let cy = y + 23;
|
|
const positive = selectedSet(payload.positive_phrases);
|
|
const negative = selectedSet(payload.negative_phrases);
|
|
const byGroup = new Map();
|
|
for (const item of memory) {
|
|
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 items = (byGroup.get(group) || []).slice(0, 24);
|
|
if (!items.length) {
|
|
continue;
|
|
}
|
|
ctx.fillStyle = "#58a6d6";
|
|
ctx.fillText(group.toUpperCase(), x, cy + 8);
|
|
cy += 17;
|
|
let cx = x;
|
|
for (const item of items) {
|
|
const text = item.text || item.key;
|
|
const selected = group === "negative" ? negative.has(text.toLowerCase()) : positive.has(text.toLowerCase());
|
|
const key = `bank:${group}:${text}`;
|
|
const chipWidth = Math.min(width, Math.max(34, ctx.measureText(text).width + 18));
|
|
if (cx + chipWidth > x + width) {
|
|
cx = x;
|
|
cy += 23;
|
|
}
|
|
const used = drawChip(ctx, this, key, text, cx, cy, selected, width);
|
|
cx += used + 5;
|
|
}
|
|
cy += 25;
|
|
if (cy > y + 185) {
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.fillText("Refresh after queueing to see more collected phrases.", x, cy + 8);
|
|
ctx.globalAlpha = app.canvas.editor_alpha;
|
|
break;
|
|
}
|
|
}
|
|
if (!memory.length) {
|
|
ctx.globalAlpha = app.canvas.editor_alpha * 0.55;
|
|
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
|
|
ctx.fillText("Queue once with positive/negative prompts connected to collect phrases.", x, cy + 8);
|
|
ctx.globalAlpha = app.canvas.editor_alpha;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
this.handleHit(hit[0], event, node);
|
|
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]
|
|
));
|
|
}
|
|
|
|
handleHit(key, event, node) {
|
|
const payload = payloadFromNode(node);
|
|
if (key === "refresh") {
|
|
void refreshTracked();
|
|
return;
|
|
}
|
|
if (["save", "update", "delete"].includes(key)) {
|
|
void runSceneAction(node, key);
|
|
return;
|
|
}
|
|
if (key === "import") {
|
|
importScenes();
|
|
return;
|
|
}
|
|
if (key === "export") {
|
|
void exportScenes();
|
|
return;
|
|
}
|
|
if (key.startsWith("positive_phrases:remove:")) {
|
|
const text = key.slice("positive_phrases:remove:".length);
|
|
payload.positive_phrases = payload.positive_phrases.filter((item) => item !== text);
|
|
writePayload(node, payload);
|
|
return;
|
|
}
|
|
if (key.startsWith("negative_phrases:remove:")) {
|
|
const text = key.slice("negative_phrases:remove:".length);
|
|
payload.negative_phrases = payload.negative_phrases.filter((item) => item !== text);
|
|
writePayload(node, payload);
|
|
return;
|
|
}
|
|
if (key.startsWith("bank:")) {
|
|
const [, group, ...rest] = key.split(":");
|
|
const text = rest.join(":");
|
|
const field = group === "negative" ? "negative_phrases" : "positive_phrases";
|
|
const lower = text.toLowerCase();
|
|
if (payload[field].some((item) => item.toLowerCase() === lower)) {
|
|
payload[field] = payload[field].filter((item) => item.toLowerCase() !== lower);
|
|
} else {
|
|
payload[field].push(text);
|
|
}
|
|
writePayload(node, payload);
|
|
return;
|
|
}
|
|
if (key === "lora:add") {
|
|
void fetchLoras().then((values) => {
|
|
searchablePicker(event, values, "None", (value) => {
|
|
payload.loras.push(normalizeLoraRow({ lora: value }));
|
|
writePayload(node, payload);
|
|
}, "Search LoRA");
|
|
});
|
|
return;
|
|
}
|
|
if (key.startsWith("lora:")) {
|
|
const [, action, rawIndex] = key.split(":");
|
|
const index = Number(rawIndex);
|
|
const row = payload.loras[index];
|
|
if (!row) {
|
|
return;
|
|
}
|
|
if (action === "toggle") {
|
|
row.on = row.on === false;
|
|
writePayload(node, payload);
|
|
} else if (action === "delete") {
|
|
payload.loras.splice(index, 1);
|
|
writePayload(node, payload);
|
|
} else if (action === "name") {
|
|
void fetchLoras().then((values) => {
|
|
searchablePicker(event, values, row.lora, (value) => {
|
|
row.lora = value;
|
|
writePayload(node, payload);
|
|
}, "Search LoRA");
|
|
});
|
|
} else if (action === "type") {
|
|
new LiteGraph.ContextMenu(LORA_TYPES, {
|
|
event,
|
|
title: "LoRA Type",
|
|
callback: (value) => {
|
|
row.type = value;
|
|
writePayload(node, payload);
|
|
},
|
|
});
|
|
} else if (action === "strength") {
|
|
app.canvas.prompt("LoRA strength", row.strength, (value) => {
|
|
const parsed = Number(value);
|
|
if (Number.isFinite(parsed)) {
|
|
row.strength = parsed;
|
|
writePayload(node, payload);
|
|
}
|
|
}, event);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeSceneBuilderWidget(node) {
|
|
node.widgets = (node.widgets || []).filter((widget) => widget.name !== "funpack_scene_builder");
|
|
}
|
|
|
|
function setupSceneBuilderNode(node) {
|
|
trackedNodes.add(node);
|
|
hideWidget(widgetByName(node, ACTION_WIDGET));
|
|
hideWidget(widgetByName(node, PAYLOAD_WIDGET));
|
|
removeSceneBuilderWidget(node);
|
|
node.addCustomWidget(new SceneBuilderWidget(node));
|
|
updateSceneCombo(node);
|
|
void refreshTracked();
|
|
}
|
|
|
|
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);
|
|
};
|
|
},
|
|
});
|