Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38207e93e5 | ||
|
|
8bca327a07 | ||
|
|
23294fa8df | ||
|
|
f0cc7aee26 | ||
|
|
ba9e2f8d78 | ||
|
|
1b159d81f9 | ||
|
|
e6040e1bad | ||
|
|
4d98a77639 | ||
|
|
c1c4557096 | ||
|
|
df0a760c70 | ||
|
|
1cce96f64f | ||
|
|
b31d511a27 | ||
|
|
32a8bfa511 | ||
|
|
0604d7fb0e | ||
|
|
0f90ddd5da | ||
|
|
04b51c81f2 | ||
|
|
9f2807826e |
34
README.md
34
README.md
@@ -8,6 +8,10 @@ All of my nodes are created with the help of AI, so there may or may not be redu
|
||||
|
||||
## ▶️ YouTube Tutorial Videos
|
||||
|
||||
<a href="https://youtu.be/j28z5PZXkKk?si=TPeWKm2BW-uA3BAx">
|
||||
<img width="642" height="718" alt="Capture d'écran 2026-06-17 234136" src="https://github.com/user-attachments/assets/89433110-8b78-4971-8098-3a9e9226f460" />
|
||||
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
@@ -28,19 +32,33 @@ All of my nodes are created with the help of AI, so there may or may not be redu
|
||||
## ❓ How to install nodes
|
||||
|
||||
- Navigate to your `/ComfyUI/custom_nodes/ folder`
|
||||
- Run `git clone https://github.com/WhatDreamscost/WhatDreamsCost-ComfyUI`
|
||||
- Or download through the ComfyUI Manager.
|
||||
- Delete your old "WhatDreamsCost-ComfyUI" Folder
|
||||
- Run `git clone -b main_cs https://github.com/CGlide/WhatDreamsCost-ComfyUI.git`
|
||||
|
||||
|
||||
**❗❗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).
|
||||
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!
|
||||
|
||||
This is a Modded LTX director node with some extra options to help you create videos with references sheets
|
||||
|
||||
This node uses Ollama Locally : you will need to install it (very lightweight) and
|
||||
|
||||
- In your Ollama model folder run this command to install qwen 3.5 2b q4 (1.9gb) "ollama run huihui_ai/qwen3.5-abliterated:2B"
|
||||
|
||||
- The model won't eat memory while generating since there is an auto clear VRAM when you hit Run or after 5min.
|
||||
|
||||
- You can still enter your description manually if you don't want to install it but it works very well!
|
||||
|
||||
- Both references mode work (fixed), Licon MSR and Ghost Mask.
|
||||
|
||||
- Use their Lora, that is an important step : https://huggingface.co/LiconStudio/LTX-2.3-Multiple-Subject-Reference/tree/main
|
||||
|
||||
- Enjoy!
|
||||
|
||||
<img width="596" height="668" alt="Capture d'écran 2026-06-13 234036" src="https://github.com/user-attachments/assets/2dfe7992-a72a-4eda-a103-41b3a8f77714" />
|
||||
|
||||
|
||||
# 🔄 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**
|
||||
* **LTX Director Hotfix 2**
|
||||
|
||||
@@ -8,6 +8,7 @@ from .ltx_director import LTXDirector
|
||||
from .ltx_director_guide import LTXDirectorGuide
|
||||
from comfy_api.latest import ComfyExtension, io
|
||||
from typing_extensions import override
|
||||
from .latent_slice import CleanLatentSlice
|
||||
|
||||
class PromptRelay(ComfyExtension):
|
||||
@override
|
||||
@@ -29,6 +30,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"LoadVideoUI": LoadVideoUI,
|
||||
"LTXDirector": LTXDirector,
|
||||
"LTXDirectorGuide": LTXDirectorGuide,
|
||||
"CleanLatentSlice": CleanLatentSlice,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -40,6 +42,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"LoadVideoUI": "Load Video UI",
|
||||
"LTXDirector": "LTX Director",
|
||||
"LTXDirectorGuide": "LTX Director Guide",
|
||||
"CleanLatentSlice": "Clean Latent Slice",
|
||||
}
|
||||
|
||||
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 MSR_Ghost_V5.json
Normal file
1
example_workflows/LTX Director MSR_Ghost_V5.json
Normal file
File diff suppressed because one or more lines are too long
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
example_workflows/LTX Director Workflow_v4.json
Normal file
1
example_workflows/LTX Director Workflow_v4.json
Normal file
File diff suppressed because one or more lines are too long
1452
js/ltx_director.js
1452
js/ltx_director.js
File diff suppressed because it is too large
Load Diff
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']
|
||||
1187
ltx_director.py
1187
ltx_director.py
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ class LTXDirectorGuide(LTXVAddGuide):
|
||||
GuideData.Input("guide_data", tooltip="Guide data produced by Prompt Relay Encode (Timeline)."),
|
||||
io.Float.Input("scale_by", default=1.0, min=0.01, max=8.0, step=0.01, tooltip="Scale the latent by this factor."),
|
||||
io.Combo.Input("upscale_method", options=["nearest-exact", "bilinear", "area", "bicubic", "bislerp"], default="bicubic", tooltip="Method used to upscale/downscale the latent."),
|
||||
io.Float.Input("msr_strength", default=0.0, min=0.0, max=1.0, step=0.05, tooltip="Licon MSR only: per-stage reference strength override. 0 = use the Director's value (full pull, right for stage 1). On a refinement/upscale stage set ~0.4 to hold detail without the references repainting the opening (fixes stage-2 mist/ghosting)."),
|
||||
],
|
||||
outputs=[
|
||||
io.Conditioning.Output(display_name="positive"),
|
||||
@@ -33,7 +34,7 @@ class LTXDirectorGuide(LTXVAddGuide):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, positive, negative, vae, latent, guide_data, scale_by=1.0, upscale_method="bicubic") -> io.NodeOutput:
|
||||
def execute(cls, positive, negative, vae, latent, guide_data, scale_by=1.0, upscale_method="bicubic", msr_strength=0.0) -> io.NodeOutput:
|
||||
scale_factors = vae.downscale_index_formula
|
||||
|
||||
# Clone latents to avoid mutating upstream nodes
|
||||
@@ -68,6 +69,12 @@ class LTXDirectorGuide(LTXVAddGuide):
|
||||
|
||||
_, _, latent_length, latent_height, latent_width = latent_image.shape
|
||||
|
||||
# MSR mode: the Director handed us the raw references; inject them at THIS node's
|
||||
# resolution (so a half-res Stage 1 and a full-res Stage 2 each stay self-consistent).
|
||||
msr = guide_data.get("msr")
|
||||
if msr is not None:
|
||||
return cls._inject_msr(positive, negative, vae, latent_image, noise_mask, msr, msr_strength)
|
||||
|
||||
images = guide_data.get("images", [])
|
||||
insert_frames = guide_data.get("insert_frames", [])
|
||||
strengths = guide_data.get("strengths", [])
|
||||
@@ -88,3 +95,60 @@ class LTXDirectorGuide(LTXVAddGuide):
|
||||
)
|
||||
|
||||
return io.NodeOutput(positive, negative, {"samples": latent_image, "noise_mask": noise_mask})
|
||||
|
||||
@classmethod
|
||||
def _inject_msr(cls, positive, negative, vae, latent_image, noise_mask, msr, msr_strength=0.0):
|
||||
"""Inject the Licon MSR references at this node's current resolution."""
|
||||
from nodes import NODE_CLASS_MAPPINGS
|
||||
from .ltx_director import _execute_comfy_node, _unpack
|
||||
|
||||
IC = NODE_CLASS_MAPPINGS.get("LTXAddVideoICLoRAGuide")
|
||||
AG = NODE_CLASS_MAPPINGS.get("LTXVAddGuide")
|
||||
if IC is None or AG is None:
|
||||
raise ValueError("MSR mode needs LTXAddVideoICLoRAGuide (ComfyUI-LTXVideo) and LTXVAddGuide (core LTXV nodes).")
|
||||
|
||||
def clamp01(v):
|
||||
return max(0.0, min(1.0, float(v)))
|
||||
|
||||
slideshow = msr["slideshow"]
|
||||
keyframes = msr["keyframes"]
|
||||
prefix_latents = int(msr["prefix_latents"])
|
||||
# Per-stage override: msr_strength > 0 wins (set it low on a refinement stage so the
|
||||
# references hold detail without repainting the opening); 0 = use the Director's value.
|
||||
strength = clamp01(msr_strength) if float(msr_strength) > 0.0 else clamp01(msr["strength"])
|
||||
downscale = float(msr["downscale"])
|
||||
|
||||
# Pad the generated region so (pad + keyframes) == prefix_latents, so the downstream crop
|
||||
# (which trims the slideshow's temporal footprint) lands on the true clean length.
|
||||
pad_latents = max(0, prefix_latents - len(keyframes))
|
||||
if pad_latents > 0:
|
||||
B, C, F, H, W = latent_image.shape
|
||||
latent_image = torch.cat(
|
||||
[latent_image, torch.zeros((B, C, pad_latents, H, W), dtype=latent_image.dtype, device=latent_image.device)],
|
||||
dim=2,
|
||||
)
|
||||
mb, mc, mf, mh, mw = noise_mask.shape
|
||||
noise_mask = torch.cat(
|
||||
[noise_mask, torch.ones((mb, mc, pad_latents, mh, mw), dtype=noise_mask.dtype, device=noise_mask.device)],
|
||||
dim=2,
|
||||
)
|
||||
|
||||
latent = {"samples": latent_image, "noise_mask": noise_mask}
|
||||
|
||||
# CONDITIONING PATH: slideshow -> IC-LoRA -> keep conditioning, discard latent.
|
||||
cp, cn, _ = _unpack(_execute_comfy_node(
|
||||
IC, positive=positive, negative=negative, vae=vae, latent=latent, image=slideshow,
|
||||
frame_idx=0, strength=strength, latent_downscale_factor=downscale,
|
||||
crop="center", use_tiled_encode=False, tile_size=256, tile_overlap=64,
|
||||
))
|
||||
|
||||
positive, negative = cp, cn
|
||||
|
||||
# LATENT PATH: one frozen keyframe per reference at frame 0 -> keep latent, drop cond.
|
||||
for kf in keyframes:
|
||||
_p, _n, latent = _unpack(_execute_comfy_node(
|
||||
AG, positive=positive, negative=negative, vae=vae, latent=latent,
|
||||
image=kf, frame_idx=0, strength=strength,
|
||||
))
|
||||
|
||||
return io.NodeOutput(positive, negative, latent)
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from comfy_extras.nodes_lt import get_noise_mask, LTXVAddGuide
|
||||
import torch
|
||||
import comfy.utils
|
||||
@@ -55,12 +56,14 @@ class LTXSequencer(LTXVAddGuide):
|
||||
node_id="LTXSequencer",
|
||||
display_name="LTX Sequencer",
|
||||
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,
|
||||
outputs=[
|
||||
io.Conditioning.Output(display_name="positive"),
|
||||
io.Conditioning.Output(display_name="negative"),
|
||||
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")
|
||||
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
|
||||
for i in range(1, num_images + 1):
|
||||
# Skip if this image index exceeds the batch
|
||||
if i > batch_size:
|
||||
sync_log_lines.append(f"Image #{i}: Skipped (No image loaded in batch)")
|
||||
continue
|
||||
|
||||
img = multi_input[i-1:i] # Extract the single image frame from the batch
|
||||
if img is None:
|
||||
continue
|
||||
|
||||
# Calculate the final frame index based on the chosen mode
|
||||
f_idx = None
|
||||
if insert_mode == "frames":
|
||||
f_idx = kwargs.get(f"insert_frame_{i}")
|
||||
elif insert_mode == "seconds":
|
||||
sec = kwargs.get(f"insert_second_{i}")
|
||||
if sec is not None:
|
||||
f_idx = int(sec * frame_rate)
|
||||
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":
|
||||
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:
|
||||
continue
|
||||
|
||||
strength = kwargs.get(f"strength_{i}", 1.0)
|
||||
|
||||
# Execution logic mirrored from LTXVAddGuideMulti
|
||||
image_1, t = cls.encode(vae, latent_width, latent_height, img, scale_factors)
|
||||
|
||||
@@ -130,4 +167,8 @@ class LTXSequencer(LTXVAddGuide):
|
||||
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