Extract MSR characters into reusable nodes
This commit is contained in:
259
js/msr_character.js
Normal file
259
js/msr_character.js
Normal file
@@ -0,0 +1,259 @@
|
||||
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.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 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);
|
||||
}
|
||||
|
||||
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), 124];
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user