507 lines
16 KiB
JavaScript
507 lines
16 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 resolveFileWidgetUrlFromOrigin(originNode) {
|
|
if (!originNode) return "";
|
|
|
|
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)}`
|
|
);
|
|
}
|
|
|
|
function resolvePreviewUrlFromOrigin(originNode) {
|
|
if (!originNode) return "";
|
|
const preview = Array.isArray(originNode.imgs) ? coercePreviewUrl(originNode.imgs[0]) : "";
|
|
return preview || "";
|
|
}
|
|
|
|
function resolveCandidateImageUrls(originNode) {
|
|
const urls = [];
|
|
const fileUrl = resolveFileWidgetUrlFromOrigin(originNode);
|
|
const previewUrl = resolvePreviewUrlFromOrigin(originNode);
|
|
if (fileUrl) urls.push(fileUrl);
|
|
if (previewUrl && previewUrl !== fileUrl) urls.push(previewUrl);
|
|
return urls;
|
|
}
|
|
|
|
async function imageInputToDataUrl(node, inputName) {
|
|
const originNode = getOriginNodeForInput(node, inputName);
|
|
const candidateUrls = resolveCandidateImageUrls(originNode);
|
|
if (!candidateUrls.length) {
|
|
return {
|
|
dataUrl: null,
|
|
debug: {
|
|
inputName,
|
|
originNodeId: originNode?.id ?? null,
|
|
originNodeType: originNode?.type ?? null,
|
|
candidateUrls: [],
|
|
selectedUrl: null,
|
|
mimeType: null,
|
|
blobBytes: 0,
|
|
dataUrlLength: 0,
|
|
error: "No candidate image URLs found",
|
|
},
|
|
};
|
|
}
|
|
|
|
for (const imageUrl of candidateUrls) {
|
|
try {
|
|
const response = await fetch(imageUrl);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const blob = await response.blob();
|
|
if (!blob.type.startsWith("image/")) {
|
|
throw new Error(`Unexpected blob type: ${blob.type || "unknown"}`);
|
|
}
|
|
const dataUrl = await new Promise((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result);
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
return {
|
|
dataUrl,
|
|
debug: {
|
|
inputName,
|
|
originNodeId: originNode?.id ?? null,
|
|
originNodeType: originNode?.type ?? null,
|
|
candidateUrls,
|
|
selectedUrl: imageUrl,
|
|
mimeType: blob.type || null,
|
|
blobBytes: blob.size || 0,
|
|
dataUrlLength: typeof dataUrl === "string" ? dataUrl.length : 0,
|
|
error: null,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.warn("[MSRCharacter] Failed candidate image source", imageUrl, error);
|
|
}
|
|
}
|
|
|
|
return {
|
|
dataUrl: null,
|
|
debug: {
|
|
inputName,
|
|
originNodeId: originNode?.id ?? null,
|
|
originNodeType: originNode?.type ?? null,
|
|
candidateUrls,
|
|
selectedUrl: null,
|
|
mimeType: null,
|
|
blobBytes: 0,
|
|
dataUrlLength: 0,
|
|
error: "All candidate image URLs failed",
|
|
},
|
|
};
|
|
}
|
|
|
|
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) => {
|
|
const originNode = getOriginNodeForInput(node, inputName);
|
|
const candidates = resolveCandidateImageUrls(originNode);
|
|
return candidates[0] || "";
|
|
})
|
|
.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 imageResults = await Promise.all([
|
|
imageInputToDataUrl(node, "image_1"),
|
|
imageInputToDataUrl(node, "image_2"),
|
|
]);
|
|
const imageDebug = imageResults.map((result) => result?.debug || null).filter(Boolean);
|
|
const images = (
|
|
imageResults.map((result) => result?.dataUrl || null)
|
|
).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,
|
|
image_debug: imageDebug,
|
|
}),
|
|
});
|
|
const result = await response.json();
|
|
if (result.status !== "success") {
|
|
console.warn("[MSRCharacter] analyze debug", {
|
|
sentImages: imageDebug,
|
|
response: result,
|
|
});
|
|
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);
|
|
};
|
|
}
|
|
},
|
|
});
|