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

@@ -1,5 +1,6 @@
from .ltx_director import LTXDirector from .ltx_director import LTXDirector
from .ltx_director_guide import LTXDirectorGuide, LTXDirectorCropGuides from .ltx_director_guide import LTXDirectorGuide, LTXDirectorCropGuides
from .msr_character import MSRCharacter, MSRCharacterSet
from comfy_api.latest import ComfyExtension, io from comfy_api.latest import ComfyExtension, io
from typing_extensions import override from typing_extensions import override
from .latent_slice import CleanLatentSlice from .latent_slice import CleanLatentSlice
@@ -8,6 +9,8 @@ class PromptRelay(ComfyExtension):
@override @override
async def get_node_list(self) -> list[type[io.ComfyNode]]: async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [ return [
MSRCharacter,
MSRCharacterSet,
LTXDirector, LTXDirector,
] ]
@@ -16,6 +19,8 @@ async def comfy_entrypoint() -> PromptRelay:
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"LTXDirectorCS": LTXDirector, "LTXDirectorCS": LTXDirector,
"MSRCharacterCS": MSRCharacter,
"MSRCharacterSetCS": MSRCharacterSet,
"LTXDirectorGuideCS": LTXDirectorGuide, "LTXDirectorGuideCS": LTXDirectorGuide,
"LTXDirectorCropGuidesCS": LTXDirectorCropGuides, "LTXDirectorCropGuidesCS": LTXDirectorCropGuides,
"CleanLatentSliceCS": CleanLatentSlice, "CleanLatentSliceCS": CleanLatentSlice,
@@ -23,6 +28,8 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"LTXDirectorCS": "LTX Director CS", "LTXDirectorCS": "LTX Director CS",
"MSRCharacterCS": "MSR Character CS",
"MSRCharacterSetCS": "MSR Character Set CS",
"LTXDirectorGuideCS": "LTX Director Guide CS", "LTXDirectorGuideCS": "LTX Director Guide CS",
"LTXDirectorCropGuidesCS": "LTX Director Crop Guides CS", "LTXDirectorCropGuidesCS": "LTX Director Crop Guides CS",
"CleanLatentSliceCS": "Clean Latent Slice CS", "CleanLatentSliceCS": "Clean Latent Slice CS",

View File

@@ -788,12 +788,7 @@ function parseInitial(jsonStr) {
reference_mode: "OFF", reference_mode: "OFF",
analyzeProvider: "ollama", analyzeProvider: "ollama",
analyzeBaseUrl: "", analyzeBaseUrl: "",
analyzeModel: "", analyzeModel: ""
characters: [
{ images: [], description: "" },
{ images: [], description: "" },
{ images: [], description: "" }
]
}; };
try { try {
if (jsonStr) { if (jsonStr) {
@@ -820,15 +815,6 @@ function parseInitial(jsonStr) {
if (p.analyzeProvider !== undefined) parsed.analyzeProvider = p.analyzeProvider; if (p.analyzeProvider !== undefined) parsed.analyzeProvider = p.analyzeProvider;
if (p.analyzeBaseUrl !== undefined) parsed.analyzeBaseUrl = p.analyzeBaseUrl; if (p.analyzeBaseUrl !== undefined) parsed.analyzeBaseUrl = p.analyzeBaseUrl;
if (p.analyzeModel !== undefined) parsed.analyzeModel = p.analyzeModel; 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 (Array.isArray(p.segments)) { if (Array.isArray(p.segments)) {
parsed.segments = p.segments.map(stripImageSegmentTransient); parsed.segments = p.segments.map(stripImageSegmentTransient);
} }
@@ -4055,9 +4041,6 @@ class TimelineEditor {
this.wrapper.appendChild(propContainer); this.wrapper.appendChild(propContainer);
this.wrapper.appendChild(this.globalPropContainer); 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.globalPromptInput) this.setupAutocomplete(this.globalPromptInput);
if (this.promptInput) this.setupAutocomplete(this.promptInput); if (this.promptInput) this.setupAutocomplete(this.promptInput);
@@ -9339,7 +9322,10 @@ class TimelineEditor {
const suggestions = [ const suggestions = [
{ tag: "@char1", label: "Character 1" }, { tag: "@char1", label: "Character 1" },
{ tag: "@char2", label: "Character 2" }, { tag: "@char2", label: "Character 2" },
{ tag: "@char3", label: "Character 3" } { tag: "@char3", label: "Character 3" },
{ tag: "@char4", label: "Character 4" },
{ tag: "@char5", label: "Character 5" },
{ tag: "@char6", label: "Character 6" }
]; ];
let activeIndex = 0; let activeIndex = 0;
@@ -9559,10 +9545,6 @@ class TimelineEditor {
analyzeProvider: this.timeline.analyzeProvider || "ollama", analyzeProvider: this.timeline.analyzeProvider || "ollama",
analyzeBaseUrl: this.timeline.analyzeBaseUrl || "", analyzeBaseUrl: this.timeline.analyzeBaseUrl || "",
analyzeModel: this.timeline.analyzeModel || "", 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 || ""
})),
segments: sortedSegments.map(stripImageSegmentTransient), segments: sortedSegments.map(stripImageSegmentTransient),
motionSegments: (this.timeline.motionSegments || []).map(stripMotionSegmentTransient), motionSegments: (this.timeline.motionSegments || []).map(stripMotionSegmentTransient),
audioSegments: (this.timeline.audioSegments || []).map(stripAudioSegmentTransient) audioSegments: (this.timeline.audioSegments || []).map(stripAudioSegmentTransient)
@@ -12225,11 +12207,8 @@ app.registerExtension({
const canvasH = self._timelineEditor ? self._timelineEditor.canvasHeight : CANVAS_HEIGHT; const canvasH = self._timelineEditor ? self._timelineEditor.canvasHeight : CANVAS_HEIGHT;
const propH = self._timelineEditor ? (self._timelineEditor.propHeight || 90) : 90; const propH = self._timelineEditor ? (self._timelineEditor.propHeight || 90) : 90;
const globalPropH = self._timelineEditor ? (self._timelineEditor.globalPropHeight || 60) : 60; 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; const nodeWidth = self.size?.[0] || width || 1375;
return [Math.max(10, nodeWidth - 30), canvasH + propH + globalPropH + charPanelH + 160]; return [Math.max(10, nodeWidth - 30), canvasH + propH + globalPropH + 160];
}; };
setTimeout(() => { setTimeout(() => {

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);
};
}
},
});

View File

@@ -31,13 +31,9 @@ from .prompt_relay import (
) )
from .patches import detect_model_type, apply_patches from .patches import detect_model_type, apply_patches
from .msr_character import MSRCharacterSetData
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
CHARACTER_TAG_GROUPS = (
("@character1", "@char1"),
("@character2", "@char2"),
("@character3", "@char3"),
)
# Setup global event loop exception handler to silence ConnectionResetError (WinError 10054/10053) on Windows # Setup global event loop exception handler to silence ConnectionResetError (WinError 10054/10053) on Windows
try: try:
@@ -286,15 +282,39 @@ def _time_range_to_latent_indices(rel_start_frames: float, rel_length_frames: fl
return start_idx, end_idx return start_idx, end_idx
def _preprocess_prompts_with_characters(global_prompt, local_prompts, char1="", char2="", char3=""): def _normalize_character_alias(alias: str) -> str:
"""Invisibly swaps out @character1/@char1 tags with their high-fidelity VLM descriptions.""" alias = (alias or "").strip()
if alias.startswith("@"):
alias = alias[1:]
return alias.strip()
def _build_character_tag_groups(characters: list[dict]) -> list[tuple[str, ...]]:
groups: list[tuple[str, ...]] = []
for idx, character in enumerate(characters or []):
tags = [f"@character{idx + 1}", f"@char{idx + 1}"]
alias = _normalize_character_alias(character.get("alias", ""))
if alias:
tags.append(f"@{alias}")
groups.append(tuple(tags))
return groups
def _character_prompt_replacements(characters: list[dict]) -> dict[str, str]:
replacements: dict[str, str] = {}
for character, tags in zip(characters or [], _build_character_tag_groups(characters)):
replacement = character.get("description", "") or ""
for tag in tags:
replacements[tag] = replacement
return replacements
def _preprocess_prompts_with_characters(global_prompt, local_prompts, characters: list[dict] | None = None):
"""Invisibly swaps out @characterN/@charN/@alias tags with their character descriptions."""
if "@" not in (global_prompt or "") and "@" not in (local_prompts or ""): if "@" not in (global_prompt or "") and "@" not in (local_prompts or ""):
return global_prompt or "", local_prompts or "" return global_prompt or "", local_prompts or ""
replacements = {} replacements = _character_prompt_replacements(characters or [])
for tags, value in zip(CHARACTER_TAG_GROUPS, (char1 or "", char2 or "", char3 or "")):
for tag in tags:
replacements[tag] = value
def apply_replacements(text: str) -> str: def apply_replacements(text: str) -> str:
updated = text or "" updated = text or ""
@@ -1157,23 +1177,34 @@ def _encode_relay(model, clip, latent, global_prompt, local_prompts, segment_len
return patched, conditioning return patched, conditioning
def _load_character_slot_images(tdata: dict, image_cache: dict, input_dir: str): def _extract_runtime_character_entries(tdata: dict, character_set, image_cache: dict, input_dir: str) -> list[dict]:
char_images = [] entries: list[dict] = []
char_slot_images = []
descriptions = ["", "", ""] if isinstance(character_set, dict) and character_set.get("characters"):
try:
for item in character_set.get("characters", []):
images = [img for img in (item.get("images") or []) if img is not None]
entries.append(
{
"images": images,
"description": item.get("description", "") or "",
"alias": _normalize_character_alias(item.get("alias", "")),
}
)
except Exception as e:
log.warning("[LTXDirector] Could not process external character_set input: %s", e)
if entries:
return entries
try: try:
characters = tdata.get("characters", []) for char_info in tdata.get("characters", []):
for idx, char_info in enumerate(characters[:3]):
descriptions[idx] = char_info.get("description", "")
for char_info in characters:
images_list = char_info.get("images", []) images_list = char_info.get("images", [])
legacy_b64 = char_info.get("imageB64", "") legacy_b64 = char_info.get("imageB64", "")
if legacy_b64 and not images_list: if legacy_b64 and not images_list:
images_list = [{"b64": legacy_b64, "name": char_info.get("fileName", "")}] images_list = [{"b64": legacy_b64, "name": char_info.get("fileName", "")}]
slot_tensors = [] loaded_images = []
for img_info in images_list: for img_info in images_list:
tensor = _load_image_source( tensor = _load_image_source(
img_info.get("b64", ""), img_info.get("b64", ""),
@@ -1181,13 +1212,19 @@ def _load_character_slot_images(tdata: dict, image_cache: dict, input_dir: str):
cache=image_cache, cache=image_cache,
input_dir=input_dir, input_dir=input_dir,
) )
char_images.append(tensor) loaded_images.append(tensor)
slot_tensors.append(tensor)
char_slot_images.append(slot_tensors)
except Exception as e:
log.warning("[LTXDirector] Could not process character slot inputs: %s", e)
return char_images, char_slot_images, descriptions entries.append(
{
"images": loaded_images,
"description": char_info.get("description", "") or "",
"alias": _normalize_character_alias(char_info.get("alias", "")),
}
)
except Exception as e:
log.warning("[LTXDirector] Could not process legacy timeline character slots: %s", e)
return entries
def _build_guide_data_from_timeline( def _build_guide_data_from_timeline(
@@ -1380,6 +1417,7 @@ def _build_reference_mode_outputs(
segment_lengths: str, segment_lengths: str,
duration_frames: int, duration_frames: int,
epsilon: float, epsilon: float,
characters: list[dict],
char_images: list, char_images: list,
char_slot_images: list, char_slot_images: list,
guide_data: dict, guide_data: dict,
@@ -1404,7 +1442,8 @@ def _build_reference_mode_outputs(
raise ValueError("Licon MSR (Prefix) ref option requires connecting the VAE to LTX Director!") raise ValueError("Licon MSR (Prefix) ref option requires connecting the VAE to LTX Director!")
prompt_text = (global_prompt or "") + " " + (local_prompts or "") prompt_text = (global_prompt or "") + " " + (local_prompts or "")
referenced_slots = [i for i, tags in enumerate(CHARACTER_TAG_GROUPS) if any(t in prompt_text for t in tags)] tag_groups = _build_character_tag_groups(characters)
referenced_slots = [i for i, tags in enumerate(tag_groups) if any(t in prompt_text for t in tags)]
selected = [] selected = []
for slot in referenced_slots: for slot in referenced_slots:
if slot < len(char_slot_images): if slot < len(char_slot_images):
@@ -1751,7 +1790,12 @@ class LTXDirector(io.ComfyNode):
), ),
io.Image.Input( io.Image.Input(
"ref_images", optional=True, "ref_images", optional=True,
tooltip="Ghost Mask only. Extra reference image(s) (e.g. an object) — a single image or a batch. Appended as their own block of hidden reference frames AFTER the @char references, for any number of characters loaded (0-3). Ignored in MSR / OFF modes.", tooltip="Ghost Mask only. Extra reference image(s) (e.g. an object) — a single image or a batch. Appended as their own block of hidden reference frames AFTER the @char references, for any number of characters loaded (0-6). Ignored in MSR / OFF modes.",
),
MSRCharacterSetData.Input(
"character_set",
optional=True,
tooltip="Optional external MSR Character Set. Replaces the legacy inline character panel in the director UI.",
), ),
io.Float.Input( io.Float.Input(
"start", force_input=True, optional=True, default=0.0, "start", force_input=True, optional=True, default=0.0,
@@ -1788,7 +1832,7 @@ class LTXDirector(io.ComfyNode):
custom_width=768, custom_height=512, resize_method="maintain aspect ratio", custom_width=768, custom_height=512, resize_method="maintain aspect ratio",
divisible_by=32, img_compression=0, audio_vae=None, optional_latent=None, divisible_by=32, img_compression=0, audio_vae=None, optional_latent=None,
use_custom_audio=False, inpaint_audio=True, use_custom_motion=True, override_audio=False, use_custom_audio=False, inpaint_audio=True, use_custom_motion=True, override_audio=False,
vae=None, reference_strength=1.0, ref_images=None, vae=None, reference_strength=1.0, ref_images=None, character_set=None,
start=None, end=None, duration=None) -> io.NodeOutput: start=None, end=None, duration=None) -> io.NodeOutput:
input_dir = folder_paths.get_input_directory() input_dir = folder_paths.get_input_directory()
image_cache = {} image_cache = {}
@@ -1846,10 +1890,14 @@ class LTXDirector(io.ComfyNode):
# One of: "Ghost Mask (End)", "Licon MSR (Prefix)", "OFF". # One of: "Ghost Mask (End)", "Licon MSR (Prefix)", "OFF".
reference_mode = tdata.get("reference_mode", "OFF") reference_mode = tdata.get("reference_mode", "OFF")
char_images, char_slot_images, char_descriptions = _load_character_slot_images( characters = _extract_runtime_character_entries(
tdata, image_cache, input_dir tdata=tdata,
character_set=character_set,
image_cache=image_cache,
input_dir=input_dir,
) )
char1_val, char2_val, char3_val = char_descriptions char_slot_images = [list(character.get("images") or []) for character in characters]
char_images = [img for slot_images in char_slot_images for img in slot_images]
# --- @charN substitution --- # --- @charN substitution ---
# Ghost Mask / OFF: swap @char tags for their VLM descriptions in the prompt text. # Ghost Mask / OFF: swap @char tags for their VLM descriptions in the prompt text.
@@ -1859,7 +1907,7 @@ class LTXDirector(io.ComfyNode):
ref_global, ref_local = global_prompt, local_prompts ref_global, ref_local = global_prompt, local_prompts
else: else:
ref_global, ref_local = _preprocess_prompts_with_characters( ref_global, ref_local = _preprocess_prompts_with_characters(
global_prompt, local_prompts, char1_val, char2_val, char3_val global_prompt, local_prompts, characters
) )
global_prompt, local_prompts = ref_global, ref_local global_prompt, local_prompts = ref_global, ref_local
@@ -1913,6 +1961,7 @@ class LTXDirector(io.ComfyNode):
segment_lengths=segment_lengths, segment_lengths=segment_lengths,
duration_frames=duration_frames, duration_frames=duration_frames,
epsilon=epsilon, epsilon=epsilon,
characters=characters,
char_images=char_images, char_images=char_images,
char_slot_images=char_slot_images, char_slot_images=char_slot_images,
guide_data=guide_data, guide_data=guide_data,

126
msr_character.py Normal file
View File

@@ -0,0 +1,126 @@
from __future__ import annotations
from comfy_api.latest import io
MSRCharacterData = io.Custom("MSR_CHARACTER")
MSRCharacterSetData = io.Custom("MSR_CHARACTER_SET")
def _compact_alias(alias: str) -> str:
if not alias:
return ""
alias = alias.strip()
if alias.startswith("@"):
alias = alias[1:]
return alias.strip()
class MSRCharacter(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="MSRCharacterCS",
display_name="MSR Character CS",
category="WhatDreamsCost CS",
description=(
"Reusable MSR character payload. Accepts up to two reference images plus "
"a description and optional alias for @alias prompt replacement."
),
inputs=[
io.Image.Input(
"image_1",
optional=True,
tooltip="Optional first reference image for this character.",
),
io.Image.Input(
"image_2",
optional=True,
tooltip="Optional second reference image for this character.",
),
io.String.Input(
"description",
multiline=True,
default="",
optional=True,
tooltip="Manual or generated appearance description used for prompt replacement.",
),
io.String.Input(
"alias",
default="",
optional=True,
tooltip="Optional alias. Prompts can use @alias alongside @charN/@characterN.",
),
],
outputs=[
MSRCharacterData.Output(display_name="character"),
],
)
@classmethod
def execute(cls, image_1=None, image_2=None, description="", alias="") -> io.NodeOutput:
images = []
if image_1 is not None:
images.append(image_1)
if image_2 is not None:
images.append(image_2)
payload = {
"images": images,
"description": (description or "").strip(),
"alias": _compact_alias(alias or ""),
}
return io.NodeOutput(payload)
class MSRCharacterSet(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="MSRCharacterSetCS",
display_name="MSR Character Set CS",
category="WhatDreamsCost CS",
description="Bundles up to six MSR Character nodes into one character set for the director.",
inputs=[
MSRCharacterData.Input("character_1", optional=True),
MSRCharacterData.Input("character_2", optional=True),
MSRCharacterData.Input("character_3", optional=True),
MSRCharacterData.Input("character_4", optional=True),
MSRCharacterData.Input("character_5", optional=True),
MSRCharacterData.Input("character_6", optional=True),
],
outputs=[
MSRCharacterSetData.Output(display_name="character_set"),
],
)
@classmethod
def execute(
cls,
character_1=None,
character_2=None,
character_3=None,
character_4=None,
character_5=None,
character_6=None,
) -> io.NodeOutput:
characters = []
for item in (
character_1,
character_2,
character_3,
character_4,
character_5,
character_6,
):
if not item:
continue
characters.append(
{
"images": list(item.get("images") or []),
"description": (item.get("description") or "").strip(),
"alias": _compact_alias(item.get("alias") or ""),
}
)
return io.NodeOutput({"characters": characters})