5 Commits
main ... 0.11

Author SHA1 Message Date
CGlide
32a8bfa511 Add files via upload 2026-06-02 12:22:23 +02:00
CGlide
0604d7fb0e Add files via upload 2026-05-24 16:03:13 +02:00
CGlide
0f90ddd5da Add files via upload 2026-05-24 16:02:32 +02:00
CGlide
04b51c81f2 Add files via upload 2026-05-23 18:44:58 +02:00
CGlide
9f2807826e Add files via upload 2026-05-22 16:29:52 +02:00
42 changed files with 40765 additions and 40249 deletions

View File

@@ -33,14 +33,10 @@ All of my nodes are created with the help of AI, so there may or may not be redu
**❗❗IMPORTANT❗❗** **❗❗IMPORTANT❗❗**
If you don't see the latest version (v1.3.9) yet in the manager then just downloaded the nightly version (or fetch the updates to update the list to see the latest version). If you don't see the latest version (v1.3.5) yet in the manager then just downloaded the nightly version (or fetch the updates to update the list to see the latest version).
Also you will need to update ComfyUI-LTXVideo and ComfyUI-KJNodes to the latest version as well. You cannot use this node without updating ComfyUI-LTXVideo! Also you will need to update ComfyUI-LTXVideo and ComfyUI-KJNodes to the latest version as well. You cannot use this node without updating ComfyUI-LTXVideo!
# 🔄 Recent Updates # 🔄 Recent Updates
**v1.3.9**
* **Fixed recent updates not showing in the manager**
It took like 5 tries but I finally got it working 🤦‍♂️
**v1.3.3** **v1.3.3**
* **LTX Director Hotfix 2** * **LTX Director Hotfix 2**

View File

@@ -8,6 +8,7 @@ from .ltx_director import LTXDirector
from .ltx_director_guide import LTXDirectorGuide from .ltx_director_guide import LTXDirectorGuide
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
class PromptRelay(ComfyExtension): class PromptRelay(ComfyExtension):
@override @override
@@ -29,6 +30,7 @@ NODE_CLASS_MAPPINGS = {
"LoadVideoUI": LoadVideoUI, "LoadVideoUI": LoadVideoUI,
"LTXDirector": LTXDirector, "LTXDirector": LTXDirector,
"LTXDirectorGuide": LTXDirectorGuide, "LTXDirectorGuide": LTXDirectorGuide,
"CleanLatentSlice": CleanLatentSlice,
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
@@ -40,6 +42,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"LoadVideoUI": "Load Video UI", "LoadVideoUI": "Load Video UI",
"LTXDirector": "LTX Director", "LTXDirector": "LTX Director",
"LTXDirectorGuide": "LTX Director Guide", "LTXDirectorGuide": "LTX Director Guide",
"CleanLatentSlice": "Clean Latent Slice",
} }
WEB_DIRECTORY = "./js" WEB_DIRECTORY = "./js"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1,5 @@
// --- START OF FILE ltx_director.js ---
const { app } = window.comfyAPI.app; const { app } = window.comfyAPI.app;
const { api } = window.comfyAPI.api; const { api } = window.comfyAPI.api;
@@ -516,6 +518,55 @@ const STYLES = `
.pr-segment:hover:not(.active) { .pr-segment:hover:not(.active) {
color: #ccc; color: #ccc;
} }
/* Autocomplete suggestion styles */
.pr-autocomplete-menu {
position: fixed;
background: #181818;
border: 1px solid #444;
border-radius: 6px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
z-index: 10000;
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
min-width: 180px;
max-height: 200px;
overflow-y: auto;
}
.pr-autocomplete-item {
background: #252525;
color: #aaa;
border: 1px solid #333;
border-radius: 4px;
padding: 6px 12px;
font-size: 11px;
font-family: monospace;
cursor: pointer;
text-align: left;
display: flex;
align-items: center;
justify-content: space-between;
transition: all 0.15s ease;
}
.pr-autocomplete-item:hover, .pr-autocomplete-item.active {
background: #1c222d;
color: #4fff8f;
border-color: #4fff8f;
}
.pr-autocomplete-item span {
font-weight: bold;
font-size: 12px;
}
.pr-autocomplete-item small {
color: #777;
font-size: 10px;
}
.pr-autocomplete-item.active small {
color: #4fff8f;
opacity: 0.8;
}
`; `;
if (!document.getElementById("prompt-relay-styles")) { if (!document.getElementById("prompt-relay-styles")) {
@@ -712,6 +763,7 @@ class TimelineEditor {
this.pauseAudio(); this.pauseAudio();
window.removeEventListener("keydown", this.handleKeyDown, true); window.removeEventListener("keydown", this.handleKeyDown, true);
window.removeEventListener("paste", this.handlePaste, true); window.removeEventListener("paste", this.handlePaste, true);
if (this._autocompleteMenu) { this._autocompleteMenu.remove(); }
} }
getDurationFrames() { getDurationFrames() {
@@ -1025,7 +1077,7 @@ class TimelineEditor {
// --- Text Area (Image/Text) --- // --- Text Area (Image/Text) ---
this.promptInput = document.createElement("textarea"); this.promptInput = document.createElement("textarea");
this.promptInput.className = "pr-prompt-area"; this.promptInput.className = "pr-prompt-area";
this.promptInput.placeholder = "Enter prompt for selected segment..."; this.promptInput.placeholder = "Enter prompt for selected segment... Type '@' for character shortcuts.";
this.promptInput.addEventListener("input", () => { this.promptInput.addEventListener("input", () => {
if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) { if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) {
this.timeline.segments[this.selectedIndex].prompt = this.promptInput.value; this.timeline.segments[this.selectedIndex].prompt = this.promptInput.value;
@@ -1355,6 +1407,146 @@ class TimelineEditor {
this.wrapper.appendChild(propContainer); this.wrapper.appendChild(propContainer);
this.container.appendChild(this.wrapper); this.container.appendChild(this.wrapper);
// --- Initialize autocomplete popup support ---
this.setupAutocomplete();
}
// --- Auto-complete Popup Setup ---
setupAutocomplete() {
const input = this.promptInput;
if (!input) return;
const menu = document.createElement("div");
menu.className = "pr-autocomplete-menu";
menu.style.display = "none";
document.body.appendChild(menu);
this._autocompleteMenu = menu;
const suggestions = [
{ tag: "@char1", label: "Character 1" },
{ tag: "@char2", label: "Character 2" },
{ tag: "@char3", label: "Character 3" },
{ tag: "@character1", label: "Character 1 (Full)" },
{ tag: "@character2", label: "Character 2 (Full)" },
{ tag: "@character3", label: "Character 3 (Full)" }
];
let activeIndex = 0;
let showMenu = false;
let queryStart = -1;
const hideMenu = () => {
menu.style.display = "none";
showMenu = false;
};
const getCaretCoordinates = () => {
const rect = input.getBoundingClientRect();
return {
left: rect.left,
top: rect.bottom + window.scrollY + 2
};
};
const updateMenu = () => {
if (!showMenu) return;
const text = input.value;
const cursor = input.selectionStart;
const query = text.slice(queryStart + 1, cursor).toLowerCase();
const filtered = suggestions.filter(s => s.tag.toLowerCase().includes("@" + query) || s.tag.toLowerCase().includes(query));
if (filtered.length === 0) {
hideMenu();
return;
}
menu.innerHTML = "";
// Clamp activeIndex inside the filtered results boundaries
if (activeIndex >= filtered.length) {
activeIndex = 0;
}
filtered.forEach((s, idx) => {
const item = document.createElement("div");
item.className = "pr-autocomplete-item" + (idx === activeIndex ? " active" : "");
item.innerHTML = `<span>${s.tag}</span><small>${s.label}</small>`;
item.addEventListener("mousedown", (e) => {
e.preventDefault(); // Prevent losing focus on textarea
insertSuggestion(s.tag);
});
menu.appendChild(item);
});
const coords = getCaretCoordinates();
menu.style.left = `${coords.left}px`;
menu.style.top = `${coords.top}px`;
menu.style.display = "flex";
};
const insertSuggestion = (tag) => {
const text = input.value;
const cursor = input.selectionStart;
const before = text.slice(0, queryStart);
const after = text.slice(cursor);
input.value = before + tag + " " + after;
input.selectionStart = input.selectionEnd = queryStart + tag.length + 1;
// Trigger input event to save changes in the node data
input.dispatchEvent(new Event("input"));
hideMenu();
input.focus();
};
input.addEventListener("keydown", (e) => {
if (showMenu) {
const items = menu.querySelectorAll(".pr-autocomplete-item");
if (items.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
activeIndex = (activeIndex + 1) % items.length;
updateMenu();
} else if (e.key === "ArrowUp") {
e.preventDefault();
activeIndex = (activeIndex - 1 + items.length) % items.length;
updateMenu();
} else if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const activeItem = items[activeIndex];
if (activeItem) {
const tag = activeItem.querySelector("span").textContent;
insertSuggestion(tag);
}
} else if (e.key === "Escape") {
e.preventDefault();
hideMenu();
}
}
});
input.addEventListener("input", () => {
const text = input.value;
const cursor = input.selectionStart;
const textBeforeCursor = text.slice(0, cursor);
const lastAt = textBeforeCursor.lastIndexOf("@");
if (lastAt !== -1 && lastAt >= textBeforeCursor.search(/\s[^\s]*$/)) {
showMenu = true;
queryStart = lastAt;
updateMenu();
} else {
hideMenu();
}
});
input.addEventListener("blur", () => {
// Small delay to let mousedown register on menu items before closing
setTimeout(hideMenu, 150);
});
} }
checkResize() { checkResize() {
@@ -3808,17 +4000,7 @@ app.registerExtension({
compWidget.value = 18; compWidget.value = 18;
} }
// Hide global prompt by default on creation without destroying its DOM element // Global Prompt is now left completely visible on creation!
const globalPromptWidget = this.widgets?.find(w => w.name === "global_prompt");
if (globalPromptWidget) {
if (!globalPromptWidget.options) globalPromptWidget.options = {};
globalPromptWidget.options.hidden = true;
globalPromptWidget.hidden = true;
globalPromptWidget.computeSize = () => [0, 0];
setTimeout(() => {
if (globalPromptWidget.element) globalPromptWidget.element.style.display = "none";
}, 0);
}
const container = document.createElement("div"); const container = document.createElement("div");
const widget = this.addDOMWidget("timeline_ui", "timeline_ui", container, { const widget = this.addDOMWidget("timeline_ui", "timeline_ui", container, {

75
latent_slice.py Normal file
View File

@@ -0,0 +1,75 @@
# --- START OF FILE latent_slice.py ---
import torch
class CleanLatentSlice:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"latent": ("LATENT",),
"start": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 1, "tooltip": "The starting frame index to slice from (plug 'latent_start_index' here)."}),
"length": ("INT", {"default": 1, "min": 1, "max": 100000, "step": 1, "tooltip": "The number of frames to keep (plug 'clean_latent_frames' here)."}),
}
}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latent",)
FUNCTION = "slice_latent"
CATEGORY = "WhatDreamsCost"
DESCRIPTION = "Safely slices a video latent starting from an offset index for a specific length. Uses torch.narrow to bypass PyTorch NestedTensor slicing bugs."
def slice_latent(self, latent, start, length):
new_latent = latent.copy()
def safe_slice(tensor, target_start, target_len):
dims = tensor.ndim if hasattr(tensor, "ndim") else len(tensor.shape)
# Constrain starting index and length to the actual size of the tensor
max_size = tensor.size(2) if dims == 5 else tensor.size(0)
actual_start = min(target_start, max_size - 1) if max_size > 0 else 0
actual_len = min(target_len, max_size - actual_start)
try:
# torch.narrow is the safest low-level C++ slice method to bypass NestedTensor bugs
if dims == 5:
# [Batch, Channels, Frames, Height, Width] -> Slice dimension 2
return torch.narrow(tensor, 2, actual_start, actual_len)
elif dims == 4:
# [Frames, Channels, Height, Width] -> Slice dimension 0
return torch.narrow(tensor, 0, actual_start, actual_len)
elif dims == 3:
# [Frames, Height, Width] -> Slice dimension 0
return torch.narrow(tensor, 0, actual_start, actual_len)
except Exception as e:
# Fallback if narrow fails
if dims == 5:
return tensor[:, :, actual_start : actual_start + actual_len]
elif dims == 4:
return tensor[actual_start : actual_start + actual_len]
elif dims == 3:
return tensor[actual_start : actual_start + actual_len]
return tensor
# Safely slice video samples
if "samples" in new_latent:
new_latent["samples"] = safe_slice(new_latent["samples"], start, length)
# Safely slice video noise mask (if it exists)
if "noise_mask" in new_latent:
new_latent["noise_mask"] = safe_slice(new_latent["noise_mask"], start, length)
return (new_latent,)
# Register the node with ComfyUI
NODE_CLASS_MAPPINGS = {
"CleanLatentSlice": CleanLatentSlice
}
NODE_DISPLAY_NAME_MAPPINGS = {
"CleanLatentSlice": "Clean Latent Slice"
}
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']

View File

@@ -1,8 +1,11 @@
# --- START OF FILE ltx_director.py ---
import logging import logging
import json import json
import base64 import base64
import io as _io import io as _io
import math import math
import time
import numpy as np import numpy as np
import torch import torch
@@ -31,6 +34,181 @@ log = logging.getLogger(__name__)
GuideData = io.Custom("GUIDE_DATA") GuideData = io.Custom("GUIDE_DATA")
def _preprocess_prompts_with_characters(global_prompt, local_prompts, char1="", char2="", char3=""):
"""Invisibly swaps out @character1/@char1 tags with their high-fidelity VLM descriptions."""
gp = global_prompt
char1 = char1 if char1 else ""
char2 = char2 if char2 else ""
char3 = char3 if char3 else ""
# Process Global Prompt
for tag in ["@character1", "@char1"]:
if tag in gp:
gp = gp.replace(tag, char1)
for tag in ["@character2", "@char2"]:
if tag in gp:
gp = gp.replace(tag, char2)
for tag in ["@character3", "@char3"]:
if tag in gp:
gp = gp.replace(tag, char3)
# Process Local Timeline Prompts
locals_list = [p.strip() for p in local_prompts.split("|")] if local_prompts else []
processed_locals = []
for lp in locals_list:
for tag in ["@character1", "@char1"]:
if tag in lp:
lp = lp.replace(tag, char1)
for tag in ["@character2", "@char2"]:
if tag in lp:
lp = lp.replace(tag, char2)
for tag in ["@character3", "@char3"]:
if tag in lp:
lp = lp.replace(tag, char3)
processed_locals.append(lp)
return gp, " | ".join(processed_locals)
def _format_timeline_to_text(global_prompt, duration_frames, frame_rate, epsilon,
custom_width, custom_height, resize_method,
timeline_data, local_prompts, segment_lengths, guide_strength):
lines = []
lines.append("LTX Director Timeline Export")
lines.append("============================")
lines.append("")
lines.append("=== Global Parameters ===")
lines.append(f"Global Prompt:\n{global_prompt}\n")
fr = float(frame_rate) if frame_rate else 24.0
if fr <= 0: fr = 24.0
lines.append(f"Duration: {duration_frames} frames ({(duration_frames / fr):.2f}s @ {fr} FPS)")
lines.append(f"Epsilon (Penalty Decay): {epsilon}")
if custom_width > 0 or custom_height > 0:
lines.append(f"Target Dimensions: {custom_width}x{custom_height} (Resize Method: {resize_method})")
else:
lines.append("Target Dimensions: Auto (Based on first image)")
lines.append("")
lines.append("=== Timeline Segments ===")
# --- 1. Text Prompts (Extracted from direct inputs) ---
locals_list = [p.strip() for p in local_prompts.split("|")] if local_prompts else []
lengths_list = [l.strip() for l in segment_lengths.split(",")] if segment_lengths else []
if locals_list and any(locals_list):
lines.append("\n--- Text Prompts ---")
current_frame = 0.0
for i, prompt in enumerate(locals_list):
try:
len_f = float(lengths_list[i]) if i < len(lengths_list) and lengths_list[i] else 0.0
except ValueError:
len_f = 0.0
start_f = current_frame
end_f = start_f + len_f
start_s = start_f / fr
end_s = end_f / fr
len_s = len_f / fr
lines.append(f"\n[Prompt {i+1}]")
lines.append(f"Time: {start_s:.2f}s - {end_s:.2f}s (Duration: {len_s:.2f}s)")
lines.append(f"Frames: {start_f:.1f} - {end_f:.1f} (Length: {len_f:.1f})")
lines.append(f"Prompt:\n{prompt}")
lines.append("-" * 40)
current_frame += len_f
# --- 2. Images & Audio (Extracted from JSON) ---
try:
tdata = json.loads(timeline_data) if timeline_data else {}
segs = tdata.get("segments", [])
img_segs = [s for s in segs if s.get("type", "image") == "image"]
img_segs.sort(key=lambda s: float(s.get("start", 0)))
if img_segs:
lines.append("\n--- Image Guides ---")
strengths = [float(x.strip()) for x in guide_strength.split(",")] if guide_strength and guide_strength.strip() else []
for i, seg in enumerate(img_segs):
start_f = float(seg.get("start", 0))
start_s = start_f / fr
strength = strengths[i] if i < len(strengths) else 1.0
lines.append(f"\n[Image {i+1}]")
lines.append(f"Time Inserted: {start_s:.2f}s (Frame {start_f:.1f})")
lines.append(f"Guide Strength: {strength}")
lines.append("-" * 40)
audio_segs = [s for s in segs if s.get("type", "audio") == "audio"]
audio_segs.sort(key=lambda s: float(s.get("start", 0)))
if audio_segs:
lines.append("\n--- Audio Segments ---")
for i, seg in enumerate(audio_segs):
start_f = float(seg.get("start", 0))
len_f = float(seg.get("length", 0))
start_s = start_f / fr
len_s = len_f / fr
file_name = seg.get("fileName", "Unknown")
lines.append(f"\n[Audio {i+1}] {file_name}")
lines.append(f"Time: {start_s:.2f}s (Frame {start_f:.1f}) | Duration: {len_s:.2f}s")
lines.append("-" * 40)
except Exception as e:
lines.append(f"\n[Note: Could not parse detailed timeline JSON for images/audio. Error: {e}]")
return "\n".join(lines)
# Register API endpoint for instant export from the JS UI if needed
try:
import server
from aiohttp import web
@server.PromptServer.instance.routes.post("/ltx_director/export_timeline")
async def export_timeline_endpoint(request):
try:
data = await request.json()
global_prompt = data.get("global_prompt", "")
timeline_data = data.get("timeline_data", "{}")
duration_frames = int(data.get("duration_frames", 0))
frame_rate = float(data.get("frame_rate", 24))
epsilon = float(data.get("epsilon", 0.001))
custom_width = int(data.get("custom_width", 0))
custom_height = int(data.get("custom_height", 0))
resize_method = data.get("resize_method", "maintain aspect ratio")
local_prompts = data.get("local_prompts", "")
segment_lengths = data.get("segment_lengths", "")
guide_strength = data.get("guide_strength", "")
formatted_text = _format_timeline_to_text(
global_prompt, duration_frames, frame_rate, epsilon,
custom_width, custom_height, resize_method,
timeline_data, local_prompts, segment_lengths, guide_strength
)
out_dir = folder_paths.get_output_directory()
filename = f"ltx_director_prompts_{int(time.time())}.txt"
filepath = os.path.join(out_dir, filename)
with open(filepath, "w", encoding="utf-8") as f:
f.write(formatted_text)
return web.json_response({
"status": "success",
"filepath": filepath,
"filename": filename,
"content": formatted_text
})
except Exception as e:
log.error(f"[PromptRelay] Export timeline endpoint error: {e}")
return web.json_response({"status": "error", "message": str(e)}, status=500)
except Exception as e:
log.warning(f"[PromptRelay] Could not register /ltx_director/export_timeline endpoint: {e}")
def _load_image_tensor(seg: dict) -> torch.Tensor: def _load_image_tensor(seg: dict) -> torch.Tensor:
"""Decode an image from the ComfyUI input folder (if imageFile provided) or fallback to base64 """Decode an image from the ComfyUI input folder (if imageFile provided) or fallback to base64
to a ComfyUI-style image tensor of shape [1, H, W, 3], float32 in [0, 1].""" to a ComfyUI-style image tensor of shape [1, H, W, 3], float32 in [0, 1]."""
@@ -389,73 +567,32 @@ class LTXDirector(io.ComfyNode):
io.Clip.Input("clip"), io.Clip.Input("clip"),
io.Vae.Input("audio_vae", optional=True, tooltip="Optional. Connect an Audio VAE to generate audio latents."), io.Vae.Input("audio_vae", optional=True, tooltip="Optional. Connect an Audio VAE to generate audio latents."),
io.Latent.Input("optional_latent", optional=True, tooltip="Optional. Connect a latent to override the auto-generated one."), io.Latent.Input("optional_latent", optional=True, tooltip="Optional. Connect a latent to override the auto-generated one."),
io.String.Input( io.String.Input("global_prompt", multiline=True, default="", tooltip="Conditions the entire video. Anchors persistent characters, objects, and scene context."),
"global_prompt", multiline=True, default="", io.Int.Input("duration_frames", default=120, min=1, max=10000, step=1, tooltip="Total timeline length in pixel-space frames. Used by the editor for visual scale only."),
tooltip="Conditions the entire video. Anchors persistent characters, objects, and scene context.", io.Float.Input("duration_seconds", default=5.0, min=0.1, max=1000.0, step=0.01, tooltip="Total timeline duration in seconds (computed/synced from frames)."),
), io.String.Input("timeline_data", default="", tooltip="JSON state of the timeline editor (auto-managed; do not edit by hand)."),
io.Int.Input( io.Boolean.Input("use_custom_audio", default=False, tooltip="Toggle between using timeline audio (ON) and generating audio from scratch (OFF)."),
"duration_frames", default=120, min=1, max=10000, step=1, io.String.Input("local_prompts", multiline=True, default="", tooltip="Auto-populated from the timeline editor."),
tooltip="Total timeline length in pixel-space frames. Used by the editor for visual scale only.", io.String.Input("segment_lengths", default="", tooltip="Auto-populated from the timeline editor (pixel-space frame counts)."),
), io.Float.Input("epsilon", default=0.001, min=0.0001, max=0.99, step=0.0001, tooltip="Penalty decay parameter. Values below ~0.1 all produce sharp boundaries (paper default 0.001). For softer transitions, try 0.5 or higher."),
io.Float.Input( io.Float.Input("frame_rate", default=24.0, min=1.0, max=240.0, step=1.0, tooltip="Frames per second — only affects how time is displayed in the timeline editor when time_units is set to 'seconds'."),
"duration_seconds", default=5, min=0.1, max=1000.0, step=0.01, io.Combo.Input("display_mode", options=["frames", "seconds"], default="seconds", tooltip="Display the ruler, segment ranges, length input, and total in frames or seconds. Internal storage is always pixel-space frames."),
tooltip="Total timeline duration in seconds (computed/synced from frames).", io.String.Input("guide_strength", default="", tooltip="Auto-populated from the timeline editor (comma-separated guide strengths for image segments)."),
), io.Int.Input("custom_width", default=0, min=0, max=8192, step=1, tooltip="Target output width for all image segments. Set to 0 to use the original image width."),
io.String.Input( io.Int.Input("custom_height", default=0, min=0, max=8192, step=1, tooltip="Target output height for all image segments. Set to 0 to use the original image height."),
"timeline_data", default="", io.Combo.Input("resize_method", options=["maintain aspect ratio", "stretch to fit", "pad", "crop"], default="maintain aspect ratio", tooltip="How to resize image segments to fit the target dimensions."),
tooltip="JSON state of the timeline editor (auto-managed; do not edit by hand).", io.Int.Input("divisible_by", default=32, min=1, max=256, step=1, tooltip="Snap the final output image dimensions to be divisible by this number (e.g. 32 for LTX)."),
), io.Int.Input("img_compression", default=18, min=0, max=100, step=1, tooltip="H.264 CRF compression to apply to each guide image. 0 = no compression, higher = more artefacts."),
io.Boolean.Input( io.Boolean.Input("save_prompts_to_file", default=False, optional=True, tooltip="Save the timeline prompts and parameters to a text file in your ComfyUI output directory during execution."),
"use_custom_audio", default=False, optional=True, io.Image.Input("reference_image", optional=True, tooltip="Invisible character sheet/style reference. Can also be a Batch of images. Automatically appended to the sequence out-of-bounds."),
tooltip="Toggle between using timeline audio (ON) and generating audio from scratch (OFF).", io.Image.Input("reference_image_2", optional=True, tooltip="Second optional reference image."),
), io.Image.Input("reference_image_3", optional=True, tooltip="Third optional reference image."),
io.String.Input( io.Float.Input("reference_strength", default=1.0, min=0.0, max=5.0, step=0.05, optional=True, tooltip="Guide strength for the reference images."),
"local_prompts", multiline=True, default="",
tooltip="Auto-populated from the timeline editor.", # New descriptive inputs for automatic under-the-hood character replacement
), io.String.Input("char1_description", multiline=True, default="", optional=True, tooltip="Plug in a detailed description for @character1 / @char1. You can write it manually or connect a VLM/Caption node."),
io.String.Input( io.String.Input("char2_description", multiline=True, default="", optional=True, tooltip="Plug in a detailed description for @character2 / @char2."),
"segment_lengths", default="", io.String.Input("char3_description", multiline=True, default="", optional=True, tooltip="Plug in a detailed description for @character3 / @char3."),
tooltip="Auto-populated from the timeline editor (pixel-space frame counts).",
),
io.Float.Input(
"epsilon", default=0.001, min=0.0001, max=0.99, step=0.0001,
tooltip="Penalty decay parameter. Values below ~0.1 all produce sharp boundaries (paper default 0.001). For softer transitions, try 0.5 or higher.",
),
io.Float.Input(
"frame_rate", default=24, min=1, max=240, step=1, optional=True,
tooltip="Frames per second — only affects how time is displayed in the timeline editor when time_units is set to 'seconds'.",
),
io.Combo.Input(
"display_mode", options=["frames", "seconds"], default="seconds", optional=True,
tooltip="Display the ruler, segment ranges, length input, and total in frames or seconds. Internal storage is always pixel-space frames.",
),
io.String.Input(
"guide_strength", default="",
tooltip="Auto-populated from the timeline editor (comma-separated guide strengths for image segments).",
),
io.Int.Input(
"custom_width", default=0, min=0, max=8192, step=1, optional=True,
tooltip="Target output width for all image segments. Set to 0 to use the original image width.",
),
io.Int.Input(
"custom_height", default=0, min=0, max=8192, step=1, optional=True,
tooltip="Target output height for all image segments. Set to 0 to use the original image height.",
),
io.Combo.Input(
"resize_method",
options=["maintain aspect ratio", "stretch to fit", "pad", "crop"],
default="maintain aspect ratio",
optional=True,
tooltip="How to resize image segments to fit the target dimensions.",
),
io.Int.Input(
"divisible_by", default=32, min=1, max=256, step=1, optional=True,
tooltip="Snap the final output image dimensions to be divisible by this number (e.g. 32 for LTX).",
),
io.Int.Input(
"img_compression", default=18, min=0, max=100, step=1, optional=True,
tooltip="H.264 CRF compression to apply to each guide image. 0 = no compression, higher = more artefacts.",
),
], ],
outputs=[ outputs=[
io.Model.Output(display_name="model"), io.Model.Output(display_name="model"),
@@ -465,20 +602,36 @@ class LTXDirector(io.ComfyNode):
GuideData.Output(display_name="guide_data"), GuideData.Output(display_name="guide_data"),
io.Float.Output(display_name="frame_rate", tooltip="The frame rate used for the timeline."), io.Float.Output(display_name="frame_rate", tooltip="The frame rate used for the timeline."),
io.Audio.Output(display_name="combined_audio", tooltip="Combined timeline audio layout."), io.Audio.Output(display_name="combined_audio", tooltip="Combined timeline audio layout."),
io.Int.Output(display_name="clean_latent_frames", tooltip="Plug this into a CleanLatentSlice node 'length' to effortlessly cut off the invisible reference image."),
io.Int.Output(display_name="clean_pixel_frames", tooltip="Plug this into your Video Combine 'frame_load_cap' to effortlessly cut off the invisible reference image."),
], ],
) )
@classmethod @classmethod
def execute(cls, model, clip, global_prompt, duration_frames, duration_seconds, def execute(cls, model, clip, global_prompt, duration_frames, duration_seconds,
timeline_data, local_prompts, segment_lengths, guide_strength="", epsilon=1e-3, timeline_data, local_prompts, segment_lengths, guide_strength="", epsilon=1e-3,
frame_rate=24, display_mode="seconds", frame_rate=24.0, display_mode="seconds",
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) -> io.NodeOutput: use_custom_audio=False, save_prompts_to_file=False,
reference_image=None, reference_image_2=None, reference_image_3=None, reference_strength=1.0,
char1_description="", char2_description="", char3_description="") -> io.NodeOutput:
# --- Calculate Clean Output Bounds First ---
clean_pixel_frames = duration_frames + 1
clean_latent_frames = ((clean_pixel_frames - 1) // 8) + 1
# --- Build guide_data from image segments FIRST (to derive output dimensions) --- # --- Build guide_data from image segments FIRST (to derive output dimensions) ---
guide_data = {"images": [], "insert_frames": [], "strengths": [], "frame_rate": frame_rate} guide_data = {"images": [], "insert_frames": [], "strengths": [], "frame_rate": float(frame_rate)}
derived_w, derived_h = custom_width, custom_height derived_w, derived_h = custom_width, custom_height
# Consolidate all reference inputs into a flat list, unraveling any image batches.
refs_to_process = []
for ref_input in (reference_image, reference_image_2, reference_image_3):
if ref_input is not None:
for i in range(ref_input.shape[0]):
refs_to_process.append(ref_input[i:i+1])
try: try:
tdata = json.loads(timeline_data) if timeline_data else {} tdata = json.loads(timeline_data) if timeline_data else {}
img_segs = [ img_segs = [
@@ -531,12 +684,15 @@ class LTXDirector(io.ComfyNode):
strength = strengths[idx] if idx < len(strengths) else 1.0 strength = strengths[idx] if idx < len(strengths) else 1.0
guide_data["images"].append(tensor) guide_data["images"].append(tensor)
# Keep timeline images at their exact timeline frames (no shifting)
guide_data["insert_frames"].append(int(seg["start"])) guide_data["insert_frames"].append(int(seg["start"]))
guide_data["strengths"].append(float(strength)) guide_data["strengths"].append(float(strength))
# If no images were loaded from the timeline, create a dummy image at strength 0 # If no images were loaded from the timeline, create a dummy image at strength 0
# to prevent artifacts in text-to-video mode. # to prevent artifacts in text-to-video mode.
if not guide_data["images"]: if not guide_data["images"] and not refs_to_process:
w = derived_w if derived_w > 0 else 768 w = derived_w if derived_w > 0 else 768
h = derived_h if derived_h > 0 else 512 h = derived_h if derived_h > 0 else 512
w = (w // 32) * 32 w = (w // 32) * 32
@@ -552,27 +708,70 @@ class LTXDirector(io.ComfyNode):
except Exception as e: except Exception as e:
log.warning("[PromptRelay] Could not build guide_data: %s", e) log.warning("[PromptRelay] Could not build guide_data: %s", e)
# --- Auto-generate LTXV latent if none was provided --- # --- Handle Reference Image Injection (End-Hiding) ---
ltxv_length = duration_frames + 1 if refs_to_process:
if optional_latent is not None:
log.warning("[PromptRelay] You connected reference images AND an external 'optional_latent'. Make sure your custom latent is long enough to fit the appended reference frames!")
for i, single_ref in enumerate(refs_to_process):
src_h, src_w = single_ref.shape[1], single_ref.shape[2]
def snap(val, div): return max(div, (val // div) * div)
if custom_width > 0 and custom_height > 0:
ref_tensor = _resize_image(single_ref, custom_width, custom_height, resize_method, divisible_by)
elif custom_width > 0:
tgt_w = snap(custom_width, divisible_by)
tgt_h = snap(int(src_h * tgt_w / src_w), divisible_by)
ref_tensor = _resize_image(single_ref, tgt_w, tgt_h, "stretch to fit", divisible_by)
elif custom_height > 0:
tgt_h = snap(custom_height, divisible_by)
tgt_w = snap(int(src_w * tgt_h / src_h), divisible_by)
ref_tensor = _resize_image(single_ref, tgt_w, tgt_h, "stretch to fit", divisible_by)
else:
ref_tensor = _resize_image(single_ref, src_w, src_h, "maintain aspect ratio", divisible_by)
if img_compression > 0:
ref_tensor = _compress_image(ref_tensor, img_compression)
guide_data["images"].append(ref_tensor)
# Safely hide reference sheets at the very end of the video sequence
insert_point = (clean_latent_frames + i) * 8
guide_data["insert_frames"].append(insert_point)
guide_data["strengths"].append(float(reference_strength))
# Record dimensions for empty latent generation from the FIRST reference image if none exist
if derived_w <= 0 or derived_h <= 0:
derived_w = ref_tensor.shape[2]
derived_h = ref_tensor.shape[1]
# --- Auto-generate LTXV latent ---
total_latents = clean_latent_frames + len(refs_to_process)
ltxv_length = ((total_latents - 1) * 8) + 1 # Map back to pixel frames for the empty latent logic
if optional_latent is None: if optional_latent is None:
latent_w = max(32, (derived_w // 32) * 32) latent_w = max(32, (derived_w // 32) * 32)
latent_h = max(32, (derived_h // 32) * 32) latent_h = max(32, (derived_h // 32) * 32)
# LTXV temporal: ((length - 1) // 8) + 1 latent frames; invert to get pixel frames -> length
latent_t = ((ltxv_length - 1) // 8) + 1
samples = torch.zeros( samples = torch.zeros(
[1, 128, latent_t, latent_h // 32, latent_w // 32], [1, 128, total_latents, latent_h // 32, latent_w // 32],
device=comfy.model_management.intermediate_device(), device=comfy.model_management.intermediate_device(),
) )
latent = {"samples": samples} latent = {"samples": samples}
log.info( log.info(
"[PromptRelay] Auto-generated LTXV latent: %dx%d, %d pixel frames (%d latent frames)", "[PromptRelay] Auto-generated LTXV latent: %dx%d, %d pixel frames (%d latent frames, %d refs hidden)",
latent_w, latent_h, ltxv_length, latent_t, latent_w, latent_h, ltxv_length, total_latents, len(refs_to_process)
) )
else: else:
latent = optional_latent latent = optional_latent
# --- Preprocess Prompts with Character Tags ---
processed_global, processed_local = _preprocess_prompts_with_characters(
global_prompt, local_prompts, char1_description, char2_description, char3_description
)
patched, conditioning = _encode_relay( patched, conditioning = _encode_relay(
model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon, model, clip, latent, processed_global, processed_local, segment_lengths, epsilon,
) )
# --- Build Audio Output --- # --- Build Audio Output ---
@@ -582,7 +781,6 @@ class LTXDirector(io.ComfyNode):
audio_latent = {} audio_latent = {}
if audio_vae is not None: if audio_vae is not None:
# Helper to generate empty latent
def get_empty_latent(): def get_empty_latent():
# Support both raw AudioVAE objects and ComfyUI VAE wrappers. # Support both raw AudioVAE objects and ComfyUI VAE wrappers.
inner = getattr(audio_vae, "first_stage_model", audio_vae) inner = getattr(audio_vae, "first_stage_model", audio_vae)
@@ -598,17 +796,12 @@ class LTXDirector(io.ComfyNode):
if use_custom_audio: if use_custom_audio:
try: try:
if audio_out is not None: if audio_out is not None:
# 1. Encode audio waveform into latent space
waveform = audio_out["waveform"] waveform = audio_out["waveform"]
if waveform.ndim == 2: if waveform.ndim == 2:
waveform = waveform.unsqueeze(0) waveform = waveform.unsqueeze(0)
if waveform.ndim != 3: if waveform.ndim != 3:
raise ValueError( raise ValueError(f"Expected custom audio waveform with 2 or 3 dims, got shape {tuple(waveform.shape)}")
f"Expected custom audio waveform with 2 or 3 dims, got shape {tuple(waveform.shape)}"
)
# Wrapped ComfyUI VAE expects (batch, samples, channels);
# raw AudioVAE expects a dict with waveform in (batch, channels, samples).
if hasattr(audio_vae, "first_stage_model"): if hasattr(audio_vae, "first_stage_model"):
latent_samples = audio_vae.encode(waveform.movedim(1, -1)) latent_samples = audio_vae.encode(waveform.movedim(1, -1))
else: else:
@@ -620,7 +813,6 @@ class LTXDirector(io.ComfyNode):
if latent_samples.numel() == 0: if latent_samples.numel() == 0:
raise ValueError("Encoded audio latent is empty (0 elements).") raise ValueError("Encoded audio latent is empty (0 elements).")
# 2. Create solid mask with value 0.0 (0 means keep/use conditioning, 1 means generate noise)
mask = torch.full( mask = torch.full(
(1, latent_samples.shape[-2], latent_samples.shape[-1]), (1, latent_samples.shape[-2], latent_samples.shape[-1]),
0.0, 0.0,
@@ -628,7 +820,6 @@ class LTXDirector(io.ComfyNode):
device=comfy.model_management.intermediate_device() device=comfy.model_management.intermediate_device()
) )
# 3. Set Latent Noise Mask
audio_latent = { audio_latent = {
"samples": latent_samples, "samples": latent_samples,
"type": "audio", "type": "audio",
@@ -641,7 +832,6 @@ class LTXDirector(io.ComfyNode):
log.error("[PromptRelay] Failed to generate custom audio latent: %s", e) log.error("[PromptRelay] Failed to generate custom audio latent: %s", e)
raise e raise e
else: else:
# Generate empty latent
try: try:
audio_latent = get_empty_latent() audio_latent = get_empty_latent()
log.info("[PromptRelay] Auto-generated empty audio latent.") log.info("[PromptRelay] Auto-generated empty audio latent.")
@@ -649,7 +839,34 @@ class LTXDirector(io.ComfyNode):
log.error("[PromptRelay] Could not generate empty audio latent: %s", e) log.error("[PromptRelay] Could not generate empty audio latent: %s", e)
raise e raise e
return io.NodeOutput(patched, conditioning, latent, audio_latent, guide_data, float(frame_rate), audio_out) # --- Save formatted Prompts logic ---
if save_prompts_to_file:
try:
formatted_text = _format_timeline_to_text(
processed_global, duration_frames, float(frame_rate), epsilon,
custom_width, custom_height, resize_method,
timeline_data, processed_local, segment_lengths, guide_strength
)
out_dir = folder_paths.get_output_directory()
filename = f"ltx_director_prompts_{int(time.time())}.txt"
filepath = os.path.join(out_dir, filename)
with open(filepath, "w", encoding="utf-8") as f:
f.write(formatted_text)
log.info(f"[PromptRelay] Saved timeline prompts to {filepath}")
except Exception as e:
log.warning(f"[PromptRelay] Failed to save prompts to txt: {e}")
return io.NodeOutput(
patched,
conditioning,
latent,
audio_latent,
guide_data,
float(frame_rate),
audio_out,
clean_latent_frames,
clean_pixel_frames
)
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {

View File

@@ -1,3 +1,4 @@
import json
from comfy_extras.nodes_lt import get_noise_mask, LTXVAddGuide from comfy_extras.nodes_lt import get_noise_mask, LTXVAddGuide
import torch import torch
import comfy.utils import comfy.utils
@@ -55,12 +56,14 @@ class LTXSequencer(LTXVAddGuide):
node_id="LTXSequencer", node_id="LTXSequencer",
display_name="LTX Sequencer", display_name="LTX Sequencer",
category="WhatDreamsCost", category="WhatDreamsCost",
description="Add multiple guide images at specified frame indices or seconds with strengths. Number of widgets is dynamically configured.", description="Add multiple guide images at specified frame indices or seconds. Auto-syncs to Prompt Relay invisibly via the positive wire.",
inputs=inputs, inputs=inputs,
outputs=[ outputs=[
io.Conditioning.Output(display_name="positive"), io.Conditioning.Output(display_name="positive"),
io.Conditioning.Output(display_name="negative"), io.Conditioning.Output(display_name="negative"),
io.Latent.Output(display_name="latent", tooltip="Video latent with added guides"), io.Latent.Output(display_name="latent", tooltip="Video latent with added guides"),
# ---> ADDED SYNC LOG OUTPUT <---
io.String.Output(display_name="sync_log", tooltip="Outputs a readable text block showing the actual times used for each image"),
], ],
) )
@@ -89,30 +92,64 @@ class LTXSequencer(LTXVAddGuide):
insert_mode = kwargs.get("insert_mode", "frames") insert_mode = kwargs.get("insert_mode", "frames")
frame_rate = kwargs.get("frame_rate", 24) frame_rate = kwargs.get("frame_rate", 24)
# ---> EXTRACT GHOST SYNC DATA <---
timeline_data = None
if positive is not None and len(positive) > 0:
if "prompt_relay_timeline" in positive[0][1]:
try:
timeline_data = json.loads(positive[0][1]["prompt_relay_timeline"])
except json.JSONDecodeError:
pass
# Prepare the log text
sync_log_lines = []
if timeline_data:
sync_log_lines.append("=== TIMELINE SYNC ENABLED ===")
sync_log_lines.append(f"Invisibly Synced via Positive Wire! Using {insert_mode.upper()}:")
else:
sync_log_lines.append("=== MANUAL MODE ===")
sync_log_lines.append(f"No timeline detected, or Sync turned OFF. Using manual UI inputs ({insert_mode}):")
# Process inputs up to num_images, extracting dynamic frame/strength values from kwargs # Process inputs up to num_images, extracting dynamic frame/strength values from kwargs
for i in range(1, num_images + 1): for i in range(1, num_images + 1):
# Skip if this image index exceeds the batch # Skip if this image index exceeds the batch
if i > batch_size: if i > batch_size:
sync_log_lines.append(f"Image #{i}: Skipped (No image loaded in batch)")
continue continue
img = multi_input[i-1:i] # Extract the single image frame from the batch img = multi_input[i-1:i] # Extract the single image frame from the batch
if img is None: if img is None:
continue continue
# Calculate the final frame index based on the chosen mode
f_idx = None f_idx = None
strength = kwargs.get(f"strength_{i}", 1.0)
# 1. AUTO-SYNC
if timeline_data and "starts_frames" in timeline_data and (i - 1) < len(timeline_data["starts_frames"]):
display_frame = timeline_data["starts_frames"][i - 1]
display_sec = timeline_data["starts_seconds"][i - 1]
if insert_mode == "frames":
f_idx = display_frame
sync_log_lines.append(f"-> Image #{i} (Strength: {strength}): Synced to Segment #{i} start @ {display_frame} frames")
elif insert_mode == "seconds":
f_idx = int(display_sec * frame_rate)
sync_log_lines.append(f"-> Image #{i} (Strength: {strength}): Synced to Segment #{i} start @ {display_sec:.2f} seconds (Frame {f_idx})")
# 2. MANUAL FALLBACK
else:
if insert_mode == "frames": if insert_mode == "frames":
f_idx = kwargs.get(f"insert_frame_{i}") f_idx = kwargs.get(f"insert_frame_{i}")
sync_log_lines.append(f"-> Image #{i} (Strength: {strength}): Manual input @ {f_idx} frames")
elif insert_mode == "seconds": elif insert_mode == "seconds":
sec = kwargs.get(f"insert_second_{i}") sec = kwargs.get(f"insert_second_{i}")
if sec is not None: if sec is not None:
f_idx = int(sec * frame_rate) f_idx = int(sec * frame_rate)
sync_log_lines.append(f"-> Image #{i} (Strength: {strength}): Manual input @ {sec:.2f} seconds (Frame {f_idx})")
if f_idx is None: if f_idx is None:
continue continue
strength = kwargs.get(f"strength_{i}", 1.0)
# Execution logic mirrored from LTXVAddGuideMulti # Execution logic mirrored from LTXVAddGuideMulti
image_1, t = cls.encode(vae, latent_width, latent_height, img, scale_factors) image_1, t = cls.encode(vae, latent_width, latent_height, img, scale_factors)
@@ -130,4 +167,8 @@ class LTXSequencer(LTXVAddGuide):
scale_factors, scale_factors,
) )
return io.NodeOutput(positive, negative, {"samples": latent_image, "noise_mask": noise_mask}) sync_log_str = "\n".join(sync_log_lines)
print(f"\n[LTX Sequencer]\n{sync_log_str}\n")
# Returned the newly formatted sync log string at the end of NodeOutput
return io.NodeOutput(positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, sync_log_str)