Reported from the first live run: the idea works (opening reads as t2v, character details preserved) but the output is full of noise artifacts, as if under-denoised, with the last steps seemingly skipped. That was a real bug, not the concept. comfy's CONST scaling is x = sigma*noise + (1-sigma)*latent_image, and it treats latent_image as a CLEAN image. Pass 2 was handed the PARTIALLY DENOISED latent as latent_image, so at a 0.725 cut it entered at 0.725*fresh_noise + 0.275*picture — pass 1's content attenuated to a quarter and buried under a full dose of new noise, with only the tail steps left to recover. Exactly the reported symptom. I cited segmented detailing's stage-2 tail as the precedent for this re-entry, but that recipe feeds a FINISHED latent, where re-noising is correct. The precedent does not transfer mid-trajectory. My error. Fix — the two modes are now actually different things: - CONTINUE (restart sigma 0, the default): pass 2 resumes from exactly the state pass 1 handed over. Passing that state as BOTH noise and latent_image collapses the CONST expression to an identity (s*x + (1-s)*x == x), so no noise is added at all. Cost still one full schedule. - REWIND (restart sigma > 0): pass 1 now runs the WHOLE schedule to a finished clip first, the shift is applied to that, and pass 2 re-noises it upward and denoises down. That is the img2img recipe, and it is valid precisely because the input is finished. Costs a full schedule plus the tail. Also fixed pass 2's progress offset, which restarted at pass 1's, so the bar appeared to stall short of the end. Two new tests reproduce the CONST arithmetic directly and assert the identity holds in continue mode and does not in rewind mode. Their comfy stubs are now snapshot/restored — the first version leaked into other modules and shifted the suite off baseline (3 unrelated tests changed result). Back to baseline: 22 pre-existing failures, 272 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
11 KiB
Python
237 lines
11 KiB
Python
"""anchor_shift — "fake t2v": run with the real untouched i2v anchor, then delete it.
|
|
|
|
The point of the feature is that the anchor is NOT weakened on the way in (ALG blurs it and
|
|
loses character detail; Best-FaceID overlap tokens approximate it and lose some too) — it is
|
|
pinned at full strength for pass 1 and then removed from the clip entirely. So the invariants
|
|
worth testing are about the shift itself:
|
|
|
|
1. The schedule is cut into two passes that each still have a real step, or refused outright.
|
|
2. The slide drops the head WITHOUT shortening the clip — no extra frames are ever generated.
|
|
3. The pin is dropped with the frame it belonged to (pass 2 must run unpinned).
|
|
4. Audio is reset when asked, since pass 1's audio was formed against frames that moved.
|
|
"""
|
|
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
|
|
|
|
|
|
# The standard LTX distilled schedule: plateau, then structure at 0.909, then detail.
|
|
SCHEDULE = torch.tensor([1.0, 0.975, 0.909, 0.725, 0.422, 0.0])
|
|
|
|
|
|
def _node():
|
|
return samplers.FunPackLTXAVSceneChainSampler()
|
|
|
|
|
|
def _latent(frames=10, audio=True):
|
|
video = torch.arange(frames, dtype=torch.float32).view(1, 1, frames, 1, 1).repeat(1, 4, 1, 2, 2)
|
|
latent = {"samples": video, "noise_mask": torch.zeros_like(video)}
|
|
if audio:
|
|
latent["_audio"] = torch.ones(1, 4, frames * 2)
|
|
return latent
|
|
|
|
|
|
# ── schedule splitting ──────────────────────────────────────────────────────
|
|
|
|
def test_continue_mode_stops_at_the_cut_and_resumes_from_that_state():
|
|
first, second, cut_at, restart_at, resume = _node()._anchor_shift_split_sigmas(
|
|
SCHEDULE, 0.909, 0.0)
|
|
# float32: the schedule stores 0.909 as 0.90899997, so compare loosely.
|
|
assert abs(cut_at - 0.909) < 1e-5 and abs(restart_at - 0.909) < 1e-5
|
|
# pass 1 ends ON the cut sigma; pass 2 resumes from it, so no step is run twice.
|
|
assert [round(v, 3) for v in first.tolist()] == [1.0, 0.975, 0.909]
|
|
assert [round(v, 3) for v in second.tolist()] == [0.909, 0.725, 0.422, 0.0]
|
|
# resume=True is load-bearing: pass 2 receives a MID-TRAJECTORY latent, and comfy's
|
|
# CONST scaling (x = s*noise + (1-s)*latent) would otherwise treat it as a clean image
|
|
# and re-noise it — scaling the picture to (1-s) under a fresh s of noise. That is the
|
|
# bug that made the first live run come out under-denoised.
|
|
assert resume is True
|
|
|
|
|
|
def test_rewind_mode_runs_pass_one_to_completion_first():
|
|
"""A re-noise to a HIGHER sigma is only meaningful on a clean latent, so rewind mode
|
|
must hand pass 2 a finished clip — not a mid-trajectory one."""
|
|
first, second, cut_at, restart_at, resume = _node()._anchor_shift_split_sigmas(
|
|
SCHEDULE, 0.725, 0.975)
|
|
assert abs(cut_at - 0.725) < 1e-5 and abs(restart_at - 0.975) < 1e-5
|
|
assert [round(v, 3) for v in first.tolist()] == [1.0, 0.975, 0.909, 0.725, 0.422, 0.0]
|
|
assert [round(v, 3) for v in second.tolist()] == [0.975, 0.909, 0.725, 0.422, 0.0]
|
|
assert resume is False # clean input -> re-noising is correct here
|
|
|
|
|
|
def test_split_refuses_when_a_pass_would_have_no_steps():
|
|
n = _node()
|
|
# Threshold at/above the first sigma: pass 1 would be a single point, no step.
|
|
assert n._anchor_shift_split_sigmas(SCHEDULE, 1.0, 0.0) is None
|
|
# Threshold below everything in the schedule: never reached.
|
|
assert n._anchor_shift_split_sigmas(SCHEDULE, 0.001, 0.0) is None
|
|
# One step either side is legitimate, even on a minimal 3-sigma schedule.
|
|
assert n._anchor_shift_split_sigmas(torch.tensor([1.0, 0.5, 0.0]), 0.5, 0.0) is not None
|
|
# ...but cutting at the FINAL sigma leaves pass 2 a single point, no step.
|
|
assert n._anchor_shift_split_sigmas(torch.tensor([1.0, 0.5, 0.0]), 0.0, 0.0) is None
|
|
|
|
|
|
def test_split_refuses_a_degenerate_schedule():
|
|
n = _node()
|
|
assert n._anchor_shift_split_sigmas(torch.tensor([1.0, 0.0]), 0.5, 0.0) is None
|
|
assert n._anchor_shift_split_sigmas(None, 0.9, 0.0) is None
|
|
|
|
|
|
# ── the slide ───────────────────────────────────────────────────────────────
|
|
|
|
def test_slide_drops_the_head_without_shortening_the_clip():
|
|
latent = _latent(frames=10)
|
|
shifted, dropped = _node()._anchor_shift_latent(latent, 2, "extend_last", False)
|
|
assert dropped == 2
|
|
v = shifted["samples"]
|
|
# Length preserved — the feature must never cost extra generated frames.
|
|
assert v.shape[2] == 10
|
|
# Frames 2..9 slid to 0..7; the anchor (frame 0) is gone from the clip entirely.
|
|
assert [float(v[0, 0, i, 0, 0]) for i in range(8)] == [2., 3., 4., 5., 6., 7., 8., 9.]
|
|
# ...and the freed tail continues from the last real frame.
|
|
assert [float(v[0, 0, i, 0, 0]) for i in (8, 9)] == [9., 9.]
|
|
|
|
|
|
def test_wrap_tail_puts_the_dropped_head_at_the_end():
|
|
shifted, _ = _node()._anchor_shift_latent(_latent(frames=10), 2, "wrap", False)
|
|
v = shifted["samples"]
|
|
assert [float(v[0, 0, i, 0, 0]) for i in (8, 9)] == [0., 1.]
|
|
|
|
|
|
def test_empty_tail_zeroes_the_freed_region():
|
|
shifted, _ = _node()._anchor_shift_latent(_latent(frames=10), 3, "empty", False)
|
|
v = shifted["samples"]
|
|
assert torch.equal(v[:, :, -3:], torch.zeros_like(v[:, :, -3:]))
|
|
assert float(v[0, 0, 0, 0, 0]) == 3.0
|
|
|
|
|
|
def test_slide_drops_the_pin_with_the_frame_it_belonged_to():
|
|
latent = _latent(frames=10)
|
|
assert "noise_mask" in latent
|
|
shifted, _ = _node()._anchor_shift_latent(latent, 1, "extend_last", False)
|
|
# Pass 2 must run unpinned — the pinned frame no longer exists.
|
|
assert "noise_mask" not in shifted
|
|
|
|
|
|
def test_slide_never_consumes_the_whole_clip():
|
|
shifted, dropped = _node()._anchor_shift_latent(_latent(frames=4), 99, "extend_last", False)
|
|
assert dropped == 3 # clamped to frames - 1
|
|
assert shifted["samples"].shape[2] == 4
|
|
|
|
|
|
def test_zero_drop_is_a_no_op():
|
|
shifted, dropped = _node()._anchor_shift_latent(_latent(frames=6), 0, "extend_last", True)
|
|
assert dropped == 0
|
|
assert torch.equal(shifted["samples"], _latent(frames=6)["samples"])
|
|
|
|
|
|
def test_source_latent_is_never_mutated():
|
|
latent = _latent(frames=8)
|
|
before = latent["samples"].clone()
|
|
_node()._anchor_shift_latent(latent, 3, "extend_last", True)
|
|
assert torch.equal(latent["samples"], before)
|
|
|
|
|
|
# ── the t2v guard ───────────────────────────────────────────────────────────
|
|
|
|
def test_pinned_frames_counts_the_anchor_prefix():
|
|
n = _node()
|
|
frames = 6
|
|
video = torch.zeros(1, 4, frames, 2, 2)
|
|
mask = torch.ones(1, 4, frames, 2, 2)
|
|
mask[:, :, :2] = 0.0 # two pinned anchor frames
|
|
assert n._anchor_pinned_frames({"samples": video, "noise_mask": mask}) == 2
|
|
|
|
|
|
def test_pinned_frames_is_zero_for_a_real_t2v_scene():
|
|
"""No anchor image attached — Easy Gen's default. Shifting would drop real content,
|
|
so the caller must skip: this is the 'fake t2v' trick and it needs an anchor to fake with."""
|
|
n = _node()
|
|
video = torch.zeros(1, 4, 6, 2, 2)
|
|
assert n._anchor_pinned_frames({"samples": video, "noise_mask": torch.ones(1, 4, 6, 2, 2)}) == 0
|
|
assert n._anchor_pinned_frames({"samples": video}) == 0 # no mask at all
|
|
assert n._anchor_pinned_frames({"samples": None}) == 0 # unreadable -> refuse, don't raise
|
|
|
|
|
|
# ── the re-noise bug this feature shipped with, locked down ─────────────────
|
|
|
|
@pytest.fixture
|
|
def capture_sample_custom():
|
|
"""Stub comfy.sample so we can see exactly what noise/latent the sampler receives.
|
|
|
|
Snapshot/restore, because these comfy stubs are SHARED module objects across every test
|
|
module — leaving mutations behind changes other files' results depending on collection
|
|
order (the same trap test_detailing.py hit with comfy.nested_tensor).
|
|
"""
|
|
mod = sys.modules["comfy.sample"]
|
|
comfy_mod = sys.modules["comfy"]
|
|
saved = (getattr(mod, "prepare_noise", None), getattr(mod, "sample_custom", None),
|
|
getattr(comfy_mod, "sample", None))
|
|
seen = {}
|
|
|
|
def prepare_noise(samples, seed, *a, **k):
|
|
return torch.full_like(samples, 7.0) # unmistakably "fresh noise"
|
|
|
|
def sample_custom(model, noise, cfg, sampler, sigmas, pos, neg, latent_image, **kw):
|
|
seen["noise"] = noise
|
|
seen["latent_image"] = latent_image
|
|
return latent_image
|
|
|
|
mod.prepare_noise = prepare_noise
|
|
mod.sample_custom = sample_custom
|
|
sys.modules["comfy"].sample = mod # attribute access, not just sys.modules
|
|
yield seen
|
|
for name, val in (("prepare_noise", saved[0]), ("sample_custom", saved[1])):
|
|
if val is None:
|
|
delattr(mod, name)
|
|
else:
|
|
setattr(mod, name, val)
|
|
if saved[2] is None:
|
|
delattr(comfy_mod, "sample")
|
|
else:
|
|
comfy_mod.sample = saved[2]
|
|
|
|
|
|
def _const_noise_scaling(sigma, noise, latent_image):
|
|
"""comfy's CONST rule, verbatim: x = s*noise + (1-s)*latent_image."""
|
|
return sigma * noise + (1.0 - sigma) * latent_image
|
|
|
|
|
|
def test_continue_from_state_makes_const_scaling_an_identity(capture_sample_custom):
|
|
"""The real fix. Pass 2 gets a MID-TRAJECTORY latent; handing it as both terms makes
|
|
comfy's scaling collapse to x, so sampling resumes exactly where pass 1 stopped."""
|
|
seen = capture_sample_custom
|
|
state = torch.randn(1, 4, 5, 2, 2)
|
|
_node()._sample_chunk(object(), object(), torch.tensor([0.725, 0.422, 0.0]), 1, 1.0,
|
|
[], [], {"samples": state}, continue_from_state=True)
|
|
assert torch.equal(seen["noise"], seen["latent_image"])
|
|
resumed = _const_noise_scaling(0.725, seen["noise"], seen["latent_image"])
|
|
assert torch.allclose(resumed, state, atol=1e-6)
|
|
|
|
|
|
def test_without_continue_from_state_the_latent_is_renoised(capture_sample_custom):
|
|
"""The other mode, and the shape of the original bug: fresh noise is mixed in and the
|
|
picture is scaled to (1-sigma). Correct for a CLEAN input, wrong mid-trajectory."""
|
|
seen = capture_sample_custom
|
|
clean = torch.randn(1, 4, 5, 2, 2)
|
|
_node()._sample_chunk(object(), object(), torch.tensor([0.725, 0.422, 0.0]), 1, 1.0,
|
|
[], [], {"samples": clean}, continue_from_state=False)
|
|
assert not torch.equal(seen["noise"], seen["latent_image"])
|
|
entered = _const_noise_scaling(0.725, seen["noise"], seen["latent_image"])
|
|
assert not torch.allclose(entered, clean, atol=1e-3)
|