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