Extract MSR characters into reusable nodes

This commit is contained in:
OpenClaw Agent
2026-07-10 09:23:01 +00:00
parent 119692160a
commit e68357f384
5 changed files with 512 additions and 92 deletions

View File

@@ -788,13 +788,8 @@ function parseInitial(jsonStr) {
reference_mode: "OFF",
analyzeProvider: "ollama",
analyzeBaseUrl: "",
analyzeModel: "",
characters: [
{ images: [], description: "" },
{ images: [], description: "" },
{ images: [], description: "" }
]
};
analyzeModel: ""
};
try {
if (jsonStr) {
const p = JSON.parse(jsonStr);
@@ -819,16 +814,7 @@ function parseInitial(jsonStr) {
if (p.reference_mode !== undefined) parsed.reference_mode = p.reference_mode;
if (p.analyzeProvider !== undefined) parsed.analyzeProvider = p.analyzeProvider;
if (p.analyzeBaseUrl !== undefined) parsed.analyzeBaseUrl = p.analyzeBaseUrl;
if (p.analyzeModel !== undefined) parsed.analyzeModel = p.analyzeModel;
if (Array.isArray(p.characters)) {
parsed.characters = p.characters.map(c => ({
images: Array.isArray(c.images) ? c.images : [],
description: c.description || ""
}));
while (parsed.characters.length < 3) {
parsed.characters.push({ images: [], description: "" });
}
}
if (p.analyzeModel !== undefined) parsed.analyzeModel = p.analyzeModel;
if (Array.isArray(p.segments)) {
parsed.segments = p.segments.map(stripImageSegmentTransient);
}
@@ -4055,10 +4041,7 @@ class TimelineEditor {
this.wrapper.appendChild(propContainer);
this.wrapper.appendChild(this.globalPropContainer);
// --- Character reference slots (3 @char panels) at the bottom of the editor ---
this.createCharacterSlots(this.wrapper);
// --- @char autocomplete on both prompt fields ---
// --- @char autocomplete on both prompt fields ---
if (this.globalPromptInput) this.setupAutocomplete(this.globalPromptInput);
if (this.promptInput) this.setupAutocomplete(this.promptInput);
@@ -9336,11 +9319,14 @@ class TimelineEditor {
if (!this._autocompleteMenus) this._autocompleteMenus = [];
this._autocompleteMenus.push(menu);
const suggestions = [
{ tag: "@char1", label: "Character 1" },
{ tag: "@char2", label: "Character 2" },
{ tag: "@char3", label: "Character 3" }
];
const suggestions = [
{ tag: "@char1", label: "Character 1" },
{ tag: "@char2", label: "Character 2" },
{ tag: "@char3", label: "Character 3" },
{ tag: "@char4", label: "Character 4" },
{ tag: "@char5", label: "Character 5" },
{ tag: "@char6", label: "Character 6" }
];
let activeIndex = 0;
let showMenu = false;
@@ -9554,15 +9540,11 @@ class TimelineEditor {
fileSize: this.timeline.retakeVideo.fileSize,
} : null,
normalStartFrame: this.timeline.normalStartFrame,
normalDurationFrames: this.timeline.normalDurationFrames,
normalDurationFrames: this.timeline.normalDurationFrames,
reference_mode: this.timeline.reference_mode || "OFF",
analyzeProvider: this.timeline.analyzeProvider || "ollama",
analyzeBaseUrl: this.timeline.analyzeBaseUrl || "",
analyzeModel: this.timeline.analyzeModel || "",
characters: (this.timeline.characters || []).map(c => ({
images: (c.images || []).map(img => img.b64 ? { b64: img.b64, name: img.name } : { name: img.name }),
description: c.description || ""
})),
analyzeModel: this.timeline.analyzeModel || "",
segments: sortedSegments.map(stripImageSegmentTransient),
motionSegments: (this.timeline.motionSegments || []).map(stripMotionSegmentTransient),
audioSegments: (this.timeline.audioSegments || []).map(stripAudioSegmentTransient)
@@ -12225,12 +12207,9 @@ app.registerExtension({
const canvasH = self._timelineEditor ? self._timelineEditor.canvasHeight : CANVAS_HEIGHT;
const propH = self._timelineEditor ? (self._timelineEditor.propHeight || 90) : 90;
const globalPropH = self._timelineEditor ? (self._timelineEditor.globalPropHeight || 60) : 60;
// Reserve room for the @char reference panel at the bottom so the node doesn't
// collapse and crop it whenever ComfyUI recomputes the node height.
const charPanelH = self._timelineEditor ? (self._timelineEditor.charPanelHeight || 150) : 150;
const nodeWidth = self.size?.[0] || width || 1375;
return [Math.max(10, nodeWidth - 30), canvasH + propH + globalPropH + charPanelH + 160];
};
const nodeWidth = self.size?.[0] || width || 1375;
return [Math.max(10, nodeWidth - 30), canvasH + propH + globalPropH + 160];
};
setTimeout(() => {
try {

259
js/msr_character.js Normal file
View 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);
};
}
},
});