fix(sampler): carry_i2v_guides and mid_scene_guide had no H3 path

Both did LTX latent surgery unguarded: prepending frames off H3's 5k+2 grid and
attaching conditioning it never reads. Carry now pins the original reference at
frame 0; a mid-clip guide declines by the same rule as every other H3 pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-21 00:09:36 +03:00
co-authored by Claude Opus 5
parent a9d2c0d9f0
commit 6bcd869d4e
2 changed files with 91 additions and 5 deletions
+42 -5
View File
@@ -3284,7 +3284,33 @@ class FunPackLTXAVSceneChainSampler:
chunk["noise_mask"] = out_masks[0]
return chunk, count
def _h3_carry_i2v_guide(self, chunk, template, positive, negative):
"""carry_i2v_guides on H3: hold the ORIGINAL reference, as a frame-0 pin.
The LTX path below prepends the template's protected frames to the chunk. On H3 that
adds video latent frames the packed layout never accounted for — off its 5k+2 grid —
and attaches LTX conditioning this model does not read, so it damaged the latent
instead of carrying anything. The same intent (every scene keeps the opening
reference) is a keyframe pin, and nothing is appended, so no tail is cropped later.
It claims frame 0 ahead of the continuation pin: continuing from the previous shot is
the default, holding the original reference is what the user asked for by name.
"""
tensors = self._latent_tensors(template)
masks = self._latent_masks(template, len(tensors))
if not tensors or getattr(tensors[0], "ndim", 0) < 5:
return chunk, positive, negative, 0
protected = self._protected_prefix_frames(masks[0], self._tensor_frames(tensors[0]))
if protected <= 0:
return chunk, positive, negative, 0
frame = self._time_slice(tensors[0], 0, 1)[:1].clone()
positive = self._h3_add_keyframes(
positive, [{"resolved_frame_index": 0, "latent": frame}], self._h3_frame_count)
return chunk, positive, negative, 0
def _append_i2v_guides(self, chunk, template, positive, negative):
if self._is_h3:
return self._h3_carry_i2v_guide(chunk, template, positive, negative)
chunk_tensors = self._latent_tensors(chunk)
template_tensors = self._latent_tensors(template)
template_masks = self._latent_masks(template, len(template_tensors))
@@ -5489,11 +5515,6 @@ class FunPackLTXAVSceneChainSampler:
using LTX's guide attention mechanism (keyframe_idxs + guide_attention_entries).
Audio-safe: appends only to the video tensor, guide tokens influence denoising
through attention weights rather than overwriting hidden states."""
try:
from comfy_extras.nodes_lt import LTXVAddGuide, _append_guide_attention_entry
except ImportError:
return chunk, positive, negative, 0
prev_tensors = self._latent_tensors(previous_output)
chunk_tensors = self._latent_tensors(chunk)
if not prev_tensors or not chunk_tensors:
@@ -5504,6 +5525,22 @@ class FunPackLTXAVSceneChainSampler:
guide_frame = self._time_slice(prev_tensors[0], F_prev // 2, F_prev // 2 + 1)
guide_frame = guide_frame.to(device=chunk_tensors[0].device, dtype=chunk_tensors[0].dtype)
if self._is_h3:
# A mid-clip pin is not something H3's packed layout can place, and the LTX path
# below would append a latent frame off its grid rather than refuse. Routed
# through the keyframe path so it declines by the same rule, and says so.
positive, negative, tail = self._append_h3_keyframe(
guide_frame, max(1, int(self._h3_frame_count)) // 2, strength,
positive, negative)
return chunk, positive, negative, tail
# LTX from here down. Imported after the H3 branch so the H3 path does not depend on
# an LTX-only module importing.
try:
from comfy_extras.nodes_lt import LTXVAddGuide, _append_guide_attention_entry
except ImportError:
return chunk, positive, negative, 0
# Target temporal position: middle of current chunk in pixel space
F_chunk = self._tensor_frames(chunk_tensors[0])
mid_chunk = F_chunk // 2
+49
View File
@@ -605,3 +605,52 @@ def test_an_unreadable_carry_source_skips_rather_than_failing_the_render():
positive = [[torch.zeros(1, 8, 16), {}]]
out, applied = node._h3_continuation_pin(positive, {"samples": torch.zeros(1, 24, 4)})
assert applied is False and _pins(out) == []
# ── the two carry toggles that had no H3 path at all ─────────────────────────
def _template(video_t=37, protected=1):
latent = av_latent(video_t=video_t)
video = latent["samples"].unbind()[0]
mask = torch.ones_like(video)
mask[:, :, :protected] = 0.0 # protected = pinned prefix
latent["noise_mask"] = NT([mask, torch.ones_like(latent["samples"].unbind()[1])])
video[:, :, 0] = 5.0 # the reference frame
return latent
def test_carry_i2v_guides_pins_the_reference_instead_of_growing_the_latent():
"""The LTX path prepends the template's protected frames to the chunk. On H3 that adds
video frames off its 5k+2 grid and attaches conditioning the model does not read."""
node = h3_node()
chunk = av_latent(video_t=37)
before = chunk["samples"].unbind()[0].shape
out_chunk, positive, _neg, carried = node._append_i2v_guides(
chunk, _template(), [[torch.zeros(1, 8, 16), {}]], None)
assert out_chunk["samples"].unbind()[0].shape == before # latent untouched
assert carried == 0 # nothing to crop later
pins = _pins(positive)
assert [p["resolved_frame_index"] for p in pins] == [0]
assert float(pins[0]["latent"].max()) == 5.0 # the ORIGINAL reference
def test_holding_the_reference_outranks_continuing_from_the_last_shot():
"""Continuing is the default; carry_i2v_guides is the user asking for the opposite."""
node = h3_node()
_c, positive, _n, _t = node._append_i2v_guides(
av_latent(), _template(), [[torch.zeros(1, 8, 16), {}]], None)
out, applied = node._h3_continuation_pin(positive, av_latent())
assert applied is False
assert float(_pins(out)[0]["latent"].max()) == 5.0
def test_mid_scene_guide_declines_on_h3_instead_of_damaging_the_latent(capsys):
"""H3's packed layout places a pin at the first or last frame, nowhere between."""
node = h3_node(frame_count=124)
chunk = av_latent(video_t=37)
before = chunk["samples"].unbind()[0].shape
out_chunk, positive, _neg, tail = node._append_mid_scene_guide(
chunk, av_latent(video_t=37), [[torch.zeros(1, 8, 16), {}]], None, LTXVAE(), 1.0)
assert out_chunk["samples"].unbind()[0].shape == before
assert tail == 0 and _pins(positive) == []
assert "pins only the first" in capsys.readouterr().out