diff --git a/samplers.py b/samplers.py index a5f313fc..5d4c218e 100644 --- a/samplers.py +++ b/samplers.py @@ -4940,6 +4940,42 @@ class FunPackLTXAVSceneChainSampler: aug=None if aug >= 1.0 else aug) return positive, True + def _h3_has_first_frame_pin(self, conditioning): + pins = self._conditioning_value(conditioning, "minimax_keyframes") or [] + return any(int(p.get("resolved_frame_index", -1)) == 0 for p in pins) + + def _h3_continuation_pin(self, positive, carry_source): + """Continue an H3 scene from the previous one, the only way H3 can be told to. + + LTX chains through the LATENT: the previous scene's tail is copied into the leading + frames and masked out of denoising, and the model treats those frames as context. + H3 has no latent conditioning at all — a pre-filled frame is, in its own terms, just + noise it is free to overwrite. The mask still holds those pixels in the output, so + the seam looked right while the rest of the shot was generated knowing nothing about + what came before: the chain produced a batch of unrelated clips. + + The previous scene's last latent frame becomes this scene's frame-0 keyframe pin, + which is exactly the conditioning an i2v anchor uses. No VAE round trip: the frame is + already in latent space, and the pin path takes latents (the guide path already + relies on that). Skipped when something already pinned frame 0 — an explicit anchor + or a wired first_frame outranks a carried tail. + + Returns (positive, applied). + """ + if carry_source is None or self._h3_has_first_frame_pin(positive): + return positive, False + try: + video = self._latent_tensors(carry_source)[0] + if getattr(video, "ndim", 0) < 5 or int(video.shape[2]) < 1: + return positive, False + frame = video[:1, :, -1:].clone() + except Exception as error: # noqa: BLE001 + print(f"[FunPackSceneChain] H3: continuation pin skipped — {error}.") + return positive, False + positive = self._h3_add_keyframes( + positive, [{"resolved_frame_index": 0, "latent": frame}], self._h3_frame_count) + return positive, True + def _h3_external_pins(self, conditioning): """Rescue the keyframe pins from a MiniMax H3 Image to Video node's conditioning. @@ -6714,6 +6750,14 @@ class FunPackLTXAVSceneChainSampler: run_mechanisms.append(f"h3_wired_keyframes({'+'.join(_pin_labels)})") if "first" in _pin_labels: _h3_opening_anchored = True + # Nothing claimed frame 0 and a previous scene exists: continue from its last + # frame. Without this the carried latent tail is not conditioning on H3 — the + # seam matched and the rest of the shot knew nothing about the scene before it. + if _carry_source is not None: + scene_positive, _continued = self._h3_continuation_pin( + scene_positive, _carry_source) + if _continued: + run_mechanisms.append("h3_continuation_pin") # ref2va reference blocks. Applied after the chunk is final because the blocks are # sized against this scene's canvas, and to every scene branch (fresh / anchored / diff --git a/tests/test_minimax_h3_sampler.py b/tests/test_minimax_h3_sampler.py index 5f83ddb3..eda70cae 100644 --- a/tests/test_minimax_h3_sampler.py +++ b/tests/test_minimax_h3_sampler.py @@ -556,3 +556,52 @@ def test_a_wired_first_frame_overrides_the_timeline_anchor_on_frame_zero(): out, _labels = node._apply_h3_external_pins(positive, pins, scene_index=0, scene_count=1) kfs = out[0][1]["minimax_keyframes"] assert len(kfs) == 1 and kfs[0]["latent"] is not anchor + + +# ── continuing a scene from the one before it ──────────────────────────────── + +def _pins(cond): + return (cond[0][1] or {}).get("minimax_keyframes") or [] + + +def test_a_continuation_scene_is_pinned_to_the_previous_scenes_last_frame(): + """H3 has no latent conditioning: a carried tail is, in the model's terms, noise it may + overwrite. Without a pin the seam matched and the rest of the shot knew nothing about the + scene before it — the chain produced unrelated clips.""" + node = h3_node() + previous = av_latent(video_t=37) + previous["samples"].unbind()[0][:, :, -1] = 7.0 # a mark only the last frame has + positive = [[torch.zeros(1, 8, 16), {}]] + + out, applied = node._h3_continuation_pin(positive, previous) + assert applied is True + pins = _pins(out) + assert [p["resolved_frame_index"] for p in pins] == [0] + latent = pins[0]["latent"] + assert latent.shape == (1, 24, 1, 48, 84) # exactly one latent frame + assert float(latent.max()) == 7.0 # and it is the LAST one + + +def test_an_explicit_anchor_outranks_the_carried_tail(): + """An anchor image (or a wired first_frame) is the user saying where this scene starts; + the tail is inferred. Two pins cannot share frame 0.""" + node = h3_node() + anchored = [[torch.zeros(1, 8, 16), {"minimax_keyframes": [ + {"resolved_frame_index": 0, "latent": torch.full((1, 24, 1, 48, 84), 3.0)}]}]] + out, applied = node._h3_continuation_pin(anchored, av_latent()) + assert applied is False + assert float(_pins(out)[0]["latent"].max()) == 3.0 + + +def test_the_opening_scene_has_nothing_to_continue_from(): + node = h3_node() + positive = [[torch.zeros(1, 8, 16), {}]] + out, applied = node._h3_continuation_pin(positive, None) + assert applied is False and _pins(out) == [] + + +def test_an_unreadable_carry_source_skips_rather_than_failing_the_render(): + node = h3_node() + 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) == []