diff --git a/studio/backend/core/inference/video.py b/studio/backend/core/inference/video.py index 10a10a57e2..24f7357434 100644 --- a/studio/backend/core/inference/video.py +++ b/studio/backend/core/inference/video.py @@ -619,6 +619,27 @@ class VideoBackend: # ── generation ─────────────────────────────────────────────────────────── + @staticmethod + def _reset_step_cache(pipe: Any) -> None: + """Clear FBCache residuals on the resident DiT(s) before a generation. + + diffusers keys the residuals on the long-lived transformer and no pipeline + resets them, so the next clip would compare against the previous request's + state: a shape mismatch when the resolution changed, stale reuse otherwise. + ``_reset_stateful_cache`` is the transformer-level entry point in diffusers + 0.39 (``reset_stateful_hooks`` lives only on the HookRegistry). Best-effort: + an uncached transformer is a silent no-op.""" + for name in ("transformer", "transformer_2"): + module = getattr(pipe, name, None) + reset = getattr(module, "_reset_stateful_cache", None) or getattr( + module, "reset_stateful_hooks", None + ) + if callable(reset): + try: + reset() + except Exception: # noqa: BLE001 -- reset is best-effort, never fail a generation + pass + def generate( self, *, @@ -704,6 +725,8 @@ class VideoBackend: if "callback_on_step_end" in call_params: kwargs["callback_on_step_end"] = _on_step + if state.transformer_cache: + self._reset_step_cache(pipe) with torch.inference_mode(): output = pipe(**kwargs) if cancel.is_set(): diff --git a/studio/backend/tests/test_video_backend.py b/studio/backend/tests/test_video_backend.py index f44ee0151a..f92ae1ebc6 100644 --- a/studio/backend/tests/test_video_backend.py +++ b/studio/backend/tests/test_video_backend.py @@ -227,6 +227,39 @@ def test_generate_defaults_from_variant(fake_runtime, tmp_path): assert call["guidance_scale"] == 1.0 +def test_generate_resets_step_cache_only_when_engaged(fake_runtime, tmp_path): + # FBCache residuals live on the long-lived DiT(s) and survive a generation, so + # the next clip at a new resolution would crash on stale state. generate must + # reset them when a cache is engaged (diffusers 0.39 exposes + # _reset_stateful_cache on the transformer; reset_stateful_hooks only exists on + # the HookRegistry) and must not touch an uncached load. transformer_2 (the Wan + # dual expert) resets too when present. + import dataclasses + + (tmp_path / "ltx-2.3-22b-distilled-1.1-Q4_K_M.gguf").write_bytes(b"w") + backend = VideoBackend() + backend.load_pipeline( + str(tmp_path), + gguf_filename = "ltx-2.3-22b-distilled-1.1-Q4_K_M.gguf", + base_repo = "Lightricks/LTX-2", + family_override = "ltx-2", + ) + resets = [] + backend._state.pipe.transformer = types.SimpleNamespace( + _reset_stateful_cache = lambda: resets.append("transformer") + ) + backend._state.pipe.transformer_2 = types.SimpleNamespace( + _reset_stateful_cache = lambda: resets.append("transformer_2") + ) + # No cache engaged -> no reset. + backend.generate(prompt = "a sloth") + assert resets == [] + # Cache engaged -> both resident DiTs reset before the pipe call. + backend._state = dataclasses.replace(backend._state, transformer_cache = "fbcache") + backend.generate(prompt = "a sloth") + assert resets == ["transformer", "transformer_2"] + + def test_is_ltx23_checkpoint_gguf(monkeypatch, tmp_path): # diffusers maps every LTX-2 single file to the 2.0 config; a 2.3 checkpoint # (9-row modulation tables in the header) must be detected so the loader