Add files via upload
This commit is contained in:
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.
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.
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,3 +1,5 @@
|
||||
// --- START OF FILE ltx_director.js ---
|
||||
|
||||
const { app } = window.comfyAPI.app;
|
||||
const { api } = window.comfyAPI.api;
|
||||
|
||||
@@ -516,6 +518,55 @@ const STYLES = `
|
||||
.pr-segment:hover:not(.active) {
|
||||
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")) {
|
||||
@@ -712,6 +763,7 @@ class TimelineEditor {
|
||||
this.pauseAudio();
|
||||
window.removeEventListener("keydown", this.handleKeyDown, true);
|
||||
window.removeEventListener("paste", this.handlePaste, true);
|
||||
if (this._autocompleteMenu) { this._autocompleteMenu.remove(); }
|
||||
}
|
||||
|
||||
getDurationFrames() {
|
||||
@@ -1025,7 +1077,7 @@ class TimelineEditor {
|
||||
// --- Text Area (Image/Text) ---
|
||||
this.promptInput = document.createElement("textarea");
|
||||
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", () => {
|
||||
if (this.selectionType === "image" && this.timeline.segments[this.selectedIndex]) {
|
||||
this.timeline.segments[this.selectedIndex].prompt = this.promptInput.value;
|
||||
@@ -1355,6 +1407,146 @@ class TimelineEditor {
|
||||
this.wrapper.appendChild(propContainer);
|
||||
|
||||
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() {
|
||||
@@ -3808,17 +4000,7 @@ app.registerExtension({
|
||||
compWidget.value = 18;
|
||||
}
|
||||
|
||||
// Hide global prompt by default on creation without destroying its DOM element
|
||||
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);
|
||||
}
|
||||
// Global Prompt is now left completely visible on creation!
|
||||
|
||||
const container = document.createElement("div");
|
||||
const widget = this.addDOMWidget("timeline_ui", "timeline_ui", container, {
|
||||
|
||||
@@ -8,7 +8,8 @@ class CleanLatentSlice:
|
||||
return {
|
||||
"required": {
|
||||
"latent": ("LATENT",),
|
||||
"length": ("INT", {"default": 1, "min": 1, "max": 100000, "step": 1, "tooltip": "The target length in latent frames."}),
|
||||
"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)."}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,37 +17,59 @@ class CleanLatentSlice:
|
||||
RETURN_NAMES = ("latent",)
|
||||
FUNCTION = "slice_latent"
|
||||
CATEGORY = "WhatDreamsCost"
|
||||
DESCRIPTION = "Safely slices a video latent to a specific length. Uses torch.narrow to bypass PyTorch NestedTensor slicing bugs."
|
||||
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, length):
|
||||
def slice_latent(self, latent, start, length):
|
||||
new_latent = latent.copy()
|
||||
|
||||
def safe_slice(tensor, target_len):
|
||||
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:
|
||||
# Bypasses NestedTensor slicing bugs
|
||||
# torch.narrow is the safest low-level C++ slice method to bypass NestedTensor bugs
|
||||
if dims == 5:
|
||||
return torch.narrow(tensor, 2, 0, target_len)
|
||||
# [Batch, Channels, Frames, Height, Width] -> Slice dimension 2
|
||||
return torch.narrow(tensor, 2, actual_start, actual_len)
|
||||
elif dims == 4:
|
||||
return torch.narrow(tensor, 0, 0, target_len)
|
||||
# [Frames, Channels, Height, Width] -> Slice dimension 0
|
||||
return torch.narrow(tensor, 0, actual_start, actual_len)
|
||||
elif dims == 3:
|
||||
return torch.narrow(tensor, 0, 0, target_len)
|
||||
# [Frames, Height, Width] -> Slice dimension 0
|
||||
return torch.narrow(tensor, 0, actual_start, actual_len)
|
||||
except Exception as e:
|
||||
# Extreme fallback if narrow fails
|
||||
# Fallback if narrow fails
|
||||
if dims == 5:
|
||||
return tensor[:, :, :target_len]
|
||||
return tensor[:, :, actual_start : actual_start + actual_len]
|
||||
elif dims == 4:
|
||||
return tensor[:target_len]
|
||||
return tensor[actual_start : actual_start + actual_len]
|
||||
elif dims == 3:
|
||||
return tensor[:target_len]
|
||||
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"], length)
|
||||
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"], length)
|
||||
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']
|
||||
@@ -34,6 +34,42 @@ log = logging.getLogger(__name__)
|
||||
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):
|
||||
@@ -106,7 +142,7 @@ def _format_timeline_to_text(global_prompt, duration_frames, frame_rate, epsilon
|
||||
lines.append(f"Guide Strength: {strength}")
|
||||
lines.append("-" * 40)
|
||||
|
||||
audio_segs = [s for s in segs if s.get("type") == "audio"]
|
||||
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 ---")
|
||||
@@ -552,6 +588,11 @@ class LTXDirector(io.ComfyNode):
|
||||
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.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."),
|
||||
|
||||
# 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("char2_description", multiline=True, default="", optional=True, tooltip="Plug in a detailed description for @character2 / @char2."),
|
||||
io.String.Input("char3_description", multiline=True, default="", optional=True, tooltip="Plug in a detailed description for @character3 / @char3."),
|
||||
],
|
||||
outputs=[
|
||||
io.Model.Output(display_name="model"),
|
||||
@@ -573,7 +614,8 @@ class LTXDirector(io.ComfyNode):
|
||||
custom_width=768, custom_height=512, resize_method="maintain aspect ratio",
|
||||
divisible_by=32, img_compression=0, audio_vae=None, optional_latent=None,
|
||||
use_custom_audio=False, save_prompts_to_file=False,
|
||||
reference_image=None, reference_image_2=None, reference_image_3=None, reference_strength=1.0) -> io.NodeOutput:
|
||||
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
|
||||
@@ -642,7 +684,10 @@ class LTXDirector(io.ComfyNode):
|
||||
|
||||
strength = strengths[idx] if idx < len(strengths) else 1.0
|
||||
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["strengths"].append(float(strength))
|
||||
|
||||
# If no images were loaded from the timeline, create a dummy image at strength 0
|
||||
@@ -663,7 +708,7 @@ class LTXDirector(io.ComfyNode):
|
||||
except Exception as e:
|
||||
log.warning("[PromptRelay] Could not build guide_data: %s", e)
|
||||
|
||||
# --- Handle Reference Image Injection ---
|
||||
# --- Handle Reference Image Injection (End-Hiding) ---
|
||||
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!")
|
||||
@@ -690,8 +735,7 @@ class LTXDirector(io.ComfyNode):
|
||||
|
||||
guide_data["images"].append(ref_tensor)
|
||||
|
||||
# Insert safely in the "hidden" padded latent blocks
|
||||
# We place each ref 8 frames apart so they each get their own pure latent block.
|
||||
# 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))
|
||||
@@ -721,8 +765,13 @@ class LTXDirector(io.ComfyNode):
|
||||
else:
|
||||
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(
|
||||
model, clip, latent, global_prompt, local_prompts, segment_lengths, epsilon,
|
||||
model, clip, latent, processed_global, processed_local, segment_lengths, epsilon,
|
||||
)
|
||||
|
||||
# --- Build Audio Output ---
|
||||
@@ -794,9 +843,9 @@ class LTXDirector(io.ComfyNode):
|
||||
if save_prompts_to_file:
|
||||
try:
|
||||
formatted_text = _format_timeline_to_text(
|
||||
global_prompt, duration_frames, float(frame_rate), epsilon,
|
||||
processed_global, duration_frames, float(frame_rate), epsilon,
|
||||
custom_width, custom_height, resize_method,
|
||||
timeline_data, local_prompts, segment_lengths, guide_strength
|
||||
timeline_data, processed_local, segment_lengths, guide_strength
|
||||
)
|
||||
out_dir = folder_paths.get_output_directory()
|
||||
filename = f"ltx_director_prompts_{int(time.time())}.txt"
|
||||
|
||||
Reference in New Issue
Block a user