Add files via upload

This commit is contained in:
CGlide
2026-06-02 12:22:23 +02:00
committed by GitHub
parent 0604d7fb0e
commit 32a8bfa511
41 changed files with 40726 additions and 40471 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

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 { 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, {

View File

@@ -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']

View File

@@ -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"