Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32a8bfa511 | ||
|
|
0604d7fb0e | ||
|
|
0f90ddd5da | ||
|
|
04b51c81f2 | ||
|
|
9f2807826e |
@@ -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**
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
BIN
__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/latent_slice.cpython-312.pyc
Normal file
BIN
__pycache__/latent_slice.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/load_audio_ui.cpython-312.pyc
Normal file
BIN
__pycache__/load_audio_ui.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/load_video_ui.cpython-312.pyc
Normal file
BIN
__pycache__/load_video_ui.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/ltx_director.cpython-312.pyc
Normal file
BIN
__pycache__/ltx_director.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/ltx_director_guide.cpython-312.pyc
Normal file
BIN
__pycache__/ltx_director_guide.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/ltx_keyframer.cpython-312.pyc
Normal file
BIN
__pycache__/ltx_keyframer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/ltx_sequencer.cpython-312.pyc
Normal file
BIN
__pycache__/ltx_sequencer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/multi_image_loader.cpython-312.pyc
Normal file
BIN
__pycache__/multi_image_loader.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/patches.cpython-312.pyc
Normal file
BIN
__pycache__/patches.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/prompt_relay.cpython-312.pyc
Normal file
BIN
__pycache__/prompt_relay.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/speech_length_calculator.cpython-312.pyc
Normal file
BIN
__pycache__/speech_length_calculator.cpython-312.pyc
Normal file
Binary file not shown.
1
example_workflows/LTX Director Workflow_cs.json
Normal file
1
example_workflows/LTX Director Workflow_cs.json
Normal file
File diff suppressed because one or more lines are too long
1
example_workflows/LTX Director Workflow_v2.json
Normal file
1
example_workflows/LTX Director Workflow_v2.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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
75
latent_slice.py
Normal 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']
|
||||||
397
ltx_director.py
397
ltx_director.py
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
if insert_mode == "frames":
|
strength = kwargs.get(f"strength_{i}", 1.0)
|
||||||
f_idx = kwargs.get(f"insert_frame_{i}")
|
|
||||||
elif insert_mode == "seconds":
|
# 1. AUTO-SYNC
|
||||||
sec = kwargs.get(f"insert_second_{i}")
|
if timeline_data and "starts_frames" in timeline_data and (i - 1) < len(timeline_data["starts_frames"]):
|
||||||
if sec is not None:
|
display_frame = timeline_data["starts_frames"][i - 1]
|
||||||
f_idx = int(sec * frame_rate)
|
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":
|
||||||
|
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":
|
||||||
|
sec = kwargs.get(f"insert_second_{i}")
|
||||||
|
if sec is not None:
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user