diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js index fd3ee12c..d251cd44 100644 --- a/movie_editor/frontend/engine_settings.js +++ b/movie_editor/frontend/engine_settings.js @@ -233,7 +233,7 @@ { name: "anchor_shift_frames", label: "Frames to drop", kind: "int", default: 8, min: 8, max: 512, step: 8, dependsOn: "anchor_shift", hint: "How much of the front to drop at the shift, in real frames. 8 (one latent frame) removes just the anchor itself; more also removes the settling-in region where the shot is still visibly 'leaving' the reference still. Raise it if the opening still looks like it departed from a photo. The clip doesn't get shorter — whatever is dropped here is regrown at the end by pass 2." }, { name: "anchor_shift_restart_sigma", label: "Pass 2 restart sigma", kind: "float", default: 0.0, min: 0.0, max: 0.999, step: 0.001, dependsOn: "anchor_shift", - hint: "Where pass 2 re-enters. 0 (default) continues from where pass 1 stopped, so the total cost stays one full schedule. Setting it higher than the shift sigma rewinds — pass 2 re-noises the shifted latent back up and re-denoises from there, giving more freedom to rebuild the regrown tail and blend the joint, at the cost of repeating those steps and partly overwriting what pass 1 established. Try it if the seam is visible or the new ending looks thin." }, + hint: "Picks between the two modes. 0 (default) = CONTINUE: pass 1 stops at the shift sigma and pass 2 resumes from exactly that state with no re-noising — cost stays one full schedule. Anything above 0 = REWIND: pass 1 runs the WHOLE schedule to a finished clip, the shift is applied to that, then pass 2 re-noises it up to this sigma and denoises down. That's the standard img2img recipe and it is only valid on a finished latent, which is why pass 1 has to complete first. Rewind gives much more freedom to rebuild the regrown tail and blend the joint, and costs a full schedule plus the tail. Try it around 0.9 if continue leaves a visible seam or a thin ending." }, { name: "anchor_shift_tail", label: "Regrown tail starts from", kind: "combo", choices: ["extend_last", "wrap", "empty"], default: "extend_last", dependsOn: "anchor_shift", hint: "What fills the freed end after the slide. 'extend_last' (default) repeats the final frame so pass 2 grows the new ending out of something continuous with it. 'wrap' puts the dropped head back at the end — continuous, but it re-shows the reference image at the END of the clip. 'empty' zeroes it and lets pass 2 generate freely — most freedom, most likely to drift from the rest of the shot." }, { name: "anchor_shift_fresh_audio", label: "Regenerate audio in pass 2", kind: "bool", default: true, dependsOn: "anchor_shift", diff --git a/samplers.py b/samplers.py index 92005c7b..b795144c 100644 --- a/samplers.py +++ b/samplers.py @@ -2496,7 +2496,7 @@ class FunPackLTXAVSceneChainSampler: }), "anchor_shift_restart_sigma": ("FLOAT", { "default": 0.0, "min": 0.0, "max": 0.999, "step": 0.001, - "tooltip": "Where pass 2 re-enters the schedule. 0 (default) means 'continue from where pass 1 stopped' — the cheapest option, total cost stays one full schedule. Setting it HIGHER than anchor_shift_sigma rewinds: pass 2 re-noises the shifted latent back up to that sigma and re-denoises from there, giving the model more freedom to rebuild the regrown tail and blend the seam, at the cost of repeating those steps (and of partly overwriting what pass 1 established). Try this if the joint is visible or the regrown tail looks thin at the default.", + "tooltip": "Picks between the two modes. 0 (default) = CONTINUE: pass 1 stops at the shift sigma and pass 2 resumes from exactly that state, no re-noising. Total cost stays one full schedule. Any value above 0 = REWIND: pass 1 instead runs the WHOLE schedule to a finished clip, the shift is applied to that, and pass 2 re-noises it up to this sigma and re-denoises down — the standard img2img recipe, which is only valid on a finished latent, which is why pass 1 has to complete first. Rewind gives the model far more freedom to rebuild the regrown tail and blend the joint, and costs a full schedule PLUS the tail. Try rewind (around 0.9) if continue leaves a visible seam or a thin ending.", }), "anchor_shift_tail": (["extend_last", "wrap", "empty"], { "default": "extend_last", @@ -2951,14 +2951,21 @@ class FunPackLTXAVSceneChainSampler: def _sample_chunk(self, model, sampler, sigmas, seed, cfg, positive, negative, latent, pbar=None, step_offset=0, alg_guide_tail_frames=0, alg_guide_blur_strength=2.0, alg_guide_blur_sigma_threshold=0.975, - bounded_attention_enabled=False): + bounded_attention_enabled=False, continue_from_state=False): if sampler is None: raise ValueError("sampler input is required.") if not isinstance(sigmas, torch.Tensor): raise ValueError("sigmas input must be a SIGMAS tensor.") latent = self._clone_latent(latent) samples = latent["samples"] - noise = comfy.sample.prepare_noise(samples, int(seed)) + # continue_from_state: `samples` is ALREADY the noisy x at sigmas[0] (a run resumed + # mid-trajectory), not a clean image. comfy's CONST scaling is + # x = s*noise + (1-s)*latent_image, which treats latent_image as CLEAN and re-noises + # it — on a mid-trajectory latent that scales the picture down by (1-s) and buries it + # under a fresh s of noise. Passing the state as BOTH terms makes the expression + # collapse to an identity (s*x + (1-s)*x == x), so sampling resumes from exactly the + # state we hand it. Only correct when the caller really is mid-trajectory. + noise = samples if continue_from_state else comfy.sample.prepare_noise(samples, int(seed)) def _progress_cb(step, _denoised, _x, _total_steps): if pbar is not None: @@ -3062,16 +3069,21 @@ class FunPackLTXAVSceneChainSampler: return None # threshold above the first sigma, or not reached at all restart = float(restart_sigma) if restart <= 0: + # CONTINUE: pass 1 stops at the cut and pass 2 resumes from that exact state. + # Pass 2 must NOT re-noise (see _sample_chunk's continue_from_state). + first, second, resume = sigmas[:cut + 1], sigmas[cut:], True start = cut else: + # REWIND: pass 2 re-noises to a HIGHER sigma, which is only meaningful on a CLEAN + # latent — so pass 1 runs the whole schedule first and hands over a finished clip. + # Same shape as segmented detailing's stage-2 tail. Costs a full schedule + tail. start = _first_at_or_below(restart) if start is None: return None - first = sigmas[:cut + 1] - second = sigmas[start:] + first, second, resume = sigmas, sigmas[start:], False if first.numel() < 2 or second.numel() < 2: return None - return first, second, vals[cut], vals[start] + return first, second, vals[cut], vals[start], resume def _anchor_shift_latent(self, latent, drop, tail_mode, fresh_audio): """Slide the video stream left by `drop` latent frames and refill the freed tail. @@ -5472,20 +5484,26 @@ class FunPackLTXAVSceneChainSampler: if _shift_split is None: run_mechanisms.append(f"anchor_shift(SKIPPED: {_reason})") if _shift_split is not None: - _sig_a, _sig_b, _cut_at, _restart_at = _shift_split + _sig_a, _sig_b, _cut_at, _restart_at, _resume = _shift_split _pass1 = self._sample_chunk( model, sampler, _sig_a, scene_seed, cfg, scene_positive, scene_negative, chunk, **_sample_kwargs) _shifted, _dropped = self._anchor_shift_latent( _pass1, _shift_drop, anchor_shift_tail, anchor_shift_fresh_audio) - # Fresh noise for pass 2: reusing scene_seed's field would re-apply the - # exact noise pass 1 just resolved, against content that has moved. + # Continue mode resumes from the handed-over state (no re-noise, so the + # seed is irrelevant); rewind mode re-noises a finished clip, where fresh + # noise is wanted — reusing scene_seed there would re-apply the exact field + # pass 1 just resolved, against content that has since moved. + _kw2 = dict(_sample_kwargs) + _kw2["step_offset"] = _sample_kwargs["step_offset"] + (_sig_a.numel() - 1) + _kw2["continue_from_state"] = _resume sampled = self._sample_chunk( model, sampler, _sig_b, scene_seed + 4242, cfg, scene_positive, - scene_negative, _shifted, **_sample_kwargs) + scene_negative, _shifted, **_kw2) _pinned_n = self._anchor_pinned_frames(chunk) run_mechanisms.append( - f"anchor_shift(cut@{_cut_at:g} restart@{_restart_at:g}, " + f"anchor_shift({'continue' if _resume else 'rewind'} " + f"cut@{_cut_at:g} restart@{_restart_at:g}, " f"dropped {_dropped} of {_pinned_n} pinned latent frames, " f"tail={anchor_shift_tail}" f"{', fresh audio' if anchor_shift_fresh_audio else ''})") diff --git a/tests/test_anchor_shift.py b/tests/test_anchor_shift.py index d6304678..2a595b88 100644 --- a/tests/test_anchor_shift.py +++ b/tests/test_anchor_shift.py @@ -14,6 +14,7 @@ import sys import types from pathlib import Path +import pytest import torch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) @@ -47,19 +48,30 @@ def _latent(frames=10, audio=True): # ── schedule splitting ────────────────────────────────────────────────────── -def test_split_cuts_at_the_requested_sigma_and_continues_by_default(): - first, second, cut_at, restart_at = _node()._anchor_shift_split_sigmas(SCHEDULE, 0.909, 0.0) +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_restart_sigma_above_the_cut_rewinds_pass_two(): - first, second, cut_at, restart_at = _node()._anchor_shift_split_sigmas(SCHEDULE, 0.725, 0.975) +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(): @@ -154,3 +166,71 @@ def test_pinned_frames_is_zero_for_a_real_t2v_scene(): 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)