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

@@ -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,