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 blobToOptimizedDataUrl(blob, maxDim = 1024, quality = 0.82) { const objectUrl = URL.createObjectURL(blob); try { const image = await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error("Failed to decode image blob")); img.src = objectUrl; }); const width = image.naturalWidth || image.width || 0; const height = image.naturalHeight || image.height || 0; const scale = width > 0 && height > 0 ? Math.min(1, maxDim / Math.max(width, height)) : 1; const targetWidth = Math.max(1, Math.round(width * scale)) || width || 1; const targetHeight = Math.max(1, Math.round(height * scale)) || height || 1; const canvas = document.createElement("canvas"); canvas.width = targetWidth; canvas.height = targetHeight; const ctx = canvas.getContext("2d"); if (!ctx) { throw new Error("Could not acquire canvas context"); } ctx.drawImage(image, 0, 0, targetWidth, targetHeight); const optimizedBlob = await new Promise((resolve, reject) => { canvas.toBlob((result) => { if (result) resolve(result); else reject(new Error("Canvas toBlob failed")); }, "image/jpeg", quality); }); const dataUrl = await new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.readAsDataURL(optimizedBlob); }); return { dataUrl, originalWidth: width, originalHeight: height, outputWidth: targetWidth, outputHeight: targetHeight, outputBytes: optimizedBlob.size || 0, }; } finally { URL.revokeObjectURL(objectUrl); } } 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, originalWidth: 0, originalHeight: 0, outputWidth: 0, outputHeight: 0, outputBytes: 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 optimized = await blobToOptimizedDataUrl(blob); const dataUrl = optimized.dataUrl; 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, originalWidth: optimized.originalWidth, originalHeight: optimized.originalHeight, outputWidth: optimized.outputWidth, outputHeight: optimized.outputHeight, outputBytes: optimized.outputBytes, 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, originalWidth: 0, originalHeight: 0, outputWidth: 0, outputHeight: 0, outputBytes: 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 is stored in the field below.
`, ].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 actionsRow = document.createElement("div"); Object.assign(actionsRow.style, { display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap", }); const analyzeButton = document.createElement("button"); analyzeButton.type = "button"; analyzeButton.textContent = "Analyze"; Object.assign(analyzeButton.style, { alignSelf: "flex-start", background: "#2b4f38", color: "#f3f3f3", border: "1px solid #496d56", borderRadius: "6px", padding: "6px 10px", fontSize: "11px", cursor: "pointer", }); analyzeButton.addEventListener("click", () => { const widgetButton = (node.widgets || []).find((widget) => widget.msrAnalyze); if (!widgetButton) { alert("Analyze button widget is missing on this node."); return; } analyzeCharacterNode(node, widgetButton); }); 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); }); actionsRow.appendChild(analyzeButton); actionsRow.appendChild(settingsButton); form.appendChild(aliasLabel); form.appendChild(aliasInput); form.appendChild(descLabel); form.appendChild(descInput); form.appendChild(actionsRow); container.appendChild(previewRow); container.appendChild(meta); container.appendChild(form); node._msrPreviewContainer = container; node._msrPreviewRow = previewRow; node._msrMeta = meta; node._msrAliasInput = aliasInput; node._msrDescriptionInput = descInput; node._msrAnalyzeButton = analyzeButton; 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..."; if (node._msrAnalyzeButton) { node._msrAnalyzeButton.textContent = "Analyzing..."; node._msrAnalyzeButton.disabled = true; } 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"; if (node._msrAnalyzeButton) { node._msrAnalyzeButton.textContent = "Analyze"; node._msrAnalyzeButton.disabled = false; } 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)); }); hideNodeWidget((this.widgets || []).find((widget) => widget.msrAnalyze)); 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); }; } }, });