Files
olivv-cs-ComfyUI-FunPack/tests/test_second_pass_multiscene.py
T
digital-garbageandClaude Opus 5 7329cd9680 fix: multi-scene chains work with a resolution-changing second pass
second_pass_op="upscale_2x" hands a scene back at twice the latent size while
every LATER scene is still built from latent_template at the original size.
Everything that crosses a scene boundary — carried overlap frames, the anchor's
continuation, the soft join, the JoyAI memory frame, per-scene guide sources —
is spliced into a chunk on the template's grid, so the second scene died on the
shape mismatch and the combination was simply unavailable.

One choke point, _match_template_resolution, brings the carried material back
before it is used, applied at the three places a finished scene crosses into the
next one. Each scene still samples and OUTPUTS at 2x: only what is carried gets
resampled, and only downwards, which is the direction that survives it — those
frames exist to say "continue from here", which a resample preserves far better
than invented detail would.

Identity no-op when the grids already agree, so any run without a resizing op is
bit-identical. Audio has no spatial axes and is never touched. A stale
noise_mask is dropped rather than reapplied at the wrong scale, and a resample
that throws costs continuity with a console line, not the run.

No grid guard on the chain blend: second_pass and second_pass_op are run-level
settings applied to every scene, so a chain cannot hold two resolutions — every
scene is upscaled or none is. Both tooltips said this combination dropped
multi-scene; they now say how it works.

Also: About reads "FunPack 3" with the codename on its own line, rather than
attaching the major to the codename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:05:52 +03:00

155 lines
6.1 KiB
Python

"""Multi-scene chains survive a resolution-changing second_pass_op.
`second_pass_op="upscale_2x"` hands back a scene at twice the latent size, but every later
scene is still built from `latent_template` at the original size. Everything that crosses a
scene boundary — the carried overlap, the anchor's continuation, the soft join, the JoyAI
memory frame, per-scene guide sources — is spliced into a chunk on the template's grid, so
before this fix the second scene died on a shape mismatch and multi-scene + second pass was
simply unavailable.
`_match_template_resolution` is the single choke point. What matters:
1. It brings a resized scene back to the reference grid, and leaves audio alone.
2. It is an exact no-op (same object) when the grids already agree, so every run that does
not resize anything stays bit-identical.
3. It never raises — a failed resample must not take the whole chain down.
"""
import sys
import types
from pathlib import Path
import pytest
import torch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
for _name in (
"comfy", "comfy.k_diffusion", "comfy.k_diffusion.sampling",
"comfy.model_sampling", "comfy.nested_tensor", "comfy.sample",
"comfy.samplers", "comfy.utils",
):
sys.modules.setdefault(_name, types.ModuleType(_name))
sys.modules["comfy.nested_tensor"].NestedTensor = object
import samplers # noqa: E402
class _FakeNested(list):
is_nested = True
def unbind(self):
return list(self)
@pytest.fixture
def nested_stub():
comfy_mod = sys.modules["comfy"]
nested_mod = sys.modules["comfy.nested_tensor"]
saved = (nested_mod.NestedTensor, getattr(comfy_mod, "nested_tensor", None))
nested_mod.NestedTensor = _FakeNested
comfy_mod.nested_tensor = nested_mod
yield
nested_mod.NestedTensor = saved[0]
if saved[1] is None:
delattr(comfy_mod, "nested_tensor")
else:
comfy_mod.nested_tensor = saved[1]
def _node():
return samplers.FunPackLTXAVSceneChainSampler()
def _latent(h=16, w=24, frames=5):
return {"samples": torch.randn(1, 128, frames, h, w)}
def _av_latent(h=16, w=24, frames=5, audio=40):
return {"samples": _FakeNested([torch.randn(1, 128, frames, h, w),
torch.randn(1, 8, audio)])}
# ── the resample ────────────────────────────────────────────────────────────
def test_an_upscaled_scene_comes_back_to_the_template_grid():
out = _node()._match_template_resolution(_latent(32, 48), _latent(16, 24))
assert out["samples"].shape[-2:] == (16, 24)
def test_frame_count_and_channels_are_preserved():
"""Only the spatial axes move — a resample that changed the duration would desync the
carried frames it exists to make usable."""
out = _node()._match_template_resolution(_latent(32, 48, frames=7), _latent(16, 24, frames=5))
assert out["samples"].shape[:3] == (1, 128, 7)
def test_matching_grids_are_an_exact_no_op():
"""Identity, not equality: every run without a resizing op must be bit-identical, and
the cheapest proof of that is that nothing was rebuilt."""
latent = _latent(16, 24)
assert _node()._match_template_resolution(latent, _latent(16, 24)) is latent
def test_audio_is_never_touched(nested_stub):
src = _av_latent(32, 48, audio=40)
before = src["samples"][1].clone()
out = _node()._match_template_resolution(src, _av_latent(16, 24, audio=40))
video, audio = out["samples"].unbind()
assert video.shape[-2:] == (16, 24)
assert torch.equal(audio, before), "audio has no spatial axes to resample"
def test_a_stale_mask_is_dropped():
"""A noise_mask describes the OLD grid; the caller builds a fresh one for the chunk it
splices into, and keeping this one would apply it at the wrong scale."""
src = _latent(32, 48)
src["noise_mask"] = torch.zeros(1, 1, 5, 32, 48)
out = _node()._match_template_resolution(src, _latent(16, 24))
assert "noise_mask" not in out
def test_the_source_latent_is_never_mutated():
src = _latent(32, 48)
before = src["samples"].clone()
_node()._match_template_resolution(src, _latent(16, 24))
assert torch.equal(src["samples"], before)
def test_a_smaller_scene_is_grown_to_the_template():
"""Not reachable from upscale_2x, which only grows — but a scene SMALLER than the
template would corrupt the splice just as surely, so it is handled rather than passed
through at the wrong shape."""
out = _node()._match_template_resolution(_latent(8, 12), _latent(16, 24))
assert out["samples"].shape[-2:] == (16, 24)
def test_constant_input_survives_the_resample():
"""The carried frames are spliced next to real generated ones, so the resample has to be
energy-preserving in the DC sense — a flat region must not come back brighter or darker."""
flat = {"samples": torch.full((1, 128, 3, 32, 48), 0.7)}
out = _node()._match_template_resolution(flat, _latent(16, 24))
assert torch.allclose(out["samples"], torch.full_like(out["samples"], 0.7), atol=1e-4)
# ── it must never take the chain down ───────────────────────────────────────
def test_an_unreadable_latent_returns_unchanged():
junk = {"samples": "not a tensor"}
assert _node()._match_template_resolution(junk, _latent(16, 24)) is junk
def test_an_unreadable_template_returns_unchanged():
latent = _latent(32, 48)
assert _node()._match_template_resolution(latent, {"samples": None}) is latent
def test_a_failing_resample_is_reported_and_survived(monkeypatch, capsys):
"""A resample that blows up must cost continuity, not the whole run — and must say so,
because silently carrying the wrong grid is how this became invisible in the first place."""
import detailing
monkeypatch.setattr(detailing, "_downscale_to",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
latent = _latent(32, 48)
assert _node()._match_template_resolution(latent, _latent(16, 24)) is latent
assert "continuity" in capsys.readouterr().out