Files
WhatDreamsCost-Dumas/js/msr_character.js
2026-07-10 11:14:49 +00:00

432 lines
14 KiB
JavaScript

const { app } = window.comfyAPI.app;
const { api } = window.comfyAPI.api;
function findWidget(node, name) {
return (node.widgets || []).find((widget) => widget.name === name);
}
function findInput(node, name) {
return (node.inputs || []).find((input) => input.name === name);
}
function getOriginNodeForInput(node, inputName) {
const input = findInput(node, inputName);
if (!input || input.link == null) return null;
const link = app.graph.links[input.link];
if (!link) return null;
return app.graph.getNodeById(link.origin_id);
}
function coercePreviewUrl(value) {
if (!value) return "";
if (typeof value === "string") return value;
if (typeof value === "object") {
if (typeof value.src === "string") return value.src;
if (typeof value.url === "string") return value.url;
}
return "";
}
function resolvePreviewUrlFromOrigin(originNode) {
if (!originNode) return "";
const preview = Array.isArray(originNode.imgs) ? coercePreviewUrl(originNode.imgs[0]) : "";
if (preview) return preview;
const widgets = originNode.widgets || [];
const fileWidget = widgets.find((widget) => ["image", "filename", "file"].includes(widget.name));
const subfolderWidget = widgets.find((widget) => widget.name === "subfolder");
const filename = typeof fileWidget?.value === "string" ? fileWidget.value : "";
const subfolder = typeof subfolderWidget?.value === "string" ? subfolderWidget.value : "";
if (!filename) return "";
return api.apiURL(
`/view?filename=${encodeURIComponent(filename)}&type=input&subfolder=${encodeURIComponent(subfolder)}`
);
}
async function imageInputToDataUrl(node, inputName) {
const originNode = getOriginNodeForInput(node, inputName);
const previewUrl = resolvePreviewUrlFromOrigin(originNode);
if (!previewUrl) return null;
try {
const response = await fetch(previewUrl);
const blob = await response.blob();
return await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
} catch (error) {
console.error("[MSRCharacter] Failed to load preview image", error);
return null;
}
}
function setWidgetValue(node, widget, value) {
if (!widget) return;
const previous = widget.value;
widget.value = value;
if (widget.element) {
if (widget.element.type === "checkbox") {
widget.element.checked = !!value;
} else {
widget.element.value = value;
}
}
if (widget.callback) {
try {
widget.callback(value);
} catch (error) {}
}
if (node.onWidgetChanged) {
try {
node.onWidgetChanged(widget.name, value, previous, widget);
} catch (error) {}
}
if (node.properties) {
node.properties[widget.name] = value;
}
}
function syncFormFromWidgets(node) {
const description = findWidget(node, "description")?.value || "";
const alias = findWidget(node, "alias")?.value || "";
const provider = findWidget(node, "analyze_provider")?.value || "ollama";
const model = findWidget(node, "analyze_model")?.value || "";
const baseUrl = findWidget(node, "analyze_base_url")?.value || "";
if (node._msrAliasInput && node._msrAliasInput.value !== alias) {
node._msrAliasInput.value = alias;
}
if (node._msrDescriptionInput && node._msrDescriptionInput.value !== description) {
node._msrDescriptionInput.value = description;
}
if (node._msrMeta) {
node._msrMeta.innerHTML = [
alias
? `Alias: <span style="color:#e8e8e8">@${String(alias).replace(/^@/, "")}</span>`
: "Alias: <span style=\"color:#666\">none</span>",
`<div style="margin-top:4px;color:#888">Analyze: ${provider}${model ? ` / ${model}` : ""}${baseUrl ? ` / ${baseUrl}` : ""}</div>`,
description
? `<div style="margin-top:4px;color:#d0d0d0">${description}</div>`
: `<div style="margin-top:4px;color:#666">Description will be used for prompt replacement outside MSR prefix mode.</div>`,
].join("");
}
}
function refreshCharacterPreview(node) {
if (!node._msrPreviewRow) return;
const inputNames = ["image_1", "image_2"];
const previewUrls = inputNames
.map((inputName) => resolvePreviewUrlFromOrigin(getOriginNodeForInput(node, inputName)))
.filter(Boolean);
const previewRow = node._msrPreviewRow;
previewRow.innerHTML = "";
if (previewUrls.length) {
previewUrls.forEach((previewUrl) => {
const image = document.createElement("img");
image.src = previewUrl;
Object.assign(image.style, {
flex: "1",
minWidth: "0",
height: "74px",
objectFit: "cover",
borderRadius: "6px",
border: "1px solid #444",
background: "#111",
});
previewRow.appendChild(image);
});
} else {
const placeholder = document.createElement("div");
placeholder.textContent = "Connect up to 2 image nodes";
Object.assign(placeholder.style, {
width: "100%",
height: "74px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#777",
fontSize: "11px",
borderRadius: "6px",
border: "1px dashed #444",
background: "#181818",
});
previewRow.appendChild(placeholder);
}
syncFormFromWidgets(node);
}
function buildCharacterUi(node) {
const container = document.createElement("div");
Object.assign(container.style, {
boxSizing: "border-box",
width: "100%",
padding: "6px 2px 2px 2px",
});
const previewRow = document.createElement("div");
Object.assign(previewRow.style, {
display: "flex",
gap: "6px",
minHeight: "74px",
});
const meta = document.createElement("div");
Object.assign(meta.style, {
marginTop: "8px",
fontSize: "11px",
color: "#aaa",
lineHeight: "1.4",
});
const form = document.createElement("div");
Object.assign(form.style, {
marginTop: "10px",
display: "flex",
flexDirection: "column",
gap: "6px",
});
const settingsButton = document.createElement("button");
settingsButton.type = "button";
settingsButton.textContent = "Analyze Settings";
Object.assign(settingsButton.style, {
alignSelf: "flex-start",
background: "#252525",
color: "#ddd",
border: "1px solid #444",
borderRadius: "6px",
padding: "6px 10px",
fontSize: "11px",
cursor: "pointer",
});
settingsButton.addEventListener("click", () => {
const providerWidget = findWidget(node, "analyze_provider");
const baseUrlWidget = findWidget(node, "analyze_base_url");
const modelWidget = findWidget(node, "analyze_model");
const provider = window.prompt("Analyze provider: ollama, lmstudio, custom, off", providerWidget?.value || "ollama");
if (provider == null) return;
const baseUrl = window.prompt("Analyze base URL (blank = default)", baseUrlWidget?.value || "");
if (baseUrl == null) return;
const model = window.prompt("Analyze model (blank = provider default)", modelWidget?.value || "");
if (model == null) return;
setWidgetValue(node, providerWidget, provider.trim() || "ollama");
setWidgetValue(node, baseUrlWidget, baseUrl.trim());
setWidgetValue(node, modelWidget, model.trim());
syncFormFromWidgets(node);
node.setDirtyCanvas?.(true, true);
});
const aliasLabel = document.createElement("label");
aliasLabel.textContent = "Alias";
Object.assign(aliasLabel.style, {
fontSize: "11px",
color: "#bbb",
});
const aliasInput = document.createElement("input");
aliasInput.type = "text";
aliasInput.placeholder = "optional alias, e.g. bob";
Object.assign(aliasInput.style, {
width: "100%",
boxSizing: "border-box",
background: "#1c1c1c",
color: "#eee",
border: "1px solid #444",
borderRadius: "6px",
padding: "6px 8px",
fontSize: "12px",
});
aliasInput.addEventListener("input", () => {
setWidgetValue(node, findWidget(node, "alias"), aliasInput.value);
syncFormFromWidgets(node);
});
const descLabel = document.createElement("label");
descLabel.textContent = "Description";
Object.assign(descLabel.style, {
fontSize: "11px",
color: "#bbb",
});
const descInput = document.createElement("textarea");
descInput.placeholder = "Character description...";
Object.assign(descInput.style, {
width: "100%",
minHeight: "86px",
boxSizing: "border-box",
resize: "vertical",
background: "#1c1c1c",
color: "#eee",
border: "1px solid #444",
borderRadius: "6px",
padding: "8px",
fontSize: "12px",
lineHeight: "1.35",
});
descInput.addEventListener("input", () => {
setWidgetValue(node, findWidget(node, "description"), descInput.value);
syncFormFromWidgets(node);
});
form.appendChild(aliasLabel);
form.appendChild(aliasInput);
form.appendChild(descLabel);
form.appendChild(descInput);
form.appendChild(settingsButton);
container.appendChild(previewRow);
container.appendChild(meta);
container.appendChild(form);
node._msrPreviewContainer = container;
node._msrPreviewRow = previewRow;
node._msrMeta = meta;
node._msrAliasInput = aliasInput;
node._msrDescriptionInput = descInput;
return container;
}
async function analyzeCharacterNode(node, buttonWidget) {
const descriptionWidget = findWidget(node, "description");
if (!descriptionWidget) return;
const providerWidget = findWidget(node, "analyze_provider");
const baseUrlWidget = findWidget(node, "analyze_base_url");
const modelWidget = findWidget(node, "analyze_model");
buttonWidget.label = "Analyzing...";
node.setDirtyCanvas?.(true, true);
try {
const images = (
await Promise.all([
imageInputToDataUrl(node, "image_1"),
imageInputToDataUrl(node, "image_2"),
])
).filter(Boolean);
if (!images.length) {
throw new Error("Connect at least one image input before analyzing.");
}
const response = await api.fetchApi("/ltx_director/analyze_character", {
method: "POST",
body: JSON.stringify({
provider: providerWidget?.value || "ollama",
base_url: baseUrlWidget?.value || "",
model: modelWidget?.value || "",
image_b64: images,
}),
});
const result = await response.json();
if (result.status !== "success") {
throw new Error(result.message || "Unknown analysis error");
}
const description = result.description || "";
setWidgetValue(node, descriptionWidget, description);
if (node._msrDescriptionInput) {
node._msrDescriptionInput.value = description;
}
syncFormFromWidgets(node);
refreshCharacterPreview(node);
} catch (error) {
console.error("[MSRCharacter] analyze failed", error);
alert(`MSR Character analyze failed: ${error.message || error}`);
} finally {
buttonWidget.label = "Analyze with Ollama";
node.setDirtyCanvas?.(true, true);
}
}
function hideNodeWidget(widget) {
if (!widget) return;
widget.hidden = true;
if (!widget.options) widget.options = {};
widget.options.hidden = true;
if (widget.element) {
widget.element.style.display = "none";
}
}
app.registerExtension({
name: "Comfy.MSRCharacterCS",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === "MSRCharacterCS") {
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (originalOnNodeCreated) originalOnNodeCreated.apply(this, arguments);
if (!(this.widgets || []).find((widget) => widget.msrAnalyze)) {
this.addWidget("button", "Analyze with Ollama", null, () => {
analyzeCharacterNode(this, (this.widgets || []).find((widget) => widget.msrAnalyze));
}, { serialize: false });
const buttonWidget = this.widgets[this.widgets.length - 1];
buttonWidget.msrAnalyze = true;
buttonWidget.label = "Analyze with Ollama";
}
if (!findWidget(this, "analyze_provider")) {
this.addWidget("combo", "analyze_provider", "ollama", null, {
values: ["ollama", "lmstudio", "custom", "off"],
});
}
if (!findWidget(this, "analyze_base_url")) {
this.addWidget("text", "analyze_base_url", "");
}
if (!findWidget(this, "analyze_model")) {
this.addWidget("text", "analyze_model", "");
}
if (!this._msrPreviewWidget) {
const previewContainer = buildCharacterUi(this);
this._msrPreviewWidget = this.addDOMWidget("msr_character_ui", "msr_character_ui", previewContainer, {
getValue: () => "",
setValue: () => {},
});
this._msrPreviewWidget.computeSize = () => [Math.max(10, (this.size?.[0] || 320) - 30), 260];
}
const originalConnectionsChange = this.onConnectionsChange;
this.onConnectionsChange = function () {
const result = originalConnectionsChange?.apply(this, arguments);
refreshCharacterPreview(this);
return result;
};
const originalConfigure = this.onConfigure;
this.onConfigure = function () {
const result = originalConfigure?.apply(this, arguments);
setTimeout(() => {
syncFormFromWidgets(this);
refreshCharacterPreview(this);
}, 0);
return result;
};
["alias", "description", "analyze_provider", "analyze_base_url", "analyze_model"].forEach((widgetName) => {
hideNodeWidget(findWidget(this, widgetName));
});
this.size[0] = Math.max(this.size[0] || 0, 330);
syncFormFromWidgets(this);
refreshCharacterPreview(this);
};
}
if (nodeData.name === "MSRCharacterSetCS") {
const originalOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (originalOnNodeCreated) originalOnNodeCreated.apply(this, arguments);
this.size[0] = Math.max(this.size[0] || 0, 260);
};
}
},
});