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