"""Shared T2AV inference pipeline for OmniVAE joint-AV checkpoints. Used by both ``infer/t2av/streamlit_app.py`` (interactive) and ``infer/t2av/infer_t2av.py`` (CLI batch). Wraps the **same** denoising / decoding / muxing helpers that ``omnivae_generation.trainer.joint_av.validation.run_joint_av_validation`` uses, so videos generated here are numerically identical to a one-prompt validation pass with the same prompt / seed / steps / cfg / cfg_mode. Public surface -------------- * :func:`load_joint_av_pipeline` -- build the full pipeline (text encoder + scheduler + video VAE + audio VAE + BridgedZImageJointModel with both branches and bridges restored from a checkpoint directory). * :func:`generate_one_av` -- run one prompt through the joint denoising loop, decode, pad/trim the audio so it matches ``num_frames / fps``, and (in ``joint_av`` mode) mux video + audio into a single ``.av.mp4`` whose video stream is bit-for-bit copied from the libx264 output (i.e. no frames dropped at the tail). Layout assumption for ``checkpoint_dir`` ---------------------------------------- The trainer's ``omnivae_generation.trainer.joint_av.save_split_branches`` writes the following structure (mirrored here):: checkpoint-XXXXXXXX/ transformer_video/ diffusers ZImageTransformer2DModel (video branch) transformer_audio/ diffusers ZImageTransformer2DModel (audio branch) bridges/bridges.safetensors + bridge_config.json tokenizer/, scheduler/, metadata.json A run-config file is expected two levels up from the snapshot (``run_dir/checkpoints/snapshots/checkpoint-XXXXXXXX``) and supplies text encoder / video VAE / audio VAE / transformer-branch configs. ``resolved_config.json`` (the canonical post-override form written by the trainer at run-dir creation) is preferred when present; ``resolved_config.yaml`` is used as a fall-back for older runs that predate the json sidecar. The text encoder and the two VAEs are *not* in the snapshot (frozen at training time), they are reloaded from the paths in the resolved config -- which can themselves be overridden at load time via the ``*_override`` arguments. """ from __future__ import annotations import copy import json import logging import os import random import subprocess import threading import time from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path from types import SimpleNamespace from typing import Any, Callable, Optional import imageio.v2 as imageio import torch import torch.nn.functional as F import torchaudio logger = logging.getLogger(__name__) def _can_use_transformers_device_map() -> bool: try: from transformers.utils import is_accelerate_available return bool(is_accelerate_available()) except Exception: return False # Module-level lock that serializes the parts of ``load_joint_av_pipeline`` # which are not thread-safe under "single process, N threads, N CUDA # devices". Two distinct failure modes have been observed: # # 1. Concurrent ``Module.to(cuda:N)`` for the ~6.5 GB transformer # branches across 3 devices: PyTorch's per-device caching allocator # is thread-safe per-call but its first-time bring-up (cuBLAS / # cuDNN handles, memory pool init) is not fully reentrant, and the # corruption surfaces later as an ``illegal memory access`` on the # very first inference kernel. # # 2. Concurrent ``AutoModel.from_pretrained(..., low_cpu_mem_usage=True)`` # of the *same* Qwen text encoder from the HF cache: transformers' # meta-tensor materialization races on shared module-state and one # of the loaders is left with empty meta parameters # (``Cannot copy out of meta tensor; no data!``). # # A single lock is held both around the parallel component-loading # block AND the subsequent ``.to(device)`` block. The trade-off: per- # slot loading becomes effectively serial across slots (3 ckpts = 3x # single-load time) but never crashes. Each slot's intra-load # components can still pipeline freely behind this lock (CPU init + # disk read while previous slot's GPU move drains). _PIPELINE_LOAD_LOCK = threading.Lock() # Backwards-compat alias for older callers; kept as an alias of the # same underlying lock so behaviour is unchanged. _GPU_INIT_LOCK = _PIPELINE_LOAD_LOCK def _env_truthy(name: str, default: bool = False) -> bool: value = os.environ.get(name) if value is None: return default return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} def _release_root() -> Path: explicit = os.environ.get("OMNIVAE_RELEASE_ROOT") or os.environ.get("OPEN_SOURCE_ROOT") candidates: list[Path] = [] if explicit: candidates.append(Path(explicit)) repo_root = Path(__file__).resolve().parents[2] candidates.extend([ repo_root / "open_source", repo_root.parent / "open_source", repo_root.parent.parent / "open_source", repo_root / "open_source" / "open_source", repo_root.parent / "open_source" / "open_source", repo_root.parent.parent / "open_source" / "open_source", ]) for candidate in candidates: candidate = candidate.expanduser() if (candidate / "models").is_dir() and (candidate / "eval").is_dir(): return candidate.resolve() return (repo_root / "open_source").resolve() def _resolve_release_path(value: str | os.PathLike | None) -> str | None: if value is None: return None text = str(value).strip() if not text: return None if text.startswith(("models/", "eval/")): return str((_release_root() / text).resolve()) return text def _resolve_release_model_paths(config: dict[str, Any]) -> None: text_cfg = config.get("text_encoder") if isinstance(text_cfg, dict): resolved = _resolve_release_path(text_cfg.get("model_name_or_path")) if resolved: text_cfg["model_name_or_path"] = resolved if resolved.startswith("/"): text_cfg.setdefault("local_files_only", True) vae_cfg = config.get("vae") if isinstance(vae_cfg, dict): resolved = _resolve_release_path(vae_cfg.get("model_name_or_path")) if resolved: vae_cfg["model_name_or_path"] = resolved audio_vae_cfg = config.get("audio_vae") if isinstance(audio_vae_cfg, dict): key = "model_path" if "model_path" in audio_vae_cfg else "model_name_or_path" resolved = _resolve_release_path(audio_vae_cfg.get(key)) if resolved: audio_vae_cfg[key] = resolved scheduler_cfg = config.get("scheduler") if isinstance(scheduler_cfg, dict): resolved = _resolve_release_path(scheduler_cfg.get("model_name_or_path")) if resolved: scheduler_cfg["model_name_or_path"] = resolved if resolved.startswith("/"): scheduler_cfg.setdefault("local_files_only", True) # ---------------------------------------------------------------------- # Loader # ---------------------------------------------------------------------- @dataclass class T2AVPipeline: """Container for an instantiated T2AV inference pipeline. Used as a ``SimpleNamespace``-style record; accessed by attribute name from both UIs to keep call sites readable. """ joint_model: Any # BridgedZImageJointModel (on device, eval()) tokenizer: Any text_encoder: Any # on device, eval() video_vae: Any # on device, eval() audio_vae: Any # on device, eval() scheduler: Any # FlowMatchEulerDiscreteScheduler run_config: dict # resolved_config.yaml (deep-copied) run_dir: Path checkpoint_dir: Path checkpoint_step: int device: torch.device train_patch_size: int train_f_patch_size: int shift_v: float shift_a: float predict_target: str def _apply_runtime_patches() -> None: """Mirror the patches that the training entry / t2v eval loader apply before instantiating diffusers' Z-Image transformer. Idempotent. """ from omnivae_generation.trainer.runtime_patches import ( patch_diffusers_zimage_forward_block_stacks, patch_diffusers_zimage_real_rope, patch_transformers_qwen3_5_disable_fast_path, ) patch_diffusers_zimage_real_rope() patch_diffusers_zimage_forward_block_stacks() # The training config sets ``disable_qwen3_5_fast_path: true`` for the # frozen text encoder. The patch is idempotent, so applying it here # unconditionally is safe even if the underlying yaml disagrees. patch_transformers_qwen3_5_disable_fast_path() def _read_bridge_config(checkpoint_dir: Path) -> dict[str, Any]: """Prefer the snapshot-local ``bridge_config.json`` because the bridges' weight shapes are pinned by training-time config; falling back to the yaml would silently re-init at a different ``bridge_interval`` and break the strict bridge load. """ descriptor_path = checkpoint_dir / "bridges" / "bridge_config.json" if descriptor_path.is_file(): return json.loads(descriptor_path.read_text(encoding="utf-8")) return {} def _load_branch_from_pretrained( branch_dir: Path, branch_cfg: dict, *, dtype: torch.dtype, device: Optional[torch.device] = None, ) -> Any: """Load one Z-Image transformer branch directly via diffusers' ``from_pretrained``. When ``device`` is provided (and is a CUDA device), the branch is loaded **directly onto that GPU** via ``device_map={"": device}``; no CPU staging, no meta-tensor materialization, no follow-up ``.to(device)`` move. This is essential for parallel multi-GPU slot loading because the "meta -> CPU -> GPU" path that ``low_cpu_mem_ usage=True`` previously triggered is not thread-safe under concurrent ``from_pretrained`` calls for the same source (the transformers / accelerate internals race on materialisation bookkeeping and one of the callers ends up with empty meta parameters that raise ``Cannot copy out of meta tensor`` later). Loading direct-to-GPU also saves the CPU->GPU memcpy time on the happy path. Compared to the previous "build with random init + state_dict copy" path (``omnivae_generation.trainer.modeling.build_transformer`` followed by ``omnivae_generation.trainer.joint_av.load_pretrained_branches``), this saves the random-init allocation pass for ~6.5 GB of parameters per branch and uses safetensors' mmap-backed reader to populate the model in place. The CALLER is responsible for setting the class-level ``ZImageTransformerBlock._laion_force_disable_modulation`` / ``FinalLayer._laion_default_modulation`` flags to match ``branch_cfg['use_timestep']`` BEFORE invoking this function; these flags affect parameter allocation in ``ZImageTransformer2DModel .__init__`` and are global, so they cannot be set safely inside a worker thread when both branches load concurrently. """ from diffusers import ZImageTransformer2DModel from omnivae_generation.trainer.modeling import ( configure_transformer_prediction_target, configure_transformer_timestep_usage, ) from_pretrained_kwargs: dict[str, Any] = { "torch_dtype": dtype, } use_device_map = device is not None and device.type == "cuda" and _can_use_transformers_device_map() if use_device_map: # Pin the entire model on this slot's CUDA device. ``device_map`` # bypasses ``low_cpu_mem_usage``'s meta-tensor path and is # internally re-entrant under accelerate.dispatch_model_with_state, # so 3 worker threads loading 3 distinct branch paths to 3 distinct # CUDA devices don't trip over each other. from_pretrained_kwargs["device_map"] = {"": str(device)} elif device is None or device.type != "cuda": # CPU fallback for ``device=None`` or ``cpu``. Keep the previous # low_cpu_mem_usage path here because there's no GPU pinning to # talk about and the mmap loader is still strictly better than # the random-init route. from_pretrained_kwargs["low_cpu_mem_usage"] = True transformer = ZImageTransformer2DModel.from_pretrained( str(branch_dir), **from_pretrained_kwargs, ) if device is not None and not use_device_map: transformer.to(device) use_timestep = bool(branch_cfg.get("use_timestep", True)) configure_transformer_timestep_usage(transformer, use_timestep) configure_transformer_prediction_target(transformer, branch_cfg.get("predict_target", "v")) # Pad tokens are nn.Parameters and round-trip via safetensors. Older # snapshots that pre-date the zero-init fix can leave them at # ``torch.empty`` values; replace any non-finite contents with zeros # so attention masks don't propagate NaNs. for name in ("x_pad_token", "cap_pad_token", "siglip_pad_token"): tensor = getattr(transformer, name, None) if isinstance(tensor, torch.nn.Parameter): with torch.no_grad(): if not torch.isfinite(tensor).all(): tensor.data.zero_() tensor.requires_grad_(False) return transformer def _emit_progress(message: str) -> None: """Single-line per-stage progress emitter. Goes to both python logging and stdout (with explicit flush) so that when this loader runs under streamlit the user sees the progress live in the terminal that launched the server. We intentionally do NOT touch any streamlit APIs here because this function is also called from worker threads (parallel mode), and streamlit only supports being called from the main script thread. """ logger.info("%s", message) print(message, flush=True) def _load_text_components_on_device( text_cfg: dict, dtype: torch.dtype, *, device: Optional[torch.device] = None, ) -> tuple[Any, Any, int]: """Drop-in replacement for ``omnivae_generation.trainer.modeling.load_text_components`` that pins the text encoder to a specific GPU at ``from_pretrained`` time instead of going through the meta-tensor + ``.to(device)`` path baked into the trainer helper. Why we cannot reuse the trainer helper here: it hardcodes ``low_cpu_mem_usage=True`` for every call. Under concurrent slot loading (3 threads each calling ``AutoModel.from_pretrained`` for the SAME Qwen text encoder), transformers' meta-tensor materialisation races on shared module state and one of the callers ends up with empty meta parameters that raise ``Cannot copy out of meta tensor; no data!`` on the follow-up ``.to(device)``. Passing ``device_map={"": device}`` instead loads direct-to-GPU via ``accelerate.dispatch_model``, which IS thread- safe across distinct device targets, and avoids the meta detour entirely. """ from transformers import AutoModel, AutoTokenizer if text_cfg.get("disable_qwen3_5_fast_path", False): from omnivae_generation.trainer.runtime_patches import patch_transformers_qwen3_5_disable_fast_path patch_transformers_qwen3_5_disable_fast_path() tokenizer = AutoTokenizer.from_pretrained( text_cfg["model_name_or_path"], trust_remote_code=text_cfg.get("trust_remote_code", False), local_files_only=text_cfg.get("local_files_only", False), ) if tokenizer.pad_token is None and tokenizer.eos_token is not None: tokenizer.pad_token = tokenizer.eos_token tokenizer.padding_side = "right" model_kwargs: dict[str, Any] = { "trust_remote_code": text_cfg.get("trust_remote_code", False), "torch_dtype": dtype, "local_files_only": text_cfg.get("local_files_only", False), } attn_implementation = text_cfg.get("attn_implementation") if attn_implementation: model_kwargs["attn_implementation"] = attn_implementation use_device_map = device is not None and device.type == "cuda" and _can_use_transformers_device_map() if use_device_map: model_kwargs["device_map"] = {"": str(device)} elif device is None or device.type != "cuda": model_kwargs["low_cpu_mem_usage"] = True text_encoder = AutoModel.from_pretrained( text_cfg["model_name_or_path"], **model_kwargs ) if device is not None and not use_device_map: text_encoder.to(device) text_config = text_encoder.config.get_text_config() hidden_size = getattr(text_config, "hidden_size", None) if not isinstance(hidden_size, int) or hidden_size <= 0: raise ValueError( "Could not determine the text hidden size from the text encoder config returned by " "`config.get_text_config()`." ) return tokenizer, text_encoder, int(hidden_size) def _run_components( tasks: list[tuple[str, Callable[[], Any]]], *, max_workers: int, ) -> tuple[dict[str, Any], dict[str, float]]: """Run a list of named ``(label, callable)`` loaders. Returns ``(results, timings)`` keyed by label. When ``max_workers == 1`` the tasks run sequentially in the caller's thread, in the listed order (so the user sees the stages tick through one by one on stdout). When ``max_workers > 1`` the tasks are dispatched in a :class:`ThreadPoolExecutor`; each task emits "started" / "done" messages so progress is still visible even with N concurrent workers. The first exception is re-raised once all submitted futures have been collected, so partially-completed pipelines never leak into the caller's namespace. """ results: dict[str, Any] = {} timings: dict[str, float] = {} errors: list[tuple[str, BaseException]] = [] n = len(tasks) if n == 0: return results, timings mode_str = "serial" if max_workers <= 1 else f"parallel({max_workers})" _emit_progress( f"[t2av_pipeline] loading {n} components ({mode_str}): " + ", ".join(label for label, _ in tasks) ) completed_counter = [0] progress_lock = threading.Lock() def _wrap(label: str, fn: Callable[[], Any], index: int): _emit_progress(f"[t2av_pipeline] ({index}/{n}) starting {label} ...") t0 = time.time() try: out = fn() except BaseException as exc: # noqa: BLE001 elapsed = time.time() - t0 with progress_lock: timings[label] = elapsed completed_counter[0] += 1 done = completed_counter[0] _emit_progress( f"[t2av_pipeline] ({done}/{n}) FAILED {label} after " f"{elapsed:.1f}s: {exc!r}" ) raise elapsed = time.time() - t0 with progress_lock: timings[label] = elapsed completed_counter[0] += 1 done = completed_counter[0] _emit_progress( f"[t2av_pipeline] ({done}/{n}) done {label} in {elapsed:.1f}s" ) return out if max_workers <= 1: # Run in deterministic listed order so the terminal output reads # like a sequential script (text_encoder -> video_vae -> ... -> # video_branch -> audio_branch). When something hangs, the last # "starting X" line on stdout pinpoints which loader stalled. for i, (label, fn) in enumerate(tasks, start=1): try: results[label] = _wrap(label, fn, i) except BaseException as exc: # noqa: BLE001 errors.append((label, exc)) break else: with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_label = { executor.submit(_wrap, label, fn, i): label for i, (label, fn) in enumerate(tasks, start=1) } for future in future_to_label: label = future_to_label[future] try: results[label] = future.result() except BaseException as exc: # noqa: BLE001 errors.append((label, exc)) if errors: # Surface the first error, attaching a summary of which other # components failed for easier debugging when several fail at once. label, exc = errors[0] if len(errors) > 1: extra = ", ".join(f"{lab}={type(e).__name__}" for lab, e in errors[1:]) raise RuntimeError( f"{label} failed during component load ({exc!r}); also failed: {extra}" ) from exc raise exc return results, timings def load_joint_av_pipeline( checkpoint_dir: str | Path, *, device: str | torch.device = "cuda", run_dir: Optional[str | Path] = None, vae_type_override: Optional[str] = None, vae_path_override: Optional[str | Path] = None, audio_vae_type_override: Optional[str] = None, audio_vae_path_override: Optional[str | Path] = None, ) -> T2AVPipeline: """Build the full T2AV inference pipeline from a saved checkpoint dir. Parameters ---------- checkpoint_dir ``.../checkpoints/snapshots/checkpoint-XXXXXXXX`` produced by :func:`omnivae_generation.trainer.joint_av.save_split_branches`. device Torch device for both branches, VAEs, and the text encoder. vae_type_override, vae_path_override Override the video VAE ``type`` / ``model_name_or_path`` from ``resolved_config.yaml`` (handy when the yaml's vae block does not match the checkpoint your snapshot was actually trained against; same semantics as ``infer/t2v/streamlit_app.py``). audio_vae_type_override, audio_vae_path_override Override the audio VAE block. ``audio_vae`` uses ``model_path`` (not ``model_name_or_path``) so ``audio_vae_path_override`` is written into ``model_path``. Performance ----------- Six components are loaded one after another (text encoder + tokenizer, video VAE, audio VAE, scheduler, video branch, audio branch) by default. Each stage emits a ``starting