Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
178 lines
7.4 KiB
Python
178 lines
7.4 KiB
Python
"""Camera move: the warp maths and the wrapper on packed H3 latents."""
|
|
|
|
import types
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _needs_comfy(comfyui):
|
|
"""Imports comfy."""
|
|
|
|
|
|
def test_a_pan_shifts_the_picture_and_uncovered_cells_get_noise_at_the_step_level():
|
|
from modules.sampling.camera_move import Move, warp
|
|
video = torch.arange(1 * 3 * 4 * 4, dtype=torch.float32).reshape(1, 3, 4, 4)
|
|
out = warp(video, Move(pan_x=0.5), sigma=0.0, generator=torch.Generator().manual_seed(0))
|
|
assert torch.equal(out[:, 0], video[:, 0]) # frame 0 has not moved yet
|
|
assert torch.equal(out[:, 2][..., :2], video[:, 2][..., 2:]) # last frame: shifted 2 cells left
|
|
noisy = warp(video, Move(pan_x=0.5), sigma=0.5, generator=torch.Generator().manual_seed(0))
|
|
assert not torch.equal(noisy[:, 2][..., 2:], torch.zeros(1, 4, 2)) # uncovered cells are filled
|
|
|
|
|
|
def test_unwarp_puts_shown_cells_back_and_keeps_the_original_where_nothing_was_shown():
|
|
from modules.sampling.camera_move import Move, unwarp, warp
|
|
move = Move(pan_x=0.5)
|
|
video = torch.randn(2, 3, 4, 4)
|
|
moved = warp(video, move, 0.0, torch.Generator().manual_seed(0))
|
|
back = unwarp(moved, video, move)
|
|
assert torch.equal(back, video) # a model that echoes its input
|
|
|
|
|
|
def test_still_moves_and_nan_values_are_refused_or_clamped():
|
|
from modules.sampling.camera_move import Move, finite
|
|
assert Move().still() and not Move(zoom=1.2).still()
|
|
assert finite(float("nan"), 0.0, -1, 1) == 0.0 and finite(5, 0.0, -1, 1) == 1.0
|
|
assert Move(step=99).first_step(4) == 3 and Move(step=1).first_step(4) == 0
|
|
|
|
|
|
SHAPES = [torch.Size([1, 4, 4, 8, 8]), torch.Size([1, 3, 5])]
|
|
|
|
|
|
class Guider:
|
|
def __init__(self):
|
|
self.inner_model = types.SimpleNamespace(latent_shapes=SHAPES)
|
|
|
|
|
|
def _load(tiny, **values):
|
|
from comfy.patcher_extension import WrappersMP
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
patched, status = FunPackLoadModifiers.execute(tiny.patcher, {
|
|
"camera_move": {"enabled": True, "pan_x": 0.5, "step": 2, **values}}).result
|
|
sample = [w for ws in patched.wrappers[WrappersMP.SAMPLER_SAMPLE].values() for w in ws]
|
|
wrap = [w for ws in patched.wrappers[WrappersMP.APPLY_MODEL].values() for w in ws][-1]
|
|
return sample[-1] if sample else None, wrap, status
|
|
|
|
|
|
def _packed():
|
|
from conftest import packed_av
|
|
torch.manual_seed(5)
|
|
return packed_av(torch.randn(1, 4, 4, 8, 8), torch.randn(1, 3, 5))
|
|
|
|
|
|
def _to(i, steps=4):
|
|
s = torch.linspace(1.0, 0.0, steps + 1)
|
|
return {"sample_sigmas": s, "sigmas": s[i:i + 1]}
|
|
|
|
|
|
def _steps(sample, wrap, x0, shapes, latent=None, to=_to, steps=4, executor=None):
|
|
"""Drive the wrapper the way a sampler does: the sampler wrapper first, then every step."""
|
|
class Ex:
|
|
def __call__(self, *a, **k):
|
|
return a[4]
|
|
|
|
seen = []
|
|
|
|
def echo(x, t, *a, **k):
|
|
seen.append(x.clone())
|
|
return x
|
|
|
|
sample(Ex(), Guider(), None, {"seed": 7}, None, x0, torch.zeros_like(x0) if latent is None else latent, None, True)
|
|
sched = torch.linspace(1.0, 0.0, steps + 1)
|
|
outs = [wrap(executor or echo, x0, sched[i:i + 1], None, None, None, to(i, steps), latent_shapes=shapes)
|
|
for i in range(steps)]
|
|
return outs, seen
|
|
|
|
|
|
def test_a_model_that_echoes_its_input_sees_the_moved_picture_but_returns_the_original_until_the_last_step(tiny_h3):
|
|
sample, wrap, status = _load(tiny_h3)
|
|
assert "pan right 0.50" in status
|
|
x0, shapes = _packed()
|
|
outs, seen = _steps(sample, wrap, x0, shapes)
|
|
from conftest import unpacked
|
|
assert torch.equal(seen[0], x0) and torch.equal(outs[0], x0) # before 'from step'
|
|
assert not torch.equal(seen[1], x0) and torch.equal(outs[1], x0) # moved in, moved back out
|
|
assert torch.equal(unpacked(seen[1], shapes)[1], unpacked(x0, shapes)[1]) # sound untouched
|
|
assert not torch.equal(outs[3], x0) # the last call stays moved
|
|
|
|
|
|
def test_standing_down_cases_are_declared_and_leave_the_call_alone(tiny_h3):
|
|
from core import log
|
|
sample, wrap, _ = _load(tiny_h3)
|
|
x0, shapes = _packed()
|
|
log.new_run()
|
|
outs, _ = _steps(sample, wrap, x0, shapes, latent=torch.ones_like(x0))
|
|
assert all(torch.equal(o, x0) for o in outs)
|
|
assert any("not empty" in e["message"] for e in log.history())
|
|
log.new_run()
|
|
outs, _ = _steps(sample, wrap, x0, shapes, to=lambda i, n: {**_to(i, n), "context_window": object()})
|
|
assert all(torch.equal(o, x0) for o in outs) and any("context windows" in e["message"] for e in log.history())
|
|
|
|
|
|
def test_a_sampler_that_repeats_a_step_is_declared_and_the_rest_of_the_run_is_left_alone(tiny_h3):
|
|
from core import log
|
|
sample, wrap, _ = _load(tiny_h3, step=1)
|
|
x0, shapes = _packed()
|
|
log.new_run()
|
|
class Ex:
|
|
def __call__(self, *a, **k):
|
|
return a[4]
|
|
sample(Ex(), Guider(), None, {"seed": 1}, None, x0, torch.zeros_like(x0), None, True)
|
|
sched = torch.linspace(1.0, 0.0, 5)
|
|
calls = []
|
|
for i in (0, 1, 1, 2):
|
|
wrap(lambda x, t, *a, **k: (calls.append(x), x)[1], x0, sched[i:i + 1], None, None, None, _to(i), latent_shapes=shapes)
|
|
assert any("more than once per step" in e["message"] for e in log.history())
|
|
assert torch.equal(calls[-1], x0)
|
|
|
|
|
|
def test_no_move_is_set_and_the_wrapper_is_not_installed_and_says_so(tiny_h3):
|
|
from comfy.patcher_extension import WrappersMP
|
|
from core import log
|
|
from modules.sampling.modifiers.nodes import FunPackLoadModifiers
|
|
log.new_run()
|
|
patched, _ = FunPackLoadModifiers.execute(tiny_h3.patcher, {"camera_move": {"enabled": True}}).result
|
|
assert any("nothing to move" in e["message"] for e in log.history())
|
|
assert not patched.wrappers.get(WrappersMP.APPLY_MODEL)
|
|
|
|
|
|
def test_the_first_moved_call_is_marked_so_an_old_frame_push_is_not_added(tiny_h3):
|
|
sample, wrap, _ = _load(tiny_h3)
|
|
x0, shapes = _packed()
|
|
flags = []
|
|
|
|
def spy(x, t, c_concat=None, c_crossattn=None, control=None, transformer_options=None, **k):
|
|
flags.append(bool((transformer_options or {}).get("funpack_frame_change")))
|
|
return x
|
|
|
|
_steps(sample, wrap, x0, shapes, executor=spy)
|
|
assert flags == [False, True, False, False] # 'from step 2' of 4
|
|
|
|
|
|
def test_a_probe_call_is_neither_moved_nor_counted_as_a_step(tiny_h3):
|
|
sample, wrap, _ = _load(tiny_h3, step=1)
|
|
x0, shapes = _packed()
|
|
seen = []
|
|
outs, _ = _steps(sample, wrap, x0, shapes, to=lambda i, n: {**_to(i, n), "funpack_probe": True},
|
|
executor=lambda x, *a, **k: (seen.append(x), x)[1])
|
|
assert all(torch.equal(s, x0) for s in seen)
|
|
|
|
|
|
def test_a_repeated_step_never_logs_both_active_and_inactive(tiny_h3):
|
|
from core import log
|
|
sample, wrap, _ = _load(tiny_h3, step=1)
|
|
x0, shapes = _packed()
|
|
sched = torch.linspace(1.0, 0.0, 5)
|
|
|
|
class Ex: # the sampler: runs the steps inside the call
|
|
def __call__(self, *a, **k):
|
|
for i in (0, 1, 1, 2):
|
|
wrap(lambda x, *a, **k: x, x0, sched[i:i + 1], None, None, None, _to(i), latent_shapes=shapes)
|
|
return a[4]
|
|
|
|
log.new_run()
|
|
sample(Ex(), Guider(), None, {"seed": 1}, None, x0, torch.zeros_like(x0), None, True)
|
|
msgs = [e["message"] for e in log.history() if e["source"] == "FunPack Camera move"]
|
|
assert any("more than once" in m for m in msgs) and not any(m.startswith("Active") for m in msgs)
|