343 lines
10 KiB
JavaScript
343 lines
10 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 refreshCharacterPreview(node) {
|
|
if (!node._msrPreviewContainer) return;
|
|
const container = node._msrPreviewContainer;
|
|
container.innerHTML = "";
|
|
|
|
const inputNames = ["image_1", "image_2"];
|
|
const previewUrls = inputNames
|
|
.map((inputName) => resolvePreviewUrlFromOrigin(getOriginNodeForInput(node, inputName)))
|
|
.filter(Boolean);
|
|
|
|
const description = findWidget(node, "description")?.value || "";
|
|
const alias = findWidget(node, "alias")?.value || "";
|
|
const aliasWidget = findWidget(node, "alias");
|
|
const descriptionWidget = findWidget(node, "description");
|
|
|
|
const topRow = document.createElement("div");
|
|
Object.assign(topRow.style, {
|
|
display: "flex",
|
|
gap: "6px",
|
|
minHeight: "74px",
|
|
});
|
|
|
|
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",
|
|
});
|
|
topRow.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",
|
|
});
|
|
topRow.appendChild(placeholder);
|
|
}
|
|
|
|
const meta = document.createElement("div");
|
|
Object.assign(meta.style, {
|
|
marginTop: "8px",
|
|
fontSize: "11px",
|
|
color: "#aaa",
|
|
lineHeight: "1.4",
|
|
});
|
|
meta.innerHTML = [
|
|
alias ? `Alias: <span style="color:#e8e8e8">@${String(alias).replace(/^@/, "")}</span>` : "Alias: <span style=\"color:#666\">none</span>",
|
|
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("");
|
|
|
|
container.appendChild(topRow);
|
|
container.appendChild(meta);
|
|
|
|
const form = document.createElement("div");
|
|
Object.assign(form.style, {
|
|
marginTop: "10px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "6px",
|
|
});
|
|
|
|
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.value = alias || "";
|
|
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, aliasWidget, aliasInput.value);
|
|
});
|
|
|
|
const descLabel = document.createElement("label");
|
|
descLabel.textContent = "Description";
|
|
Object.assign(descLabel.style, {
|
|
fontSize: "11px",
|
|
color: "#bbb",
|
|
});
|
|
|
|
const descInput = document.createElement("textarea");
|
|
descInput.value = description || "";
|
|
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, descriptionWidget, descInput.value);
|
|
});
|
|
|
|
form.appendChild(aliasLabel);
|
|
form.appendChild(aliasInput);
|
|
form.appendChild(descLabel);
|
|
form.appendChild(descInput);
|
|
container.appendChild(form);
|
|
}
|
|
|
|
async function analyzeCharacterNode(node, buttonWidget) {
|
|
const descriptionWidget = findWidget(node, "description");
|
|
if (!descriptionWidget) return;
|
|
|
|
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: "ollama",
|
|
image_b64: images,
|
|
}),
|
|
});
|
|
const result = await response.json();
|
|
if (result.status !== "success") {
|
|
throw new Error(result.message || "Unknown analysis error");
|
|
}
|
|
|
|
setWidgetValue(node, descriptionWidget, result.description || "");
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 (!this._msrPreviewWidget) {
|
|
const previewContainer = document.createElement("div");
|
|
Object.assign(previewContainer.style, {
|
|
boxSizing: "border-box",
|
|
width: "100%",
|
|
padding: "6px 2px 2px 2px",
|
|
});
|
|
this._msrPreviewContainer = previewContainer;
|
|
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(() => refreshCharacterPreview(this), 0);
|
|
return result;
|
|
};
|
|
|
|
["alias", "description"].forEach((widgetName) => {
|
|
const widget = findWidget(this, widgetName);
|
|
if (!widget) return;
|
|
widget.hidden = true;
|
|
if (!widget.options) widget.options = {};
|
|
widget.options.hidden = true;
|
|
});
|
|
|
|
this.size[0] = Math.max(this.size[0] || 0, 330);
|
|
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);
|
|
};
|
|
}
|
|
},
|
|
});
|