Skip to content

Fix two silent no-ops in LTXLikenessAnchor / LTXLatentAnchorAware - #15

Open
jjdejong wants to merge 2 commits into
TenStrip:mainfrom
jjdejong:fix/anchor-silent-noop-bugs
Open

Fix two silent no-ops in LTXLikenessAnchor / LTXLatentAnchorAware#15
jjdejong wants to merge 2 commits into
TenStrip:mainfrom
jjdejong:fix/anchor-silent-noop-bugs

Conversation

@jjdejong

Copy link
Copy Markdown

Two bugs that make the anchors silently do nothing. Both fail without raising, without logging, and while still returning a valid MODEL — so the node looks like it is working. Found while debugging LTX-AV i2v runs on ComfyUI.

Two commits, reviewable independently.


1. The video latent is located by guessing tensor rank

Both anchors discover their grid dimensions in the backbone pre-hook by scanning forward()'s args/kwargs for "the first 5D tensor", commented as "typically the latent input".

It never is. LTX-AV passes x as a video+audio container, not a Tensor — separate_audio_and_video_latents indexes it as x[0]/x[1], and recombine_audio_and_video_latents returns a plain [vx, ax] list — so isinstance(..., torch.Tensor) skips it and the scan never descends.

What it actually finds is denoise_mask, reshaped to (B, 1, F, H, W) by model_base. Its F/H/W coincidentally equal the latent's, so the dims come out right by accident:

graph denoise_mask result
i2v / inpainting present, 5D works — via the mask, not the latent
no mask supplied None nothing captured

In the second case LTXLikenessAnchor's latent_frame_0 mode returns its input unmodified at every block, and LTXLatentAnchorAware's blend never runs.

Fix: _extract_video_latent() resolves the x argument (keyword or first positional), unwraps a .tensors wrapper or plain list to reach the video element, and only then falls back to a rank-5 scan that excludes denoise_mask/concat_mask.

Verified against the real argument shapes:

list [video, audio] + denoise_mask  -> (1, 128, 31, 17, 17)   # was returning the 1-channel mask
NestedTensor-style wrapper          -> (1, 128, 31, 17, 17)
bare 5D tensor                      -> (1, 128, 31, 17, 17)
x as keyword                        -> (1, 128, 31, 17, 17)
denoise_mask alone                  -> None

The channel count is the tell: 128 is the video latent, 1 was the mask. Confirmed live — LTXLatentAnchorAware now logs captured video latent: (1, 128, 31, 17, 17).


2. The backbone pre-hook is never re-registered

if not getattr(backbone, HOOK_ATTR_BACKBONE, False):
    backbone.register_forward_pre_hook(backbone_pre_hook, with_kwargs=True)
    setattr(backbone, HOOK_ATTR_BACKBONE, True)

That sentinel lives on the backbone module, which model.clone() shares by reference. From the second apply onward in a single ComfyUI process the gate is already set, so the new pre-hook is never attached and the first apply's closure stays live — writing captured_latent_shape and current_sigma into a state dict nothing else can read.

For every apply after the first, for the process lifetime:

  • latent_frame_0 silently returns unmodified outputcaptured_latent_shape stays None, so the hook bails at every block.
  • skip_when_sigma_above stops working in all modes, including guidecurrent_sigma comes from the same dead closure.

Only the first prompt after a restart ever worked, which makes it easy to mistake for a functioning node.

The attn1 hooks in this same node already remove-and-re-register correctly, and LTXLatentAnchorAware does so for both its hook types — this path was the outlier.

Fix: track the handle, remove any prior registration, re-register unconditionally. The bypass path now detaches the pre-hook too, rather than only dropping the sentinel — which previously left it attached and firing.

Verified by applying three times against a shared module:

apply 1: state='run1' live_hooks=1
apply 2: state='run2' live_hooks=1
apply 3: state='run3' live_hooks=1

Confirmed live: before the fix a second apply logged video latent shape was never captured; after it, the same second apply reports HOOK ACTIVE.


Tested on LTX-2.3 22B i2v, ComfyUI on ROCm/gfx1151. No API, node-name or default-value changes; behaviour is unchanged on graphs that were already working (i2v with a mask, first apply after restart).

jjdejong added 2 commits July 28, 2026 18:32
Both anchors discovered their grid dimensions by scanning forward()'s
args/kwargs for "the first 5D tensor", commented as "typically the latent
input". It never was. LTX-AV passes x as a video+audio container rather
than a Tensor -- separate_audio_and_video_latents indexes it as x[0]/x[1]
and recombine_audio_and_video_latents returns a plain [vx, ax] list -- so
isinstance(..., torch.Tensor) skips it and the scan never descends.

What the scan actually picked up was denoise_mask, shaped (B, 1, F, H, W)
by model_base, whose F/H/W coincidentally equal the latent's. So the dims
came out right on i2v graphs by accident, while graphs that pass no mask
captured nothing at all: LTXLikenessAnchor's latent_frame_0 mode returned
its input unmodified and LTXLatentAnchorAware's blend never ran, both
silently and with no output even under debug.

Add _extract_video_latent, which resolves the x argument (keyword or first
positional), unwraps a .tensors wrapper or plain list to reach the video
element, and falls back to a rank-5 scan that explicitly excludes
denoise_mask/concat_mask so a mask can never masquerade as the latent.
Report the failure under debug instead of going quiet.

Verified against the real argument shapes: a [video, audio] list, a
NestedTensor-style wrapper, a bare 5D tensor, and the x-as-keyword form all
resolve to the 128-channel video latent even when denoise_mask is present;
a mask alone now returns None rather than passing itself off as the latent.
Registration was gated on a sentinel attribute living on the backbone,
which model.clone() shares by reference. From the second apply onward in a
single ComfyUI process the gate was already set, so the new pre-hook was
never attached and the first apply's closure stayed live, writing into a
state dict nothing else could read.

The effect was that captured_latent_shape and current_sigma stayed None for
the rest of the process: latent_frame_0 silently returned unmodified output
from the second prompt onward, and the skip_when_sigma_above gate stopped
working in every mode including guide. Only the first prompt after a
restart ever worked, which makes it easy to mistake for a working node.

Track the handle, remove any prior registration, and re-register
unconditionally -- the same lifecycle the attn1 hooks already use, and that
LTXLatentAnchorAware already applies to both its hook types. The bypass path
now detaches the pre-hook too instead of only dropping the sentinel, which
previously left it attached and firing.

Verified by applying three times against a shared module: each apply's state
dict is the one populated, and exactly one pre-hook stays live.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant