Extract MSR characters into reusable nodes
This commit is contained in:
157
ltx_director.py
157
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,
|
||||
|
||||
Reference in New Issue
Block a user