Extract MSR characters into reusable nodes
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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
259
js/msr_character.js
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
117
ltx_director.py
117
ltx_director.py
@@ -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
126
msr_character.py
Normal 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})
|
||||||
Reference in New Issue
Block a user