Director 2CS v0.21

"WhatDreamsCost" Director node modded for Ref sheets
This commit is contained in:
CGlide
2026-06-27 14:21:50 +02:00
committed by GitHub
parent 21dd076689
commit 722777cc13
25 changed files with 391682 additions and 2338 deletions

182
README.md
View File

@@ -1,48 +1,25 @@
# Overview # Overview
A Complete Timeline Editor For LTX 2.3. This is the sucessor of my previous nodes, and has loads of features in it. It was originally based off of [Kijai's Prompt Relay node](https://github.com/kijai/ComfyUI-PromptRelay) and LTX Sequencer/Multi Image Loader nodes.
This will be a collection of free resources for ComfyUI. This will be a collection of free resources for ComfyUI.
Hopefully it will make creating cool stuff easier. Hopefully it will make creating cool stuff easier!
All of my nodes are created with the help of AI, so there may or may not be redundant, messy code. Also if you want to support this project or my channel I did make a Ko-Fi due to popular demand lol (anything helps!)
**❗❗IMPORTANT❗❗** [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/J5N221K0D5)
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!
## ▶️ YouTube Tutorial Videos ## ▶️ YouTube Tutorial Videos
<a href="https://youtu.be/j28z5PZXkKk?si=TPeWKm2BW-uA3BAx">
<img width="642" height="718" alt="Capture d&#39;écran 2026-06-17 234136" src="https://github.com/user-attachments/assets/89433110-8b78-4971-8098-3a9e9226f460" />
<table> <table>
<tr> <tr>
<td> <td>
<p align="center">LTX Director Trailer</p> <p align="center">LTX Director 2.0 Trailer</p>
<a href="https://www.youtube.com/watch?v=fZgtkRcu4_k"> <a href="https://www.youtube.com/watch?v=o0l6Ikvn5Q0">
<img src="https://img.youtube.com/vi/fZgtkRcu4_k/0.jpg" alt="LTX Director Trailer" width="400"> <img src="https://img.youtube.com/vi/o0l6Ikvn5Q0/0.jpg" alt="LTX Director Trailer" width="400">
</a> </a>
</td> </td>
<td> <td>
<p align="center">LTX Director Tutorial</p> <p align="center">LTX Director 1.0 Tutorial</p>
<a href="https://www.youtube.com/watch?v=vM60pJJqqEI"> <a href="https://www.youtube.com/watch?v=vM60pJJqqEI">
<img src="https://img.youtube.com/vi/vM60pJJqqEI/0.jpg" alt="LTX Director Tutorial" width="400"> <img src="https://img.youtube.com/vi/vM60pJJqqEI/0.jpg" alt="LTX Director Tutorial" width="400">
</a> </a>
@@ -53,17 +30,39 @@ This node uses Ollama Locally : you will need to install it (very lightweight) a
## ❓ How to install nodes ## ❓ How to install nodes
- Navigate to your `/ComfyUI/custom_nodes/ folder` - Navigate to your `/ComfyUI/custom_nodes/ folder`
- Run `git clone https://github.com/WhatDreamscost/WhatDreamsCost-ComfyUI`
- Or download through the ComfyUI Manager.
- Run `git clone -b main_cs https://github.com/CGlideWhatDreamsCost-CSGlide.git` **❗❗IMPORTANT❗❗**
<img width="596" height="668" alt="Capture d&#39;écran 2026-06-13 234036" src="https://github.com/user-attachments/assets/2dfe7992-a72a-4eda-a103-41b3a8f77714" />
If you don't see the latest version (v1.3.9) yet in the manager then just download 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!
# 🔄 Recent Updates # 🔄 Recent Updates
**v2.0.0**
* **Massive Update to LTX Director. I will add the full list of changes later.**
- **Complete Video Support:** Edit Videos with AI all inside the node. Videos can be extended using a combination of prompts, keyframes, and audio. Trim, Split, and combine videos all within the timeline.
- **IC-LoRA Support:** Take full advantage of IC-LoRA's to take your generations to the next level. Simply drag and drop videos onto the IC-LoRA track to quickly setup IC-LoRA videos. Compatible with prompt relay, keyframe, and custom audio features within the node.
- **Audio Inpainting:** Seamlessly blend imported audio with generated audio. Not only can audio be extended, but can also be prompted alongside your imprted audio to really bring your generations to life.
- **Retake Mode (Beta):** Redirect what happens within a shot. Allows you to select a segment within a video, and re-generate what happens in that segment. An early working experiment.
- **Timeline Saving/Loading:** You can now save your timeline and settings to a json file. It will keep any videos/audio/images you have imported into the node and every setting you have changed.
- **UI Overhaul:** Huge update to the UI, dozens of big changes such as a new side bar, redesigned prompt boxes, a bunch of new settings and redesigned menus, and more.
- **Quality of Life Improvements:** Snapping, in/out points, multi-select, mark selection, workspace folder, more HUD options, resizable prompt boxes, new hotkeys, labels, filename preview options, "split at playhead" functionality, end frames (convert any keyframe into a end/last frame), toggleable tracks, NAG Support, tons of bug fixes and more!
**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**
- Fixed duration_seconds input issue. - Fixed duration_seconds input issue.
@@ -83,6 +82,10 @@ If anyone finds anymore bugs or has idea for improvements please let me know!
* **LTX Director Example Workflow Fix** * **LTX Director Example Workflow Fix**
- Minor fix to the example workflow (i forgot to set the clip loader type to ltxv lol) - Minor fix to the example workflow (i forgot to set the clip loader type to ltxv lol)
<details>
<summary>Click to view older Updates</summary>
**v1.3.0** **v1.3.0**
* **New nodes: LTX Director and LTX Director Guide** * **New nodes: LTX Director and LTX Director Guide**
- A complete timeline editor that can do almost everything. It's my most ambitious node so far and the successor to LTX Sequencer/Multi Image Loader. - A complete timeline editor that can do almost everything. It's my most ambitious node so far and the successor to LTX Sequencer/Multi Image Loader.
@@ -98,9 +101,6 @@ If anyone finds anymore bugs or has idea for improvements please let me know!
Also added a text output to the Speech Length Calculator node (can't believe i didn't do this initially) Also added a text output to the Speech Length Calculator node (can't believe i didn't do this initially)
<details>
<summary>Click to view older Updates</summary>
**v1.2.8** **v1.2.8**
* **Updated Load Video UI and Color Conversion** * **Updated Load Video UI and Color Conversion**
* Added crop mode, a simple interface to crop videos. It also include various aspect ratio presets. * Added crop mode, a simple interface to crop videos. It also include various aspect ratio presets.
@@ -154,10 +154,10 @@ Overhaul of the load audio node. Features a simple interface to easily trim audi
# ⚙️ Custom Nodes # ⚙️ Custom Nodes
## LTX Director ## LTX Director 2.0
<img width="1481" height="833" alt="Clipboard Image (2)" src="https://github.com/user-attachments/assets/08f3fe53-9393-4f5d-9de5-58b229fbed47" /> <img width="1562" height="870" alt="LTX_Director_Wide" src="https://github.com/user-attachments/assets/e2f9edec-c492-443e-84de-0ad1c0db04b3" />
A Complete Timeline Editor For LTX 2.3. This is the sucessor of my previous nodes, and has loads of features in it. It was originally based off of [Kijai's Prompt Relay node](https://github.com/kijai/ComfyUI-PromptRelay) and my LTX Sequencer/Multi Image Loader nodes.
**Main Features:** **Main Features:**
- **Fully Functional Timeline Editor:** I spent hours studying various video editors and ended up with this design. If anyone has ideas for improvements let me know! I will adding documentation on all the functions soon. - **Fully Functional Timeline Editor:** I spent hours studying various video editors and ended up with this design. If anyone has ideas for improvements let me know! I will adding documentation on all the functions soon.
@@ -167,11 +167,107 @@ Overhaul of the load audio node. Features a simple interface to easily trim audi
- **Image to Video:** Part of the goal of this node was to make it easier to do everything, including Image to Video. It has built in resize functionality, and of course all the benifits of the prompt relay and custom audio integration. - **Image to Video:** Part of the goal of this node was to make it easier to do everything, including Image to Video. It has built in resize functionality, and of course all the benifits of the prompt relay and custom audio integration.
- **Text to Video:** Use text segments to create T2V videos. Compatible with all other features of the node. - **Text to Video:** Use text segments to create T2V videos. Compatible with all other features of the node.
**LTX Director 2.0 Update Main Features**
- **Complete Video Support:** Edit Videos with AI all inside the node. Videos can be extended using a combination of prompts, keyframes, and audio. Trim, Split, and combine videos all within the timeline.
- **IC-LoRA Support:** Take full advantage of IC-LoRA's to take your generations to the next level. Simply drag and drop videos onto the IC-LoRA track to quickly setup IC-LoRA videos. Compatible with prompt relay, keyframe, and custom audio features within the node.
Special Thanks to https://nghtdrp.com for vibe coding the inital implementation of IC-LoRA support.
- **Audio Inpainting:** Seamlessly blend imported audio with generated audio. Not only can audio be extended, but can also be prompted alongside your imprted audio to really bring your generations to life.
- **Retake Mode (Beta):** Redirect what happens within a shot. Allows you to select a segment within a video, and re-generate what happens in that segment. An early working experiment.
- **Timeline Saving/Loading:** You can now save your timeline and settings to a json file. It will keep any videos/audio/images you have imported into the node and every setting you have changed.
- **UI Overhaul:** Huge update to the UI, dozens of big changes such as a new side bar, redesigned prompt boxes, a bunch of new settings and redesigned menus, and more.
- **Quality of Life Improvements:** Snapping, in/out points, multi-select, mark selection, workspace folder, more HUD options, resizable prompt boxes, new hotkeys, labels, filename preview options, "split at playhead" functionality, end frames (convert any keyframe into a end/last frame), toggleable tracks, NAG Support, tons of bug fixes and more!
Download workflows here: https://github.com/WhatDreamsCost/WhatDreamsCost-ComfyUI/tree/main/example_workflows Download workflows here: https://github.com/WhatDreamsCost/WhatDreamsCost-ComfyUI/tree/main/example_workflows
**Tutorial videos and documentation coming soon** **Tutorial videos and documentation coming soon**
## Multi Image Loader
<img width="1280" height="720" alt="Multi_Image_Loader_Wide_Gif" src="https://github.com/user-attachments/assets/99b6afd8-5197-4e6c-81da-a7bd156c42c7" />
An Image loader that features a built in gallery, allowing your to easily rearrange images and output them seperately or batched together. It also combines the image resize node and LTXVPreprocess node to reduce clutter in LTX workflows.
## LTX Sequencer
![LTX_Sequencer_GIF](https://github.com/user-attachments/assets/88f27155-f50e-4cb2-b937-ab173e6bdf0b)
An overhaul of the LTXVAddGuideMulti node. It allows you to quickly create FFLF (First Frame Last Frame) videos, shot sequences, supports any number of middle frames.
Connect the Multi Image Loader node's multi_output to automatically update the node's widgets.
It also has a sync feature that syncs all LTX Sequencer nodes together in realtime, removing the need to edit every single node manually every time you want to make a change to something.
For those asking, I coded all of this myself (LOL). ## LTX Keyframer
<img width="1082" height="608" alt="LTX Keyframer Wide" src="https://github.com/user-attachments/assets/850ba4a2-dbca-4e5a-a580-1c271e9f0c41" />
An overhaul of the LTXVImgToVideoInplaceKJ node. It allows you to quickly create FFLF (First Frame Last Frame) videos and shot sequences. Also upports any number of middle frames.
Connect the Multi Image Loader node's multi_output to automatically update the node's widgets.
It also has a sync feature that syncs all LTX Keyframer nodes together in realtime, removing the need to edit every single node manually every time you want to make a change to something.
**I would recommend using the LTX Sequencer Node over this node, after further testing it seems superior in at pretty much everything. I'll leave it in just in case more people want to test it**
## Speech Length Calculator
<img width="1280" height="720" alt="Speech Length Calculator v2 Gif" src="https://github.com/user-attachments/assets/04b9a1cf-20e4-4b7b-a9c6-4a5a0825995b" />
<br>
<br>
This node calculates in realtime how long a video should be based on the dialogue. Any words in quotations will be considered as speech. The node updates in realtime without having to run the workflow, and outputs the length depending on how fast the speech is.
If you connect another string/text node to the text_input, it will still update in the length in realtime.
I kept having to play the guessing game on my own generations so I made this node to make it easier :man_shrugging:
## Load Video UI
<table width="100%">
<tr>
<td width="50%" align="center">
<p>Simple Controls</p>
<img src="https://github.com/user-attachments/assets/fb76ff03-a6ff-4837-bd63-7e429f5f3d37" width="100%" />
</td>
<td width="50%" align="center">
<p>New Crop Mode!</p>
<img src="https://github.com/user-attachments/assets/28cfb4ca-e42a-44da-9afb-f20cb01b9722" width="100%" />
</td>
</tr>
</table>
<br>
<br>
An upgraded Load Video node. It has the following features:
* Simple interface to quickly trim videos and preview them in realtime.
* Ability to load any length of video into the node (the default load video node was limited to 100MB files)
* Easily switch between showing seconds and frames with a toggle button. This will change the widgets as well as the interface.
* Multiple options for resizing the video (maintain aspect ratio, crop, stretch to fit, pad)
* Allows dragging and dropping files into the node
* Progress bar
* Optimized to use less RAM (still very limited due to ComfyUI limitations, but at least a little more efficient)
Please note that due to ComfyUI limitations (and the fact that this node doesn't use any addtional libraries), this node will not work well for outputting large videos. You can trim any length of video without a problem, but if the output is still large it will end up using a lot of RAM. I have implemented various optimizations though to make it use less memory.
## Load Audio UI
<img width="1280" height="720" alt="Load_Audio_UI_V2" src="https://github.com/user-attachments/assets/e3dc5c8d-d0b9-4336-8196-944204719239" />
<br>
<br>
An upgraded Load Audio node. Features a simple interface to easily trim audio. Also allows dragging and dropping files (fixes the original node that doesn't allow dropping in videos). Also compatible with nodes 2.0.
# 💡 Workflows
Download workflows here: https://github.com/WhatDreamsCost/WhatDreamsCost-ComfyUI/tree/main/example_workflows
# ❗ Known Issues
Fixed everything so far. If there are any other issue or bugs you find please let me know!
# 💡 Additional Info
Feel free to suggest improvements, and if you run into any bugs let me know!
For those asking, I mainly used gemini to create these nodes.

View File

@@ -1,5 +1,5 @@
from .ltx_director import LTXDirector from .ltx_director import LTXDirector
from .ltx_director_guide import LTXDirectorGuide from .ltx_director_guide import LTXDirectorGuide, LTXDirectorCropGuides
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 from .latent_slice import CleanLatentSlice
@@ -9,7 +9,6 @@ class PromptRelay(ComfyExtension):
async def get_node_list(self) -> list[type[io.ComfyNode]]: async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [ return [
LTXDirector, LTXDirector,
LTXDirectorGuide
] ]
async def comfy_entrypoint() -> PromptRelay: async def comfy_entrypoint() -> PromptRelay:
@@ -18,12 +17,14 @@ async def comfy_entrypoint() -> PromptRelay:
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"LTXDirectorCS": LTXDirector, "LTXDirectorCS": LTXDirector,
"LTXDirectorGuideCS": LTXDirectorGuide, "LTXDirectorGuideCS": LTXDirectorGuide,
"LTXDirectorCropGuidesCS": LTXDirectorCropGuides,
"CleanLatentSliceCS": CleanLatentSlice, "CleanLatentSliceCS": CleanLatentSlice,
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"LTXDirectorCS": "LTX Director CS", "LTXDirectorCS": "LTX Director CS",
"LTXDirectorGuideCS": "LTX Director Guide CS", "LTXDirectorGuideCS": "LTX Director Guide CS",
"LTXDirectorCropGuidesCS": "LTX Director Crop Guides CS",
"CleanLatentSliceCS": "Clean Latent Slice CS", "CleanLatentSliceCS": "Clean Latent Slice CS",
} }

Binary file not shown.

Binary file not shown.

379053
debug_prompt_relay.log Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,24 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
// LTX Director Guide CS is a pure pass-through processor node. // LTX Director Guide is a pure pass-through processor node.
// All configuration (images, insert frames, strengths) comes from // All configuration (images, insert frames, strengths) comes from
// the guide_data output of the LTX Director CS (Timeline) node. // the guide_data output of Prompt Relay Encode (Timeline).
app.registerExtension({ app.registerExtension({
name: "Comfy.LTXDirectorGuideCS", name: "Comfy.LTXDirectorGuideCS",
async nodeCreated(node) { async nodeCreated(node) {
if (node.comfyClass !== "LTXDirectorGuideCS") return; if (node.comfyClass !== "LTXDirectorGuideCS") return;
// Nothing to initialize — the node has no configurable widgets.
// Hide retake_mode widget on LiteGraph as it is dynamically auto-detected from the timeline data.
const w = node.widgets?.find(x => x.name === "retake_mode");
if (w) {
w.hidden = true;
if (!w.options) w.options = {};
w.options.hidden = true;
if (!window.LiteGraph || !window.LiteGraph.vueNodesMode) {
w.computeSize = () => [0, -4];
w.draw = () => { };
}
if (w.element) w.element.style.display = "none";
}
}, },
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -1,106 +1,637 @@
from comfy_extras.nodes_lt import LTXVAddGuide import logging
import math
import os
import av
import numpy as np
import torch import torch
import comfy
import comfy.sd
import comfy.utils import comfy.utils
import folder_paths
import node_helpers
from comfy_extras import nodes_lt
from comfy_api.latest import io from comfy_api.latest import io
from .ltx_director import GuideData from .ltx_director import GuideData, MotionGuideData, _resize_image, _execute_comfy_node, _unpack
log = logging.getLogger(__name__)
class LTXDirectorGuide(LTXVAddGuide): # --- Helper Functions from Nghtdrp ---
@classmethod
def define_schema(cls): def _get_guide_attention_entries(conditioning):
return io.Schema( for item in conditioning:
node_id="LTXDirectorGuideCS", entries = item[1].get("guide_attention_entries", None)
display_name="LTX Director Guide CS", if entries is not None:
category="WhatDreamsCost CS", return entries
description=( return []
"Applies guide images from a Prompt Relay Timeline node at the frame positions "
"and strengths defined on the timeline. Connect guide_data from the timeline node." def _set_guide_attention_entries(conditioning, entries):
), return node_helpers.conditioning_set_values(
inputs=[ conditioning, {"guide_attention_entries": entries}
io.Conditioning.Input("positive", tooltip="Positive conditioning to add guide keyframe info to."), )
io.Conditioning.Input("negative", tooltip="Negative conditioning to add guide keyframe info to."),
io.Vae.Input("vae", tooltip="Video VAE used to encode the guide images."), def _append_guide_attention_entry(
io.Latent.Input("latent", tooltip="Video latent — guides are inserted into this latent."), conditioning,
GuideData.Input("guide_data", tooltip="Guide data produced by Prompt Relay Encode (Timeline)."), pre_filter_count,
io.Float.Input("scale_by", default=1.0, min=0.01, max=8.0, step=0.01, tooltip="Scale the latent by this factor."), latent_shape,
io.Combo.Input("upscale_method", options=["nearest-exact", "bilinear", "area", "bicubic", "bislerp"], default="bicubic", tooltip="Method used to upscale/downscale the latent."), attention_strength=1.0,
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)."), attention_mask=None,
], ):
outputs=[ entries = [*_get_guide_attention_entries(conditioning)]
io.Conditioning.Output(display_name="positive"), entries.append(
io.Conditioning.Output(display_name="negative"), {
io.Latent.Output(display_name="latent", tooltip="Video latent with guide frames applied."), "pre_filter_count": int(pre_filter_count),
], "strength": float(attention_strength),
"pixel_mask": attention_mask,
"latent_shape": list(latent_shape),
}
)
return _set_guide_attention_entries(conditioning, entries)
def _clone_noise_mask(latent, latent_image):
if "noise_mask" in latent and latent["noise_mask"] is not None:
return latent["noise_mask"].clone()
batch, _, frames, _, _ = latent_image.shape
return torch.ones(
(batch, 1, frames, 1, 1),
dtype=torch.float32,
device=latent_image.device,
)
def _resize_latent_spatial(latent_image, noise_mask, width, height, method):
b, c, f, h, w = latent_image.shape
if width == w and height == h:
return latent_image, noise_mask
latent_4d = latent_image.permute(0, 2, 1, 3, 4).reshape(b * f, c, h, w)
latent_4d = comfy.utils.common_upscale(latent_4d, width, height, method, "disabled")
latent_image = latent_4d.reshape(b, f, c, height, width).permute(0, 2, 1, 3, 4)
if noise_mask is not None and (noise_mask.shape[-1] > 1 or noise_mask.shape[-2] > 1):
mb, mc, mf, mh, mw = noise_mask.shape
mask_4d = noise_mask.permute(0, 2, 1, 3, 4).reshape(mb * mf, mc, mh, mw)
mask_4d = comfy.utils.common_upscale(mask_4d, width, height, method, "disabled")
noise_mask = mask_4d.reshape(mb, mf, mc, height, width).permute(0, 2, 1, 3, 4)
return latent_image, noise_mask
def _ceil_to_multiple(value, multiple):
multiple = max(1, int(multiple))
return int(math.ceil(value / multiple) * multiple)
def _snap_latent_to_downscale(latent_image, noise_mask, downscale_factor, method):
factor = int(max(1, round(float(downscale_factor))))
if factor <= 1:
return latent_image, noise_mask
_, _, _, h, w = latent_image.shape
new_w = _ceil_to_multiple(w, factor)
new_h = _ceil_to_multiple(h, factor)
if new_w == w and new_h == h:
return latent_image, noise_mask
log.warning(
"[LTXDirectorGuide] Auto-snapping latent grid from %sx%s to %sx%s for IC-LoRA downscale factor %s.",
w, h, new_w, new_h, factor,
)
return _resize_latent_spatial(latent_image, noise_mask, new_w, new_h, method)
def _load_lora_model_only(model, ic_lora_name, strength_model):
lora_path = folder_paths.get_full_path_or_raise("loras", ic_lora_name)
lora, metadata = comfy.utils.load_torch_file(
lora_path, safe_load=True, return_metadata=True,
)
try:
latent_downscale_factor = float(metadata["reference_downscale_factor"])
except Exception:
latent_downscale_factor = 1.0
log.warning(
"[LTXDirectorGuide] Could not read reference_downscale_factor from %s, using 1.0.",
ic_lora_name,
) )
@classmethod if strength_model == 0:
def execute(cls, positive, negative, vae, latent, guide_data, scale_by=1.0, upscale_method="bicubic", msr_strength=0.0) -> io.NodeOutput: return model, latent_downscale_factor
scale_factors = vae.downscale_index_formula
# Clone latents to avoid mutating upstream nodes model_lora, _ = comfy.sd.load_lora_for_models(
latent_image = latent["samples"].clone() model, None, lora, strength_model, 0,
)
return model_lora, latent_downscale_factor
if "noise_mask" in latent: def _encode_video_iclora_guide(vae, latent_width, latent_height, images, scale_factors, latent_downscale_factor, crop, use_tiled_encode, tile_size, tile_overlap, resize_method="crop"):
noise_mask = latent["noise_mask"].clone() time_scale_factor, width_scale_factor, height_scale_factor = scale_factors
num_frames_to_keep = ((images.shape[0] - 1) // time_scale_factor) * time_scale_factor + 1
images = images[:num_frames_to_keep]
target_width = int(latent_width * width_scale_factor / latent_downscale_factor)
target_height = int(latent_height * height_scale_factor / latent_downscale_factor)
target_width = max(8, target_width)
target_height = max(8, target_height)
# For guides, we must match the exact target latent shape.
# "maintain aspect ratio" doesn't pad, which causes shape mismatch in VAE encode / concatenation.
# So we fallback "maintain aspect ratio" to "pad".
if resize_method == "maintain aspect ratio":
resize_method = "pad"
pixels = _resize_image(images, target_width, target_height, resize_method, divisible_by=1)
encode_pixels = pixels[:, :, :, :3]
if use_tiled_encode:
guide_latent = vae.encode_tiled(encode_pixels, tile_x=tile_size, tile_y=tile_size, overlap=tile_overlap)
else:
guide_latent = vae.encode(encode_pixels)
return pixels, guide_latent
def _dilate_latent(latent: dict, horizontal_scale: int, vertical_scale: int) -> dict:
if horizontal_scale == 1 and vertical_scale == 1:
return latent
samples = latent["samples"]
mask = latent.get("noise_mask", None)
dilated_shape = samples.shape[:3] + (
samples.shape[3] * vertical_scale,
samples.shape[4] * horizontal_scale,
)
dilated_samples = torch.zeros(dilated_shape, device=samples.device, dtype=samples.dtype, requires_grad=False)
dilated_samples[..., ::vertical_scale, ::horizontal_scale] = samples
dilated_mask_shape = (
dilated_samples.shape[0], 1, dilated_samples.shape[2],
dilated_samples.shape[3], dilated_samples.shape[4],
)
dilated_mask = torch.full(dilated_mask_shape, -1.0, device=samples.device, dtype=samples.dtype, requires_grad=False)
dilated_mask[..., ::vertical_scale, ::horizontal_scale] = (mask if mask is not None else 1.0)
return {"samples": dilated_samples, "noise_mask": dilated_mask}
def _resolve_input_video_path(video_file):
if os.path.isabs(str(video_file)) and os.path.exists(str(video_file)):
return str(video_file)
input_dir = folder_paths.get_input_directory()
candidate = os.path.join(input_dir, str(video_file))
if os.path.exists(candidate):
return candidate
try:
annotated = folder_paths.get_annotated_filepath(str(video_file))
if annotated and os.path.exists(annotated):
return annotated
except Exception:
pass
raise FileNotFoundError(f"Could not find motion guide video: {video_file}")
class ResampleGuideFrames:
def execute(self, images, source_fps, target_fps, target_num_frames, mode):
if images is None: return images
frames = images
n = int(frames.shape[0])
target_num_frames = int(target_num_frames)
if n <= 1:
if target_num_frames > 1 and n == 1:
return frames.repeat(target_num_frames, 1, 1, 1)
return frames
source_fps = float(max(0.001, source_fps))
target_fps = float(max(0.001, target_fps))
if target_num_frames <= 0:
duration = (n - 1) / source_fps
target_num_frames = max(1, int(round(duration * target_fps)) + 1)
if target_num_frames == n and abs(target_fps - source_fps) < 1e-6:
return frames
positions = torch.linspace(0, n - 1, target_num_frames, device=frames.device, dtype=torch.float32)
if mode == "nearest":
idx = torch.round(positions).long().clamp(0, n - 1)
return frames.index_select(0, idx)
idx0 = torch.floor(positions).long().clamp(0, n - 1)
idx1 = torch.ceil(positions).long().clamp(0, n - 1)
alpha = (positions - idx0.to(positions.dtype)).view(-1, 1, 1, 1)
f0 = frames.index_select(0, idx0).to(torch.float32)
f1 = frames.index_select(0, idx1).to(torch.float32)
return (f0 * (1.0 - alpha) + f1 * alpha).to(frames.dtype)
def _load_motion_video_frames(video_file, trim_start_frames, length_frames, director_fps, resample_mode="nearest"):
path = _resolve_input_video_path(video_file)
target_fps = max(1.0, float(director_fps))
start_s = max(0.0, float(trim_start_frames) / target_fps)
dur_s = max(0.0, float(length_frames) / target_fps)
end_s = start_s + dur_s if dur_s > 0 else None
log.info(f"[LTXDirectorGuide] Loading video frames from {path}. start_s: {start_s:.3f}, dur_s: {dur_s:.3f}, end_s: {end_s}")
container = av.open(path)
stream = container.streams.video[0]
stream.thread_type = "AUTO" # Enable multi-threaded decoding
try:
source_fps = float(stream.average_rate) if stream.average_rate else float(stream.base_rate)
except Exception:
source_fps = target_fps
if source_fps <= 0: source_fps = target_fps
# Seek to keyframe slightly before target start_s to optimize loading speed and memory
if start_s > 0:
try:
if stream.time_base:
seek_pts = int(max(0, start_s - 0.5) / float(stream.time_base))
else:
seek_pts = int(max(0, start_s - 0.5) * av.time_base)
container.seek(seek_pts, stream=stream, backward=True)
log.info(f"[LTXDirectorGuide] PyAV seeked stream to pts={seek_pts} (approx {max(0, start_s - 0.5):.3f}s)")
except Exception as seek_err:
log.warning(f"[LTXDirectorGuide] Seek failed: {seek_err}, decoding from beginning.")
frames = []
decoded_count = 0
for frame in container.decode(stream):
if frame.time is not None:
t = float(frame.time)
elif frame.pts is not None and stream.time_base is not None:
t = float(frame.pts * stream.time_base)
else: else:
batch, _, latent_frames, latent_height, latent_width = latent_image.shape t = float(decoded_count / source_fps)
noise_mask = torch.ones( decoded_count += 1
(batch, 1, latent_frames, 1, 1),
dtype=torch.float32, if t < start_s - 0.01: continue
device=latent_image.device, if end_s is not None and t >= end_s: break
)
# Append raw uint8 numpy arrays to minimize CPU allocation overhead
frames.append(frame.to_ndarray(format="rgb24"))
container.close()
if not frames: raise ValueError(f"No frames decoded for motion guide segment: {video_file}")
# Convert all frames to float32 at once to optimize memory allocation
frames_np = np.array(frames, dtype=np.float32) / 255.0
images = torch.from_numpy(frames_np)
target_count = max(1, int(round(float(length_frames))))
images = ResampleGuideFrames().execute(images, source_fps, target_fps, target_count, resample_mode)
return images
# --- Main Class ---
class LTXDirectorGuide:
@classmethod
def INPUT_TYPES(cls):
loras = folder_paths.get_filename_list("loras")
if not loras: loras = ["put_ic_lora_in_ComfyUI_models_loras"]
return {
"required": {
"positive": ("CONDITIONING", {"tooltip": "Positive conditioning to add guide keyframe info to."}),
"negative": ("CONDITIONING", {"tooltip": "Negative conditioning to add guide keyframe info to."}),
"vae": ("VAE", {"tooltip": "Video VAE used to encode the guide images."}),
"latent": ("LATENT", {"tooltip": "Video latent — guides are inserted into this latent."}),
"guide_data": ("GUIDE_DATA", {"tooltip": "Guide data produced by Prompt Relay Encode (Timeline)."}),
},
"optional": {
"motion_guide_data": ("MOTION_GUIDE_DATA", {"tooltip": "Connect motion guide data from the timeline node to use IC-LoRA video guidance."}),
"model": ("MODEL", {"tooltip": "Connect model if using IC-LoRA for motion guidance."}),
"ic_lora_name": (["None"] + loras, {"default": "None", "tooltip": "Select the IC-LoRA model to use for motion guidance."}),
"ic_lora_strength": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"scale_by": ("FLOAT", {"default": 0.5, "min": 0.01, "max": 8.0, "step": 0.01, "tooltip": "Scale the latent by this factor. 0.5 is recommended for IC-LoRA."}),
"upscale_method": (["nearest-exact", "bilinear", "area", "bicubic", "bislerp"], {"default": "bicubic", "tooltip": "Method used to upscale/downscale the latent."}),
"image_attention_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
"crop": (["disabled", "center"], {"default": "center"}),
"auto_snap_ic_grid": ("BOOLEAN", {"default": True}),
"use_tiled_encode": ("BOOLEAN", {"default": False}),
"tile_size": ("INT", {"default": 256, "min": 64, "max": 512, "step": 32}),
"tile_overlap": ("INT", {"default": 64, "min": 16, "max": 256, "step": 16}),
"retake_mode": ("BOOLEAN", {"default": False, "tooltip": "Force Retake Mode. If false, it will still auto-detect Retake Mode from the timeline data."}),
"msr_strength": ("FLOAT", {"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)."}),
}
}
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT", "MODEL", "FLOAT")
RETURN_NAMES = ("positive", "negative", "latent", "model", "latent_downscale_factor")
FUNCTION = "execute"
@classmethod
def execute(cls, positive, negative, vae, latent, guide_data, motion_guide_data=None, model=None, ic_lora_name="None", ic_lora_strength=1.0, scale_by=1.0, upscale_method="bicubic", image_attention_strength=1.0, crop="center", auto_snap_ic_grid=True, use_tiled_encode=False, tile_size=256, tile_overlap=64, retake_mode=False, msr_strength=0.0):
motion_segments = (motion_guide_data or {}).get("segments", []) if motion_guide_data else []
image_guides_count = len(guide_data.get("images", [])) if guide_data else 0
print(f"[LTXDirectorGuide] execute started. motion_segments: {len(motion_segments)}, image_guides: {image_guides_count}, ic_lora_name: {ic_lora_name}, model connected: {model is not None}, retake_mode: {retake_mode}")
active_resize_method = guide_data.get("resize_method") if guide_data else None
if not active_resize_method:
active_resize_method = motion_guide_data.get("resize_method") if motion_guide_data else None
if not active_resize_method:
active_resize_method = "crop" if crop == "center" else "stretch to fit"
latent_downscale_factor = 1.0
if model is not None and ic_lora_name != "None":
model, latent_downscale_factor = _load_lora_model_only(model, ic_lora_name, ic_lora_strength)
scale_factors = vae.downscale_index_formula
latent_image = latent["samples"].clone()
noise_mask = _clone_noise_mask(latent, latent_image)
# Apply scale factor if not 1.0
if scale_by != 1.0: if scale_by != 1.0:
B, C, F, H, W = latent_image.shape _, _, _, h, w = latent_image.shape
width = round(W * scale_by) target_w = max(1, round(w * scale_by))
height = round(H * scale_by) target_h = max(1, round(h * scale_by))
latent_image, noise_mask = _resize_latent_spatial(latent_image, noise_mask, target_w, target_h, upscale_method)
# Reshape to 4D for common_upscale if auto_snap_ic_grid and model is not None and ic_lora_name != "None":
latent_4d = latent_image.permute(0, 2, 1, 3, 4).reshape(B * F, C, H, W) latent_image, noise_mask = _snap_latent_to_downscale(latent_image, noise_mask, latent_downscale_factor, upscale_method)
latent_resized_4d = comfy.utils.common_upscale(latent_4d, width, height, upscale_method, "disabled")
latent_image = latent_resized_4d.reshape(B, F, C, height, width).permute(0, 2, 1, 3, 4)
# Also resize noise mask if it's not a broadcasted mask # Ghost Mask: the Director kept the latent clean (so it stays length-matched with the audio
if noise_mask.shape[-1] > 1 or noise_mask.shape[-2] > 1: # latent) and asked us to pre-extend it here by one hidden frame per reference. The tail
mask_4d = noise_mask.permute(0, 2, 1, 3, 4).reshape(B * F, 1, H, W) # sits past the clean region; the reference images (in guide_data["images"], anchored at
mask_resized_4d = comfy.utils.common_upscale(mask_4d, width, height, upscale_method, "disabled") # (clean + i)) are written into it by the loop below, then dropped downstream by Clean
noise_mask = mask_resized_4d.reshape(B, F, 1, height, width).permute(0, 2, 1, 3, 4) # Latent Slice (length = clean_latent_frames).
_ghost_pre_extend = int(guide_data.get("ghost_pre_extend", 0)) if guide_data else 0
if _ghost_pre_extend > 0:
gb, gc, gf, gh, gw = latent_image.shape
latent_image = torch.cat(
[latent_image, torch.zeros((gb, gc, _ghost_pre_extend, gh, gw), 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, _ghost_pre_extend, mh, mw), dtype=noise_mask.dtype, device=noise_mask.device)],
dim=2,
)
_, _, latent_length, latent_height, latent_width = latent_image.shape _, _, latent_length, latent_height, latent_width = latent_image.shape
initial_latent_length = int(latent_length)
# MSR mode: the Director handed us the raw references; inject them at THIS node's # Licon MSR (Prefix) mode: the Director handed us raw references via guide_data["msr"].
# resolution (so a half-res Stage 1 and a full-res Stage 2 each stay self-consistent). # Inject them at THIS node's resolution (so a half-res Stage 1 and full-res Stage 2 each
msr = guide_data.get("msr") # stay self-consistent), then return early — the standard guide paths don't apply.
msr = guide_data.get("msr") if guide_data else None
if msr is not None: if msr is not None:
return cls._inject_msr(positive, negative, vae, latent_image, noise_mask, msr, msr_strength) return cls._inject_msr(positive, negative, vae, latent_image, noise_mask, msr, model, latent_downscale_factor, msr_strength)
images = guide_data.get("images", []) # Parse timeline JSON to see if retake mode is active in UI
insert_frames = guide_data.get("insert_frames", []) import json
strengths = guide_data.get("strengths", []) timeline_data_str = guide_data.get("timeline_data", "{}") if guide_data else "{}"
try:
tdata = json.loads(timeline_data_str)
except Exception:
tdata = {}
for idx, img_tensor in enumerate(images): is_retake_active = bool(retake_mode) or tdata.get("retakeMode", False)
f_idx = insert_frames[idx] if idx < len(insert_frames) else 0 is_empty_latent = (latent_image.abs().max().item() < 1e-5)
strength = strengths[idx] if idx < len(strengths) else 1.0
image_1, t = cls.encode(vae, latent_width, latent_height, img_tensor, scale_factors) # Director image guides and motion video segments
frame_idx, latent_idx = cls.get_latent_index(positive, latent_length, len(image_1), f_idx, scale_factors) images = guide_data.get("images", []) if guide_data else []
insert_frames = guide_data.get("insert_frames", []) if guide_data else []
strengths = guide_data.get("strengths", []) if guide_data else []
assert latent_idx + t.shape[2] <= latent_length, ( director_fps = float((motion_guide_data or {}).get("frame_rate", guide_data.get("frame_rate", 24) if guide_data else 24))
f"Guide image {idx + 1}: conditioning frames exceed the length of the latent sequence." segments = (motion_guide_data or {}).get("segments", [])
)
positive, negative, latent_image, noise_mask = cls.append_keyframe( is_lora_active = (model is not None) and (ic_lora_name != "None")
positive, negative, frame_idx, latent_image, noise_mask, t, strength, scale_factors, time_scale_factor = scale_factors[0]
) ltxv_length = (latent_length - 1) * time_scale_factor + 1
return io.NodeOutput(positive, negative, {"samples": latent_image, "noise_mask": noise_mask}) # -----------------------------------------------------------------------
# Retake Mode Branch:
# Load single base video, encode continuously, apply temporal mask.
# -----------------------------------------------------------------------
if is_retake_active:
print(f"[LTXDirectorGuide] Retake Mode active. Preserving base latent, masking selected regions. is_empty_latent: {is_empty_latent}")
target_width = latent_width * 32
target_height = latent_height * 32
# Calculate retake region latent indices first so we know what to copy/paste
retake_start = int(tdata.get("retakeStart", 0))
retake_len = int(tdata.get("retakeLength", 0))
retake_strength = float(tdata.get("retakeStrength", 1.0))
start_frame = int(guide_data.get("start_frame", 0))
relative_start = max(0, retake_start - start_frame)
l_start = relative_start // time_scale_factor
l_end = int(math.ceil((relative_start + retake_len) / time_scale_factor))
l_start = min(l_start, latent_length)
l_end = min(l_end, latent_length)
# Stage 2 optimization: If the retake region covers the entire generation area,
# there are no preserved regions to copy over in Stage 2. We can bypass video loading/encoding.
need_base_video = True
if not is_empty_latent and l_start == 0 and l_end >= latent_length:
need_base_video = False
print("[LTXDirectorGuide] Stage 2: Retake region covers the entire generated range. Skipping base video loading and VAE encoding.")
# 1. Try to load and encode base video from timeline data
retake_vid_info = tdata.get("retakeVideo") or {}
video_file = retake_vid_info.get("imageFile", "") if isinstance(retake_vid_info, dict) else ""
# Fallback to first segment only if it exists and we have no retake video info at all (old workflows)
if not video_file and not retake_vid_info and len(segments) > 0:
video_file = segments[0].get("videoFile", "")
if need_base_video:
if not video_file:
if retake_vid_info and not retake_vid_info.get("imageFile"):
raise ValueError(
"Retake Mode is active, but the base video file upload is still in progress (or failed). "
"Please wait for the 'Uploading base video...' overlay on the timeline to disappear before queuing the prompt."
)
else:
raise ValueError(
"Retake Mode is active, but no base video has been selected on the timeline. "
"Please drag and drop or upload a base video on the timeline first."
)
if video_file and need_base_video:
try:
print(f"[LTXDirectorGuide] Loading and encoding base video file: {video_file} starting at frame {start_frame} for length {ltxv_length} at resolution {target_width}x{target_height}")
video_frames = _load_motion_video_frames(
video_file, trim_start_frames=start_frame, length_frames=ltxv_length, director_fps=director_fps, resample_mode="nearest"
)
# Retake base video must match the exact target latent shape.
# "maintain aspect ratio" doesn't pad, which causes shape mismatch in VAE encode.
# So we fallback "maintain aspect ratio" to "pad" for retake base video.
retake_resize_method = active_resize_method
if retake_resize_method == "maintain aspect ratio":
retake_resize_method = "pad"
pixels = _resize_image(video_frames, target_width, target_height, retake_resize_method, divisible_by=1)
num_clip_frames = pixels.shape[0]
num_frames_to_keep = ((num_clip_frames - 1) // time_scale_factor) * time_scale_factor + 1
encode_src = pixels[:num_frames_to_keep, :, :, :3]
if use_tiled_encode:
base_latent = vae.encode_tiled(encode_src, tile_x=tile_size, tile_y=tile_size, overlap=tile_overlap)
else:
base_latent = vae.encode(encode_src)
base_latent = base_latent.to(device=latent_image.device, dtype=latent_image.dtype)
# Copy to latent_image
paste_len = min(base_latent.shape[2], latent_length)
if is_empty_latent:
# Stage 1: Overwrite entire latent with base video VAE encode
latent_image[:, :, :paste_len] = base_latent[:, :, :paste_len]
else:
# Stage 2: Overwrite only preserved regions (before l_start and after l_end)
# leaving the generated retake region from Stage 1 untouched
print(f"[LTXDirectorGuide] Stage 2: Copying high-resolution base latent for preserved regions (0-{l_start} and {l_end}-{paste_len})")
if l_start > 0:
latent_image[:, :, :l_start] = base_latent[:, :, :l_start]
if l_end < paste_len:
latent_image[:, :, l_end:paste_len] = base_latent[:, :, l_end:paste_len]
except Exception as e:
print(f"[LTXDirectorGuide] Failed to load/encode base video: {e}. Falling back to input latent.")
# 2. Build the temporal noise mask (0.0 = frozen, 1.0 = regenerate)
noise_mask = torch.zeros_like(noise_mask) # Initialize fully frozen
l_start = min(l_start, latent_length)
l_end = min(l_end, latent_length)
if l_end > l_start:
noise_mask[:, :, l_start:l_end] = retake_strength
print(f"[LTXDirectorGuide] noise_mask slice: {noise_mask[0, 0, :, 0, 0].tolist()}")
# In retake mode, skip normal mode processing entirely and return immediately!
exact_crop_frames = max(0, int(latent_image.shape[2]) - initial_latent_length)
positive = node_helpers.conditioning_set_values(positive, {"nghtdrp_guide_crop_latent_frames": exact_crop_frames})
negative = node_helpers.conditioning_set_values(negative, {"nghtdrp_guide_crop_latent_frames": exact_crop_frames})
return (positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, model, float(latent_downscale_factor))
# -----------------------------------------------------------------------
# Standard Timeline Keyframe Guidance:
# Appends image segments as keyframes (and motion segments if present)
# to the latent stream using standard LTX-Video cross-attention conditioning.
# Registers guide attention entries if IC-LoRA is active.
# -----------------------------------------------------------------------
if len(images) > 0 or len(segments) > 0:
print(f"[LTXDirectorGuide] Using Appended Keyframe Guidance. is_lora_active: {is_lora_active}")
# A. Process Image Guides
for idx, img_tensor in enumerate(images):
f_idx = insert_frames[idx] if idx < len(insert_frames) else 0
strength = float(strengths[idx] if idx < len(strengths) else 1.0)
if strength <= 0.0:
continue
B_img, H_img, W_img, C_img = img_tensor.shape
target_pix_w = int(latent_width * 32)
target_pix_h = int(latent_height * 32)
if target_pix_w != W_img or target_pix_h != H_img:
img_nchw = img_tensor.permute(0, 3, 1, 2)
img_resized = comfy.utils.common_upscale(img_nchw, target_pix_w, target_pix_h, upscale_method, "disabled")
img_tensor = img_resized.permute(0, 2, 3, 1)
image_pixels, guide_latent = nodes_lt.LTXVAddGuide.encode(vae, latent_width, latent_height, img_tensor, scale_factors)
frame_idx, latent_idx = nodes_lt.LTXVAddGuide.get_latent_index(positive, latent_length, len(image_pixels), int(f_idx), scale_factors)
if latent_idx >= latent_length:
continue
max_frames = latent_length - latent_idx
if guide_latent.shape[2] > max_frames:
guide_latent = guide_latent[:, :, :max_frames]
tokens_added = guide_latent.shape[2] * guide_latent.shape[3] * guide_latent.shape[4]
guide_orig_shape = list(guide_latent.shape[2:])
positive, negative, latent_image, noise_mask = nodes_lt.LTXVAddGuide.append_keyframe(
positive, negative, frame_idx, latent_image, noise_mask, guide_latent, strength, scale_factors
)
if is_lora_active:
positive = _append_guide_attention_entry(positive, tokens_added, guide_orig_shape, attention_strength=image_attention_strength)
negative = _append_guide_attention_entry(negative, tokens_added, guide_orig_shape, attention_strength=image_attention_strength)
# B. Process Motion Video Segments
for seg in segments:
try:
video_file = seg.get("videoFile")
if not video_file:
continue
start_frame = int(seg.get("start", 0))
length_frames = int(seg.get("length", 1))
trim_start = int(seg.get("trimStart", 0))
video_strength = float(seg.get("videoStrength", 1.0))
video_attention_strength = float(seg.get("videoAttentionStrength", 0.65))
if length_frames <= 0 or video_strength <= 0.0:
continue
start_frame_aligned = start_frame
video_frames = _load_motion_video_frames(video_file, trim_start, length_frames, director_fps, seg.get("resampleMode", "nearest"))
num_frames_to_keep = ((video_frames.shape[0] - 1) // time_scale_factor) * time_scale_factor + 1
video_frames = video_frames[:num_frames_to_keep]
causal_fix = int(start_frame_aligned) == 0 or num_frames_to_keep == 1
encode_frames = video_frames if causal_fix else torch.cat([video_frames[:1], video_frames], dim=0)
_, guide_latent = _encode_video_iclora_guide(vae, latent_width, latent_height, encode_frames, scale_factors, latent_downscale_factor, crop, use_tiled_encode, tile_size, tile_overlap, resize_method=active_resize_method)
if not causal_fix:
guide_latent = guide_latent[:, :, 1:, :, :]
frame_idx = start_frame_aligned
latent_idx = (frame_idx + time_scale_factor - 1) // time_scale_factor if frame_idx > 0 else 0
if latent_idx >= latent_length:
continue
if start_frame > 0 and guide_latent.shape[2] > 1:
guide_latent = guide_latent[:, :, 1:, :, :]
frame_idx += time_scale_factor
latent_idx += 1
if latent_idx >= latent_length:
continue
max_frames = latent_length - latent_idx
if guide_latent.shape[2] > max_frames:
guide_latent = guide_latent[:, :, :max_frames]
guide_orig_shape = list(guide_latent.shape[2:])
B_g, C_g, F_g, H_g, W_g = guide_latent.shape
guide_mask = torch.ones((B_g, 1, F_g, H_g, W_g), device=guide_latent.device, dtype=guide_latent.dtype)
if start_frame > 0:
ramp_steps = [0.25, 0.65]
for i, s in enumerate(ramp_steps):
if i < F_g:
guide_mask_val = 1.0 + video_strength * (1.0 - s)
guide_mask[:, :, i, :, :] = guide_mask_val
ldf = int(max(1, round(float(latent_downscale_factor))))
if ldf > 1:
dilated = _dilate_latent({"samples": guide_latent, "noise_mask": guide_mask}, horizontal_scale=ldf, vertical_scale=ldf)
guide_mask = dilated["noise_mask"]
guide_latent = dilated["samples"]
tokens_added = guide_latent.shape[2] * guide_latent.shape[3] * guide_latent.shape[4]
positive, negative, latent_image, noise_mask = nodes_lt.LTXVAddGuide.append_keyframe(
positive, negative, frame_idx, latent_image, noise_mask, guide_latent, video_strength, scale_factors, guide_mask=guide_mask, latent_downscale_factor=float(latent_downscale_factor), causal_fix=causal_fix
)
if is_lora_active:
positive = _append_guide_attention_entry(positive, tokens_added, guide_orig_shape, attention_strength=video_attention_strength)
negative = _append_guide_attention_entry(negative, tokens_added, guide_orig_shape, attention_strength=video_attention_strength)
except Exception as e:
raise RuntimeError(f"LTX Director Guide motion segment failed for {seg}: {e}") from e
else:
print("[LTXDirectorGuide] No timeline guides present. Passing through.")
exact_crop_frames = max(0, int(latent_image.shape[2]) - initial_latent_length)
positive = node_helpers.conditioning_set_values(positive, {"nghtdrp_guide_crop_latent_frames": exact_crop_frames})
negative = node_helpers.conditioning_set_values(negative, {"nghtdrp_guide_crop_latent_frames": exact_crop_frames})
return (positive, negative, {"samples": latent_image, "noise_mask": noise_mask}, model, float(latent_downscale_factor))
@classmethod @classmethod
def _inject_msr(cls, positive, negative, vae, latent_image, noise_mask, msr, msr_strength=0.0): def _inject_msr(cls, positive, negative, vae, latent_image, noise_mask, msr, model, latent_downscale_factor, msr_strength=0.0):
"""Inject the Licon MSR references at this node's current resolution.""" """Inject the Licon MSR references at this node's current resolution.
Returns this node's 5-tuple (positive, negative, latent, model, latent_downscale_factor).
"""
from nodes import NODE_CLASS_MAPPINGS from nodes import NODE_CLASS_MAPPINGS
from .ltx_director import _execute_comfy_node, _unpack
IC = NODE_CLASS_MAPPINGS.get("LTXAddVideoICLoRAGuide") IC = NODE_CLASS_MAPPINGS.get("LTXAddVideoICLoRAGuide")
AG = NODE_CLASS_MAPPINGS.get("LTXVAddGuide") AG = NODE_CLASS_MAPPINGS.get("LTXVAddGuide")
@@ -118,6 +649,8 @@ class LTXDirectorGuide(LTXVAddGuide):
strength = clamp01(msr_strength) if float(msr_strength) > 0.0 else clamp01(msr["strength"]) strength = clamp01(msr_strength) if float(msr_strength) > 0.0 else clamp01(msr["strength"])
downscale = float(msr["downscale"]) downscale = float(msr["downscale"])
initial_latent_length = int(latent_image.shape[2])
# Pad the generated region so (pad + keyframes) == prefix_latents, so the downstream crop # 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. # (which trims the slideshow's temporal footprint) lands on the true clean length.
pad_latents = max(0, prefix_latents - len(keyframes)) pad_latents = max(0, prefix_latents - len(keyframes))
@@ -140,15 +673,95 @@ class LTXDirectorGuide(LTXVAddGuide):
IC, positive=positive, negative=negative, vae=vae, latent=latent, image=slideshow, IC, positive=positive, negative=negative, vae=vae, latent=latent, image=slideshow,
frame_idx=0, strength=strength, latent_downscale_factor=downscale, frame_idx=0, strength=strength, latent_downscale_factor=downscale,
crop="center", use_tiled_encode=False, tile_size=256, tile_overlap=64, crop="center", use_tiled_encode=False, tile_size=256, tile_overlap=64,
)) ))[:3]
positive, negative = cp, cn positive, negative = cp, cn
# LATENT PATH: one frozen keyframe per reference at frame 0 -> keep latent, drop cond. # LATENT PATH: one frozen keyframe per reference at frame 0 -> keep latent, drop cond.
for kf in keyframes: for kf in keyframes:
_p, _n, latent = _unpack(_execute_comfy_node( unpacked = _unpack(_execute_comfy_node(
AG, positive=positive, negative=negative, vae=vae, latent=latent, AG, positive=positive, negative=negative, vae=vae, latent=latent,
image=kf, frame_idx=0, strength=strength, image=kf, frame_idx=0, strength=strength,
)) ))
latent = unpacked[2]
return io.NodeOutput(positive, negative, latent) # NOTE: MSR is a PREFIX. We deliberately do NOT set nghtdrp_guide_crop_latent_frames
# (the new LTXDirectorCropGuides trims from the END, which would cut clean frames).
# The IC/AG nodes write position-aware keyframe metadata into the conditioning, so the
# MSR branch of the workflow should crop with the STOCK position-aware LTXVCropGuides
# node (exactly as the old MSR pipeline did).
return (positive, negative, latent, model, float(latent_downscale_factor))
def _conditioning_get_any_value(conditioning, key, default=None):
for item in conditioning:
meta = item[1]
if key in meta and meta.get(key) is not None:
return meta.get(key)
return default
def _get_exact_crop_count_from_conditioning(conditioning):
value = _conditioning_get_any_value(conditioning, "nghtdrp_guide_crop_latent_frames", None)
if value is not None:
try:
return max(0, int(value))
except Exception:
return 0
keyframe_idxs = _conditioning_get_any_value(conditioning, "keyframe_idxs", None)
if keyframe_idxs is None:
return 0
try:
return int(torch.unique(keyframe_idxs[:, 0, :, 0]).shape[0])
except Exception:
return 0
def _get_noise_mask_for_crop(latent):
latent_image = latent["samples"]
noise_mask = latent.get("noise_mask", None)
if noise_mask is None:
batch, _, frames, _, _ = latent_image.shape
return torch.ones((batch, 1, frames, 1, 1), dtype=torch.float32, device=latent_image.device)
return noise_mask.clone()
class LTXDirectorCropGuides:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"positive": ("CONDITIONING",),
"negative": ("CONDITIONING",),
"latent": ("LATENT",),
}
}
RETURN_TYPES = ("CONDITIONING", "CONDITIONING", "LATENT")
RETURN_NAMES = ("positive", "negative", "latent")
FUNCTION = "execute"
CATEGORY = "WhatDreamsCost CS"
def execute(self, positive, negative, latent):
latent_image = latent["samples"].clone()
noise_mask = _get_noise_mask_for_crop(latent)
crop_frames = _get_exact_crop_count_from_conditioning(positive)
if crop_frames <= 0:
return (positive, negative, {"samples": latent_image, "noise_mask": noise_mask})
crop_frames = min(crop_frames, max(0, latent_image.shape[2] - 1))
if crop_frames > 0:
latent_image = latent_image[:, :, :-crop_frames]
noise_mask = noise_mask[:, :, :-crop_frames]
clear_values = {
"keyframe_idxs": None,
"guide_attention_entries": None,
"nghtdrp_guide_crop_latent_frames": None,
}
positive = node_helpers.conditioning_set_values(positive, clear_values)
negative = node_helpers.conditioning_set_values(negative, clear_values)
return (positive, negative, {"samples": latent_image, "noise_mask": noise_mask})
NODE_CLASS_MAPPINGS = {
"LTXDirectorGuideCS": LTXDirectorGuide,
"LTXDirectorCropGuidesCS": LTXDirectorCropGuides,
}

117
ltx_keyframer.py Normal file
View File

@@ -0,0 +1,117 @@
import torch
import comfy.utils
from comfy_api.latest import io
class LTXKeyframer(io.ComfyNode):
@classmethod
def define_schema(cls):
inputs = [
io.Vae.Input("vae", tooltip="Video VAE used to encode the images"),
io.Latent.Input("latent", tooltip="Video latent to insert images into"),
io.Image.Input("multi_input", tooltip="Batched images from MultiImageLoader"),
]
inputs.append(io.Int.Input("num_images", default=1, min=0, max=50, step=1, display_name="images_loaded", tooltip="Select how many index/strength widgets to configure."))
for i in range(1, 51): # 1 to 50 images
inputs.extend([
io.Int.Input(
f"insert_frame_{i}",
default=0,
min=-9999,
max=9999,
step=1,
tooltip=f"Frame insert_frame for image {i} (in pixel space).",
optional=True,
),
io.Float.Input(
f"strength_{i}",
default=1.0,
min=0.0,
max=1.0,
step=0.01,
tooltip=f"Strength for image {i}.",
optional=True,
),
])
return io.Schema(
node_id="LTXKeyframer",
display_name="LTX Keyframer",
category="WhatDreamsCost",
description="Replaces video latent frames with the encoded input images. Number of widgets is dynamically configured.",
inputs=inputs,
outputs=[
io.Latent.Output(display_name="latent", tooltip="The video latent with the images inserted and latent noise mask updated."),
],
)
@classmethod
def execute(cls, vae, latent, multi_input, num_images, **kwargs) -> io.NodeOutput:
samples = latent["samples"].clone()
scale_factors = vae.downscale_index_formula
_, height_scale_factor, width_scale_factor = scale_factors
batch, _, latent_frames, latent_height, latent_width = samples.shape
width = latent_width * width_scale_factor
height = latent_height * height_scale_factor
# Get existing noise mask if present, otherwise create new one
if "noise_mask" in latent:
conditioning_latent_frames_mask = latent["noise_mask"].clone()
else:
conditioning_latent_frames_mask = torch.ones(
(batch, 1, latent_frames, 1, 1),
dtype=torch.float32,
device=samples.device,
)
batch_size = multi_input.shape[0] if multi_input is not None else 0
# We process inputs up to num_images, extracting values from kwargs
for i in range(1, num_images + 1):
# Skip if this image index exceeds the batch
if i > batch_size:
continue
image = multi_input[i-1:i] # Extract the single image frame from the batch
if image is None:
continue
insert_frame = kwargs.get(f"insert_frame_{i}")
if insert_frame is None:
continue
strength = kwargs.get(f"strength_{i}", 1.0)
if image.shape[1] != height or image.shape[2] != width:
pixels = comfy.utils.common_upscale(image.movedim(-1, 1), width, height, "bilinear", "center").movedim(1, -1)
else:
pixels = image
encode_pixels = pixels[:, :, :, :3]
t = vae.encode(encode_pixels)
# Convert pixel frame insert_frame to latent insert_frame
time_scale_factor = scale_factors[0]
# Handle negative indexing in pixel space
pixel_frame_count = (latent_frames - 1) * time_scale_factor + 1
if insert_frame < 0:
insert_frame = pixel_frame_count + insert_frame
# Convert to latent insert_frame
latent_idx = insert_frame // time_scale_factor
# Clamp to valid range
latent_idx = max(0, min(latent_idx, latent_frames - 1))
# Calculate end insert_frame, ensuring we don't exceed latent_frames
end_index = min(latent_idx + t.shape[2], latent_frames)
# Replace samples at the specified insert_frame range
samples[:, :, latent_idx:end_index] = t[:, :, :end_index - latent_idx]
# Update mask at the specified insert_frame range
conditioning_latent_frames_mask[:, :, latent_idx:end_index] = 1.0 - strength
return io.NodeOutput({"samples": samples, "noise_mask": conditioning_latent_frames_mask})

View File

@@ -1,4 +1,3 @@
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
@@ -56,14 +55,12 @@ 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. Auto-syncs to Prompt Relay invisibly via the positive wire.", description="Add multiple guide images at specified frame indices or seconds with strengths. Number of widgets is dynamically configured.",
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"),
], ],
) )
@@ -92,64 +89,30 @@ class LTXSequencer(LTXVAddGuide):
insert_mode = kwargs.get("insert_mode", "frames") insert_mode = kwargs.get("insert_mode", "frames")
frame_rate = kwargs.get("frame_rate", 24) frame_rate = kwargs.get("frame_rate", 24)
# ---> EXTRACT GHOST SYNC DATA <---
timeline_data = None
if positive is not None and len(positive) > 0:
if "prompt_relay_timeline" in positive[0][1]:
try:
timeline_data = json.loads(positive[0][1]["prompt_relay_timeline"])
except json.JSONDecodeError:
pass
# Prepare the log text
sync_log_lines = []
if timeline_data:
sync_log_lines.append("=== TIMELINE SYNC ENABLED ===")
sync_log_lines.append(f"Invisibly Synced via Positive Wire! Using {insert_mode.upper()}:")
else:
sync_log_lines.append("=== MANUAL MODE ===")
sync_log_lines.append(f"No timeline detected, or Sync turned OFF. Using manual UI inputs ({insert_mode}):")
# Process inputs up to num_images, extracting dynamic frame/strength values from kwargs # Process inputs up to num_images, extracting dynamic frame/strength values from kwargs
for i in range(1, num_images + 1): for i in range(1, num_images + 1):
# Skip if this image index exceeds the batch # Skip if this image index exceeds the batch
if i > batch_size: if i > batch_size:
sync_log_lines.append(f"Image #{i}: Skipped (No image loaded in batch)")
continue continue
img = multi_input[i-1:i] # Extract the single image frame from the batch img = multi_input[i-1:i] # Extract the single image frame from the batch
if img is None: if img is None:
continue continue
# Calculate the final frame index based on the chosen mode
f_idx = None f_idx = None
strength = kwargs.get(f"strength_{i}", 1.0) if insert_mode == "frames":
f_idx = kwargs.get(f"insert_frame_{i}")
# 1. AUTO-SYNC elif insert_mode == "seconds":
if timeline_data and "starts_frames" in timeline_data and (i - 1) < len(timeline_data["starts_frames"]): sec = kwargs.get(f"insert_second_{i}")
display_frame = timeline_data["starts_frames"][i - 1] if sec is not None:
display_sec = timeline_data["starts_seconds"][i - 1] f_idx = int(sec * frame_rate)
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)
@@ -167,8 +130,4 @@ class LTXSequencer(LTXVAddGuide):
scale_factors, scale_factors,
) )
sync_log_str = "\n".join(sync_log_lines) return io.NodeOutput(positive, negative, {"samples": latent_image, "noise_mask": noise_mask})
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)

View File

@@ -1,7 +1,10 @@
import logging
import types import types
import torch
import comfy.ldm.modules.attention import comfy.ldm.modules.attention
log = logging.getLogger(__name__)
def _masked_attention(q, k, v, heads, mask, transformer_options={}, **kwargs): def _masked_attention(q, k, v, heads, mask, transformer_options={}, **kwargs):
# Bypass wrap_attn (sage/etc may ignore masks) by calling attention_pytorch directly. # Bypass wrap_attn (sage/etc may ignore masks) by calling attention_pytorch directly.
@@ -18,7 +21,7 @@ def _wan_t2v_forward(self, mask_fn, x, context, transformer_options={}, **kwargs
k = self.norm_k(self.k(context)) k = self.norm_k(self.k(context))
v = self.v(context) v = self.v(context)
mask = mask_fn(q, k, transformer_options) mask = mask_fn(q.shape[1], k.shape[1], q.dtype, q.device, transformer_options)
if mask is not None: if mask is not None:
x = _masked_attention(q, k, v, heads=self.num_heads, mask=mask, x = _masked_attention(q, k, v, heads=self.num_heads, mask=mask,
transformer_options=transformer_options) transformer_options=transformer_options)
@@ -44,7 +47,7 @@ def _wan_i2v_forward(self, mask_fn, x, context, context_img_len, transformer_opt
k = self.norm_k(self.k(context_text)) k = self.norm_k(self.k(context_text))
v = self.v(context_text) v = self.v(context_text)
mask = mask_fn(q, k, transformer_options) mask = mask_fn(q.shape[1], k.shape[1], q.dtype, q.device, transformer_options)
if mask is not None: if mask is not None:
x = _masked_attention(q, k, v, heads=self.num_heads, mask=mask, x = _masked_attention(q, k, v, heads=self.num_heads, mask=mask,
transformer_options=transformer_options) transformer_options=transformer_options)
@@ -56,47 +59,65 @@ def _wan_i2v_forward(self, mask_fn, x, context, context_img_len, transformer_opt
return self.o(x + img_x) return self.o(x + img_x)
def _ltx_forward(self, mask_fn, x, context=None, mask=None, pe=None, k_pe=None, transformer_options={}): def _make_masked_override(prev_override):
from comfy.ldm.lightricks.model import apply_rotary_emb """transformer_options override that routes mask-bearing attention calls through
attention_pytorch (sage/etc. drop arbitrary masks). Chains to a prior override
when no mask is present so we don't clobber other backends."""
def override(func, *args, **kwargs):
if kwargs.get("mask") is not None:
return comfy.ldm.modules.attention.attention_pytorch(*args, **kwargs)
if prev_override is not None:
return prev_override(func, *args, **kwargs)
return func(*args, **kwargs)
return override
is_self_attn = context is None
context = x if is_self_attn else context
q = self.q_norm(self.to_q(x)) def debug_log(msg):
k = self.k_norm(self.to_k(context)) try:
v = self.to_v(context) import os
log_path = os.path.join(os.path.dirname(__file__), "debug_prompt_relay.log")
with open(log_path, "a", encoding="utf-8") as f:
f.write(msg + "\n")
except Exception:
pass
if pe is not None:
q = apply_rotary_emb(q, pe)
k = apply_rotary_emb(k, pe if k_pe is None else k_pe)
if not is_self_attn: def _make_ltx_mask_wrapper(underlying, mask_fn, attr):
temporal_mask = mask_fn(q, k, transformer_options) """Wrap an existing LTX cross-attn forward (the default `CrossAttention.forward`
if temporal_mask is not None: or another node's patch — e.g. KJNodes NAG), injecting PromptRelay's additive
mask = temporal_mask if mask is None else mask + temporal_mask mask via the `mask` kwarg the upstream signature already accepts.
if mask is None: `underlying` must already be bound to its module — callable as
out = comfy.ldm.modules.attention.optimized_attention( `underlying(x, context=..., mask=..., ...)`.
q, k, v, self.heads, attn_precision=self.attn_precision, """
def wrapped(_self, x, context=None, mask=None, pe=None, k_pe=None, transformer_options={}):
debug_log(f"wrapped called: x.shape={list(x.shape)} context.shape={list(context.shape) if context is not None else None} mask_is_none={mask is None}")
if context is not None:
opts = {**transformer_options, "promptrelay_attn_type": attr}
pr_mask = mask_fn(x.shape[1], context.shape[1], x.dtype, x.device, opts)
debug_log(f"mask_fn returned pr_mask_is_none={pr_mask is None}")
if pr_mask is not None:
debug_log(f"pr_mask info: shape={list(pr_mask.shape)} min={pr_mask.min().item()} max={pr_mask.max().item()} sum={pr_mask.sum().item()}")
mask = pr_mask if mask is None else mask + pr_mask
if mask is not None:
prev = transformer_options.get("optimized_attention_override")
transformer_options = {
**transformer_options,
"optimized_attention_override": _make_masked_override(prev),
}
return underlying(
x, context=context, mask=mask, pe=pe, k_pe=k_pe,
transformer_options=transformer_options, transformer_options=transformer_options,
) )
else:
out = _masked_attention(q, k, v, self.heads, mask=mask,
attn_precision=self.attn_precision,
transformer_options=transformer_options)
if self.to_gate_logits is not None: wrapped._promptrelay_wrapper = True
gate_logits = self.to_gate_logits(x) return wrapped
b, t, _ = out.shape
out = out.view(b, t, self.heads, self.dim_head)
out = out * (2.0 * torch.sigmoid(gate_logits)).unsqueeze(-1)
out = out.view(b, t, self.heads * self.dim_head)
return self.to_out(out)
class _CrossAttnPatch: class _CrossAttnPatch:
"""Descriptor that binds (impl, mask_fn) as a method onto a cross-attn module.""" """Descriptor that binds (impl, mask_fn) as a method onto a Wan cross-attn module."""
def __init__(self, impl, mask_fn): def __init__(self, impl, mask_fn):
self.impl = impl self.impl = impl
@@ -135,8 +156,8 @@ def _check_unpatched(model_clone, key):
if key in getattr(model_clone, "object_patches", {}): if key in getattr(model_clone, "object_patches", {}):
raise RuntimeError( raise RuntimeError(
f"PromptRelay: cross-attention forward at '{key}' is already patched by " f"PromptRelay: cross-attention forward at '{key}' is already patched by "
"another node (e.g. KJNodes NAG). Stacking is not supported — remove the " "another node. Stacking is not supported for this architecture — remove "
"conflicting node." "the conflicting node."
) )
@@ -154,14 +175,19 @@ def apply_patches(model_clone, arch, mask_fn):
return return
if arch == "ltx": if arch == "ltx":
to = model_clone.model_options["transformer_options"]
to["promptrelay_mask_fn"] = mask_fn
for idx, block in enumerate(diffusion_model.transformer_blocks): for idx, block in enumerate(diffusion_model.transformer_blocks):
for attr in ("attn2", "audio_attn2"): for attr in ("attn2", "audio_attn2"):
module = getattr(block, attr, None) module = getattr(block, attr, None)
if module is None: if module is None:
continue continue
key = f"diffusion_model.transformer_blocks.{idx}.{attr}.forward" key = f"diffusion_model.transformer_blocks.{idx}.{attr}.forward"
_check_unpatched(model_clone, key) # get_model_object returns the prior patch if present, else the default bound forward.
model_clone.add_object_patch(key, _CrossAttnPatch(_ltx_forward, mask_fn).__get__(module, module.__class__)) underlying = model_clone.get_model_object(key)
wrapper = _make_ltx_mask_wrapper(underlying, mask_fn, attr)
model_clone.add_object_patch(key, types.MethodType(wrapper, module))
return return
raise ValueError(f"Unknown model arch: {arch}") raise ValueError(f"Unknown model arch: {arch}")

View File

@@ -20,7 +20,7 @@ def build_temporal_cost(q_token_idx, Lq, Lk, device, dtype, tokens_per_frame):
return offset return offset
def build_temporal_cost_scaled(q_token_idx, Lq, Lk, device, dtype, latent_frames): def build_temporal_cost_scaled(q_token_idx, Lq, Lk, device, dtype, latent_frames, is_audio=False):
"""Penalty matrix for queries that don't map to integer frames (e.g. LTXAV audio tokens).""" """Penalty matrix for queries that don't map to integer frames (e.g. LTXAV audio tokens)."""
offset = torch.zeros(Lq, Lk, device=device, dtype=dtype) offset = torch.zeros(Lq, Lk, device=device, dtype=dtype)
query_frames = torch.arange(Lq, device=device, dtype=torch.float32) * latent_frames / Lq query_frames = torch.arange(Lq, device=device, dtype=torch.float32) * latent_frames / Lq
@@ -28,54 +28,90 @@ def build_temporal_cost_scaled(q_token_idx, Lq, Lk, device, dtype, latent_frames
for seg in q_token_idx: for seg in q_token_idx:
local = seg["local_token_idx"].to(device=device) local = seg["local_token_idx"].to(device=device)
d = (query_frames[:, None] - seg["midpoint"]).abs() d = (query_frames[:, None] - seg["midpoint"]).abs()
sigma_a = seg.get("sigma_audio", seg["sigma"]) if is_audio:
window_a = seg.get("window_audio", seg["window"]) sigma_val = seg.get("sigma_audio", seg["sigma"])
strength_a = seg.get("strength_audio", 1.0) window_val = seg.get("window_audio", seg["window"])
cost = strength_a * (torch.relu(d - window_a) ** 2) / (2 * sigma_a ** 2) strength_val = seg.get("strength_audio", 1.0)
else:
sigma_val = seg["sigma"]
window_val = seg["window"]
strength_val = seg.get("strength", 1.0)
cost = strength_val * (torch.relu(d - window_val) ** 2) / (2 * sigma_val ** 2)
offset[:, local] = cost.to(offset.dtype) offset[:, local] = cost.to(offset.dtype)
return offset return offset
def debug_log(msg):
try:
import os
log_path = os.path.join(os.path.dirname(__file__), "debug_prompt_relay.log")
with open(log_path, "a", encoding="utf-8") as f:
f.write(msg + "\n")
except Exception:
pass
def create_mask_fn(q_token_idx, fallback_tokens_per_frame, latent_frames): def create_mask_fn(q_token_idx, fallback_tokens_per_frame, latent_frames):
"""Closure: mask_fn(q, k, transformer_options) -> additive mask or None.""" """Closure: mask_fn(Lq, Lk, dtype, device, transformer_options) -> additive mask or None.
Takes shapes/dtype/device instead of tensors so callers can compute the mask
without first materializing q/k projections — required so PromptRelay can
wrap an existing cross-attn forward (e.g. KJNodes NAG) instead of replacing it.
"""
cache = {} cache = {}
max_token_idx = max(int(seg["local_token_idx"].max().item()) for seg in q_token_idx) + 1 max_token_idx = max(int(seg["local_token_idx"].max().item()) for seg in q_token_idx) + 1
def mask_fn(q, k, transformer_options): def mask_fn(Lq, Lk, dtype, device, transformer_options):
Lq, Lk = q.shape[1], k.shape[1] debug_log(f"mask_fn check: Lq={Lq} Lk={Lk} max_token_idx={max_token_idx} cond_or_uncond={transformer_options.get('cond_or_uncond', [])}")
if Lq == Lk: if Lq == Lk:
debug_log("mask_fn: Lq == Lk, returning None")
return None return None
# Only apply on conditional pass — not unconditional (negative prompt) # Only apply on conditional pass — not unconditional (negative prompt)
cond_or_uncond = transformer_options.get("cond_or_uncond", []) cond_or_uncond = transformer_options.get("cond_or_uncond", [])
if 1 in cond_or_uncond and 0 not in cond_or_uncond: if 1 in cond_or_uncond and 0 not in cond_or_uncond:
debug_log("mask_fn: unconditional pass, returning None")
return None return None
grid_sizes = transformer_options.get("grid_sizes", None) grid_sizes = transformer_options.get("grid_sizes", None)
video_tpf = int(grid_sizes[1]) * int(grid_sizes[2]) if grid_sizes is not None else fallback_tokens_per_frame attn_type = transformer_options.get("promptrelay_attn_type", "attn2")
video_lq = latent_frames * video_tpf is_audio = (attn_type == "audio_attn2")
# Skip cross-modal attention — text keys are padded to a fixed length ≥ max_token_idx and != video_lq if is_audio:
if Lk == video_lq or Lk < max_token_idx: mode = "scaled"
return None video_lq = -1
else:
if grid_sizes is not None:
video_tpf = int(grid_sizes[1]) * int(grid_sizes[2])
else:
if Lq % latent_frames == 0:
video_tpf = Lq // latent_frames
else:
video_tpf = fallback_tokens_per_frame
video_lq = latent_frames * video_tpf
mode = "video" if Lq == video_lq else "scaled" # Skip cross-modal attention — text keys are padded to a fixed length ≥ max_token_idx and != video_lq
if Lk == video_lq or Lk < max_token_idx:
debug_log(f"mask_fn: Lk == video_lq ({Lk == video_lq}) or Lk < max_token_idx ({Lk < max_token_idx}), returning None")
return None
key = (Lq, Lk, mode, q.device) mode = "video" if Lq == video_lq else "scaled"
key = (Lq, Lk, mode, device)
if key not in cache: if key not in cache:
if mode == "video": if mode == "video":
cost = build_temporal_cost(q_token_idx, Lq, Lk, q.device, q.dtype, video_tpf) cost = build_temporal_cost(q_token_idx, Lq, Lk, device, dtype, video_tpf)
else: else:
cost = build_temporal_cost_scaled(q_token_idx, Lq, Lk, q.device, q.dtype, latent_frames) cost = build_temporal_cost_scaled(q_token_idx, Lq, Lk, device, dtype, latent_frames, is_audio=is_audio)
log.info( log.info(
"[PromptRelay] Built penalty matrix (%s): Lq=%d, Lk=%d, nonzero=%d/%d", "[PromptRelay] Built penalty matrix (%s): Lq=%d, Lk=%d, nonzero=%d/%d",
mode, Lq, Lk, (cost > 0).sum().item(), cost.numel(), mode, Lq, Lk, (cost > 0).sum().item(), cost.numel(),
) )
debug_log(f"Built penalty matrix ({mode}): Lq={Lq}, Lk={Lk}, key={key}")
cache[key] = -cost cache[key] = -cost
return cache[key].to(q.dtype) return cache[key].to(dtype)
return mask_fn return mask_fn
@@ -160,7 +196,30 @@ def map_token_indices(raw_tokenizer, global_prompt, local_prompts):
""" """
prefixed_locals = [" " + lp for lp in local_prompts] prefixed_locals = [" " + lp for lp in local_prompts]
full_prompt = global_prompt + "".join(prefixed_locals) full_prompt = global_prompt + "".join(prefixed_locals)
# Detect if the tokenizer appends EOS dynamically
has_eos = getattr(raw_tokenizer, "add_eos", False) has_eos = getattr(raw_tokenizer, "add_eos", False)
if not has_eos:
try:
test_res = raw_tokenizer("test")
if isinstance(test_res, dict) and "input_ids" in test_res:
ids = test_res["input_ids"]
elif hasattr(test_res, "input_ids"):
ids = test_res.input_ids
elif isinstance(test_res, list):
ids = test_res
else:
ids = []
if ids:
eos_id = getattr(raw_tokenizer, "eos_token_id", None)
if eos_id is not None and ids[-1] == eos_id:
has_eos = True
elif ids[-1] == 1:
has_eos = True
except Exception:
pass
eos_adj = 1 if has_eos else 0 eos_adj = 1 if has_eos else 0
prev_len = len(raw_tokenizer(global_prompt)["input_ids"]) - eos_adj prev_len = len(raw_tokenizer(global_prompt)["input_ids"]) - eos_adj

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "whatdreamscost-csglide" name = "whatdreamscost-csglide"
description = "LTX Director (CS) — modded LTX Director node with reference-sheet / @char / MSR support. Coexists with the original WhatDreamsCost-ComfyUI without collisions." description = "LTX Director (CS) — modded LTX Director with reference-sheet / @char / MSR + provider Analyze backend. Coexists with the original WhatDreamsCost-ComfyUI without collisions."
version = "1.0.0" version = "2.0.0"
license = {file = "LICENSE"} license = {file = "LICENSE"}
[project.urls] [project.urls]