diff --git a/__init__.py b/__init__.py
index 0f4f023..a8a63a9 100644
--- a/__init__.py
+++ b/__init__.py
@@ -1,5 +1,6 @@
from .ltx_director import LTXDirector
from .ltx_director_guide import LTXDirectorGuide, LTXDirectorCropGuides
+from .msr_character import MSRCharacter, MSRCharacterSet
from comfy_api.latest import ComfyExtension, io
from typing_extensions import override
from .latent_slice import CleanLatentSlice
@@ -8,6 +9,8 @@ class PromptRelay(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [
+ MSRCharacter,
+ MSRCharacterSet,
LTXDirector,
]
@@ -16,6 +19,8 @@ async def comfy_entrypoint() -> PromptRelay:
NODE_CLASS_MAPPINGS = {
"LTXDirectorCS": LTXDirector,
+ "MSRCharacterCS": MSRCharacter,
+ "MSRCharacterSetCS": MSRCharacterSet,
"LTXDirectorGuideCS": LTXDirectorGuide,
"LTXDirectorCropGuidesCS": LTXDirectorCropGuides,
"CleanLatentSliceCS": CleanLatentSlice,
@@ -23,6 +28,8 @@ NODE_CLASS_MAPPINGS = {
NODE_DISPLAY_NAME_MAPPINGS = {
"LTXDirectorCS": "LTX Director CS",
+ "MSRCharacterCS": "MSR Character CS",
+ "MSRCharacterSetCS": "MSR Character Set CS",
"LTXDirectorGuideCS": "LTX Director Guide CS",
"LTXDirectorCropGuidesCS": "LTX Director Crop Guides CS",
"CleanLatentSliceCS": "Clean Latent Slice CS",
diff --git a/js/ltx_director.js b/js/ltx_director.js
index 41b2f9d..31a1b2a 100644
--- a/js/ltx_director.js
+++ b/js/ltx_director.js
@@ -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 {
diff --git a/js/msr_character.js b/js/msr_character.js
new file mode 100644
index 0000000..a6f39d7
--- /dev/null
+++ b/js/msr_character.js
@@ -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: @${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);
+}
+
+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);
+ };
+ }
+ },
+});
diff --git a/ltx_director.py b/ltx_director.py
index 0e2cb52..e491783 100644
--- a/ltx_director.py
+++ b/ltx_director.py
@@ -30,14 +30,10 @@ from .prompt_relay import (
distribute_segment_lengths,
)
-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__)
-CHARACTER_TAG_GROUPS = (
- ("@character1", "@char1"),
- ("@character2", "@char2"),
- ("@character3", "@char3"),
-)
# Setup global event loop exception handler to silence ConnectionResetError (WinError 10054/10053) on Windows
try:
@@ -284,17 +280,41 @@ def _time_range_to_latent_indices(rel_start_frames: float, rel_length_frames: fl
start_idx = max(0, min(latent_frames, start_idx))
end_idx = max(0, min(latent_frames, end_idx))
return start_idx, end_idx
+
+
+def _normalize_character_alias(alias: str) -> str:
+ 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, char1="", char2="", char3=""):
- """Invisibly swaps out @character1/@char1 tags with their high-fidelity VLM descriptions."""
+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 ""):
return global_prompt or "", local_prompts or ""
- replacements = {}
- for tags, value in zip(CHARACTER_TAG_GROUPS, (char1 or "", char2 or "", char3 or "")):
- for tag in tags:
- replacements[tag] = value
+ replacements = _character_prompt_replacements(characters or [])
def apply_replacements(text: str) -> str:
updated = text or ""
@@ -1157,23 +1177,34 @@ def _encode_relay(model, clip, latent, global_prompt, local_prompts, segment_len
return patched, conditioning
-def _load_character_slot_images(tdata: dict, image_cache: dict, input_dir: str):
- char_images = []
- char_slot_images = []
- descriptions = ["", "", ""]
+def _extract_runtime_character_entries(tdata: dict, character_set, image_cache: dict, input_dir: str) -> list[dict]:
+ entries: list[dict] = []
+
+ 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:
- characters = tdata.get("characters", [])
- for idx, char_info in enumerate(characters[:3]):
- descriptions[idx] = char_info.get("description", "")
-
- for char_info in characters:
+ for char_info in tdata.get("characters", []):
images_list = char_info.get("images", [])
legacy_b64 = char_info.get("imageB64", "")
if legacy_b64 and not images_list:
images_list = [{"b64": legacy_b64, "name": char_info.get("fileName", "")}]
- slot_tensors = []
+ loaded_images = []
for img_info in images_list:
tensor = _load_image_source(
img_info.get("b64", ""),
@@ -1181,13 +1212,19 @@ def _load_character_slot_images(tdata: dict, image_cache: dict, input_dir: str):
cache=image_cache,
input_dir=input_dir,
)
- char_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)
+ loaded_images.append(tensor)
- 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(
@@ -1380,6 +1417,7 @@ def _build_reference_mode_outputs(
segment_lengths: str,
duration_frames: int,
epsilon: float,
+ characters: list[dict],
char_images: list,
char_slot_images: list,
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!")
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 = []
for slot in referenced_slots:
if slot < len(char_slot_images):
@@ -1749,14 +1788,19 @@ class LTXDirector(io.ComfyNode):
"reference_strength", default=1.0, min=0.0, max=5.0, step=0.05, optional=True,
tooltip="Guide strength applied to the character reference images (Ghost Mask / Licon MSR ref options).",
),
- io.Image.Input(
- "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.",
- ),
- io.Float.Input(
- "start", force_input=True, optional=True, default=0.0,
- tooltip="Automation (connection-only). Start time in SECONDS. Overrides the panel Start when connected.",
- ),
+ io.Image.Input(
+ "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-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(
+ "start", force_input=True, optional=True, default=0.0,
+ tooltip="Automation (connection-only). Start time in SECONDS. Overrides the panel Start when connected.",
+ ),
io.Float.Input(
"end", force_input=True, optional=True, default=0.0,
tooltip="Automation (connection-only). End time in SECONDS. When connected (and duration is not), the render length is derived from start..end.",
@@ -1788,7 +1832,7 @@ class LTXDirector(io.ComfyNode):
custom_width=768, custom_height=512, resize_method="maintain aspect ratio",
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,
- 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:
input_dir = folder_paths.get_input_directory()
image_cache = {}
@@ -1846,22 +1890,26 @@ class LTXDirector(io.ComfyNode):
# One of: "Ghost Mask (End)", "Licon MSR (Prefix)", "OFF".
reference_mode = tdata.get("reference_mode", "OFF")
- char_images, char_slot_images, char_descriptions = _load_character_slot_images(
- tdata, image_cache, input_dir
+ characters = _extract_runtime_character_entries(
+ tdata=tdata,
+ character_set=character_set,
+ image_cache=image_cache,
+ input_dir=input_dir,
)
- char1_val, char2_val, char3_val = char_descriptions
-
- # --- @charN substitution ---
- # Ghost Mask / OFF: swap @char tags for their VLM descriptions in the prompt text.
- # Licon MSR: leave the tags raw — there the reference IMAGE drives identity, and the
- # tags are used only to pick which character slots feed the slideshow.
- if reference_mode == "Licon MSR (Prefix)":
- ref_global, ref_local = global_prompt, local_prompts
- else:
- ref_global, ref_local = _preprocess_prompts_with_characters(
- global_prompt, local_prompts, char1_val, char2_val, char3_val
- )
- global_prompt, local_prompts = ref_global, ref_local
+ 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 ---
+ # Ghost Mask / OFF: swap @char tags for their VLM descriptions in the prompt text.
+ # Licon MSR: leave the tags raw — there the reference IMAGE drives identity, and the
+ # tags are used only to pick which character slots feed the slideshow.
+ if reference_mode == "Licon MSR (Prefix)":
+ ref_global, ref_local = global_prompt, local_prompts
+ else:
+ ref_global, ref_local = _preprocess_prompts_with_characters(
+ global_prompt, local_prompts, characters
+ )
+ global_prompt, local_prompts = ref_global, ref_local
guide_data, derived_w, derived_h = _build_guide_data_from_timeline(
tdata=tdata,
@@ -1913,6 +1961,7 @@ class LTXDirector(io.ComfyNode):
segment_lengths=segment_lengths,
duration_frames=duration_frames,
epsilon=epsilon,
+ characters=characters,
char_images=char_images,
char_slot_images=char_slot_images,
guide_data=guide_data,
diff --git a/msr_character.py b/msr_character.py
new file mode 100644
index 0000000..5a018bd
--- /dev/null
+++ b/msr_character.py
@@ -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})