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: @${String(alias).replace(/^@/, "")}` : "Alias: none", description ? `
${description}
` : `
Description will be used for prompt replacement outside MSR prefix mode.
`, ].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); }; } }, });