[feat]: MagiHuman pipeline orchestrator + 10-test parity battery (5/8) (#1299)
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MagiHuman base text-to-AV pipeline.
|
||||
|
||||
Top-level composition for the daVinci-MagiHuman base model. Wires:
|
||||
|
||||
InputValidationStage -> TextEncodingStage (T5-Gemma)
|
||||
-> MagiHumanLatentPreparationStage
|
||||
-> MagiHumanDenoisingStage
|
||||
-> DecodingStage (Wan 2.2 TI2V-5B VAE decode for video)
|
||||
-> MagiHumanAudioDecodingStage (Stable Audio Open 1.0 VAE decode)
|
||||
|
||||
The base checkpoint is a joint audio-visual generator; both the video
|
||||
and audio paths run in the denoising loop and both are decoded.
|
||||
|
||||
`load_modules` is overridden so the four cross-variant shared components
|
||||
(text_encoder, tokenizer, audio_vae, video vae) lazy-load from their
|
||||
canonical upstream HF repos at first build time instead of being
|
||||
bundled inside every converted MagiHuman variant. This keeps each
|
||||
variant's converted repo at ~5-30 GB (transformer + scheduler +
|
||||
model_index.json) instead of ~30-55 GB, and lets all variants share
|
||||
the same ~25 GB of cached upstream weights.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from fastvideo.configs.models.encoders.t5gemma import T5GemmaEncoderConfig
|
||||
from fastvideo.configs.models.vaes import OobleckVAEConfig
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.logger import init_logger
|
||||
from fastvideo.models.encoders.t5gemma import T5GemmaEncoderModel
|
||||
from fastvideo.models.vaes.sa_audio import SAAudioVAEModel
|
||||
from fastvideo.models.schedulers.scheduling_flow_unipc_multistep import (
|
||||
FlowUniPCMultistepScheduler, )
|
||||
from fastvideo.pipelines.basic.magi_human.stages import (
|
||||
MagiHumanAudioDecodingStage,
|
||||
MagiHumanDenoisingStage,
|
||||
MagiHumanLatentPreparationStage,
|
||||
MagiHumanReferenceImageStage,
|
||||
MagiHumanSRDenoisingStage,
|
||||
MagiHumanSRLatentPreparationStage,
|
||||
)
|
||||
from fastvideo.pipelines.composed_pipeline_base import ComposedPipelineBase
|
||||
from fastvideo.pipelines.stages import (
|
||||
DecodingStage,
|
||||
InputValidationStage,
|
||||
TextEncodingStage,
|
||||
)
|
||||
from fastvideo.utils import maybe_download_model
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_T5GEMMA_HF_ID = "google/t5gemma-9b-9b-ul2"
|
||||
_SA_AUDIO_HF_ID = "stabilityai/stable-audio-open-1.0"
|
||||
_WAN_VAE_HF_ID = "Wan-AI/Wan2.2-TI2V-5B-Diffusers"
|
||||
|
||||
|
||||
def _ensure_hf_token_env() -> str | None:
|
||||
"""Surface any of the three common HF token env vars as `HF_TOKEN`.
|
||||
|
||||
FastVideo workers spawn child processes that inherit env; both
|
||||
`huggingface_hub` and `transformers.AutoTokenizer.from_pretrained`
|
||||
look at `HF_TOKEN` / `HUGGINGFACE_HUB_TOKEN` by default but not
|
||||
`HF_API_KEY`. If only the latter is set, gated downloads fail with
|
||||
401. Aliasing at pipeline-load time is the minimum-disruption fix.
|
||||
"""
|
||||
for src in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HF_API_KEY"):
|
||||
value = os.environ.get(src)
|
||||
if value:
|
||||
os.environ.setdefault("HF_TOKEN", value)
|
||||
os.environ.setdefault("HUGGINGFACE_HUB_TOKEN", value)
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
class MagiHumanPipeline(ComposedPipelineBase):
|
||||
"""Base MagiHuman text-to-AV pipeline (no LoRA, no distill, no SR)."""
|
||||
|
||||
_required_config_modules = [
|
||||
"text_encoder",
|
||||
"tokenizer",
|
||||
"vae",
|
||||
"transformer",
|
||||
"scheduler",
|
||||
"audio_vae",
|
||||
]
|
||||
|
||||
def load_modules(
|
||||
self,
|
||||
fastvideo_args: FastVideoArgs,
|
||||
loaded_modules: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Load the variant-specific transformer + scheduler from the
|
||||
converted MagiHuman repo and lazy-load the four cross-variant
|
||||
shared components from their canonical upstream HF repos:
|
||||
|
||||
* text_encoder, tokenizer -> ``google/t5gemma-9b-9b-ul2``
|
||||
(gated, requires HF token with accepted terms of use)
|
||||
* audio_vae -> ``stabilityai/stable-audio-open-1.0`` (gated)
|
||||
* vae -> ``Wan-AI/Wan2.2-TI2V-5B-Diffusers``
|
||||
|
||||
Backwards-compatible with bundled converted repos: if any of
|
||||
these subfolders is present locally and listed in
|
||||
``model_index.json``, the standard component loader picks it up
|
||||
via super(). Otherwise the loader is told to skip the entry and
|
||||
we lazy-load it here.
|
||||
"""
|
||||
# T5-Gemma is gated: expose `HF_API_KEY` as `HF_TOKEN` if needed.
|
||||
_ensure_hf_token_env()
|
||||
|
||||
# Resolve to a local cache path so we can inspect
|
||||
# model_index.json before invoking super(). `maybe_download_model`
|
||||
# is idempotent for local paths; super() repeats the call cheaply
|
||||
# via `_load_config`.
|
||||
local_path = maybe_download_model(self.model_path)
|
||||
|
||||
# Identify which cross-variant shared keys are bundled in the
|
||||
# converted repo (declared in model_index.json with a non-null
|
||||
# spec) versus absent (the umbrella scheme). Bundled keys stay
|
||||
# in `required_config_modules` and are loaded normally by super()
|
||||
# from `<model_path>/<key>/`. Absent keys are temporarily
|
||||
# dropped so super() does not fail the "every required entry
|
||||
# must appear in model_index.json" check, then lazy-loaded
|
||||
# below.
|
||||
model_index: dict[str, Any] = {}
|
||||
try:
|
||||
with open(Path(local_path) / "model_index.json") as f:
|
||||
model_index = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
def _is_bundled(key: str) -> bool:
|
||||
spec = model_index.get(key)
|
||||
return (isinstance(spec, list | tuple) and len(spec) >= 1 and spec[0] is not None)
|
||||
|
||||
deferred = []
|
||||
for key in ("text_encoder", "tokenizer", "audio_vae", "vae"):
|
||||
if key in self.required_config_modules and not _is_bundled(key):
|
||||
self.required_config_modules.remove(key)
|
||||
deferred.append(key)
|
||||
|
||||
try:
|
||||
modules = super().load_modules(fastvideo_args, loaded_modules)
|
||||
finally:
|
||||
for key in deferred:
|
||||
if key not in self.required_config_modules:
|
||||
self.required_config_modules.append(key)
|
||||
|
||||
# For each lazy-load key, prefer whatever super() already loaded
|
||||
# (a bundled subfolder, or a caller-provided override merged in
|
||||
# via `loaded_modules`). Fall back to the caller-provided
|
||||
# `loaded_modules` entry for keys absent from model_index.json
|
||||
# (super() never iterates those). Otherwise lazy-load from the
|
||||
# canonical upstream HF repo.
|
||||
def _resolve(key: str) -> bool:
|
||||
"""Return True if `modules[key]` is already populated."""
|
||||
if modules.get(key) is not None:
|
||||
return True
|
||||
if loaded_modules and key in loaded_modules:
|
||||
modules[key] = loaded_modules[key]
|
||||
return True
|
||||
return False
|
||||
|
||||
if not _resolve("text_encoder"):
|
||||
logger.info("Building T5-Gemma text encoder (lazy-load from %s)", _T5GEMMA_HF_ID)
|
||||
enc_config = T5GemmaEncoderConfig()
|
||||
enc_config.arch_config.t5gemma_model_path = _T5GEMMA_HF_ID
|
||||
modules["text_encoder"] = T5GemmaEncoderModel(enc_config)
|
||||
|
||||
if not _resolve("tokenizer"):
|
||||
logger.info("Loading T5-Gemma tokenizer from %s", _T5GEMMA_HF_ID)
|
||||
modules["tokenizer"] = AutoTokenizer.from_pretrained(_T5GEMMA_HF_ID)
|
||||
|
||||
if not _resolve("audio_vae"):
|
||||
logger.info(
|
||||
"Building Stable Audio Open 1.0 VAE (lazy-load from %s) — "
|
||||
"requires HF terms accepted for gated repo",
|
||||
_SA_AUDIO_HF_ID,
|
||||
)
|
||||
audio_config = OobleckVAEConfig()
|
||||
audio_config.pretrained_path = _SA_AUDIO_HF_ID
|
||||
modules["audio_vae"] = SAAudioVAEModel(audio_config)
|
||||
|
||||
if not _resolve("vae"):
|
||||
modules["vae"] = self._load_video_vae(fastvideo_args)
|
||||
|
||||
return modules
|
||||
|
||||
def _load_video_vae(self, fastvideo_args: FastVideoArgs) -> Any:
|
||||
"""Resolve the video VAE: prefer a bundled ``vae/`` subfolder in
|
||||
the converted repo (legacy), fall back to lazy-downloading the
|
||||
Wan 2.2 TI2V-5B VAE shards from upstream.
|
||||
|
||||
Either way the load goes through FastVideo's standard
|
||||
``VAELoader`` so the result is the same FV ``AutoencoderKLWan``
|
||||
nn.Module that the bundled path produces.
|
||||
"""
|
||||
from fastvideo.models.loader.component_loader import VAELoader
|
||||
|
||||
bundled = Path(self.model_path) / "vae"
|
||||
if bundled.is_dir() and (bundled / "config.json").is_file():
|
||||
logger.info("Loading bundled video VAE from %s", bundled)
|
||||
return VAELoader().load(str(bundled), fastvideo_args)
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
logger.info(
|
||||
"Bundled vae/ not found at %s; lazy-loading Wan 2.2 TI2V-5B VAE from %s",
|
||||
self.model_path,
|
||||
_WAN_VAE_HF_ID,
|
||||
)
|
||||
snapshot = snapshot_download(
|
||||
repo_id=_WAN_VAE_HF_ID,
|
||||
allow_patterns=["vae/*"],
|
||||
)
|
||||
vae_dir = os.path.join(snapshot, "vae")
|
||||
if not os.path.isdir(vae_dir):
|
||||
raise RuntimeError(
|
||||
f"snapshot_download returned {snapshot} but no vae/ "
|
||||
f"subfolder was found inside it. Check that {_WAN_VAE_HF_ID} "
|
||||
"still exposes a Diffusers-format vae/ folder.", )
|
||||
return VAELoader().load(vae_dir, fastvideo_args)
|
||||
|
||||
def initialize_pipeline(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
# MagiHuman applies `flow_shift` during timestep setup; keep the
|
||||
# scheduler constructor at its default no-op shift.
|
||||
self.modules["scheduler"] = FlowUniPCMultistepScheduler()
|
||||
|
||||
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
self._add_input_and_conditioning_stages(fastvideo_args)
|
||||
self._add_base_latent_and_denoising_stages(fastvideo_args)
|
||||
self._add_decode_stages()
|
||||
|
||||
def _add_input_and_conditioning_stages(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
self.add_stage(
|
||||
stage_name="input_validation_stage",
|
||||
stage=InputValidationStage(),
|
||||
)
|
||||
|
||||
self.add_stage(
|
||||
stage_name="prompt_encoding_stage",
|
||||
stage=TextEncodingStage(
|
||||
text_encoders=[self.get_module("text_encoder")],
|
||||
tokenizers=[self.get_module("tokenizer")],
|
||||
),
|
||||
)
|
||||
|
||||
self._add_reference_image_stage(fastvideo_args)
|
||||
|
||||
def _add_base_latent_and_denoising_stages(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
pc = fastvideo_args.pipeline_config
|
||||
dit_arch = pc.dit_config.arch_config
|
||||
|
||||
# Data-proxy + eval knobs come from the PipelineConfig (`pc`).
|
||||
# Only DiT-architecture fields live on `dit_arch` now.
|
||||
self.add_stage(
|
||||
stage_name="latent_preparation_stage",
|
||||
stage=MagiHumanLatentPreparationStage(
|
||||
vae_stride=tuple(pc.vae_stride),
|
||||
z_dim=pc.z_dim,
|
||||
patch_size=tuple(dit_arch.patch_size),
|
||||
fps=pc.fps,
|
||||
t5_gemma_target_length=pc.t5_gemma_target_length,
|
||||
coords_style=pc.coords_style,
|
||||
text_offset=pc.text_offset,
|
||||
audio_in_channels=dit_arch.audio_in_channels,
|
||||
),
|
||||
)
|
||||
|
||||
self.add_stage(
|
||||
stage_name="denoising_stage",
|
||||
stage=MagiHumanDenoisingStage(
|
||||
transformer=self.get_module("transformer"),
|
||||
scheduler=self.get_module("scheduler"),
|
||||
patch_size=tuple(dit_arch.patch_size),
|
||||
video_in_channels=dit_arch.video_in_channels,
|
||||
audio_in_channels=dit_arch.audio_in_channels,
|
||||
video_txt_guidance_scale=pc.video_txt_guidance_scale,
|
||||
audio_txt_guidance_scale=pc.audio_txt_guidance_scale,
|
||||
cfg_number=pc.cfg_number,
|
||||
coords_style=pc.coords_style,
|
||||
video_guidance_high_t_threshold=pc.video_guidance_high_t_threshold,
|
||||
video_guidance_low_t_value=pc.video_guidance_low_t_value,
|
||||
),
|
||||
)
|
||||
|
||||
def _add_decode_stages(self) -> None:
|
||||
self.add_stage(
|
||||
stage_name="decoding_stage",
|
||||
stage=DecodingStage(vae=self.get_module("vae"), pipeline=self),
|
||||
)
|
||||
|
||||
self.add_stage(
|
||||
stage_name="audio_decoding_stage",
|
||||
stage=MagiHumanAudioDecodingStage(audio_vae=self.get_module("audio_vae"), ),
|
||||
)
|
||||
|
||||
def _add_reference_image_stage(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
return
|
||||
|
||||
|
||||
class MagiHumanI2VPipeline(MagiHumanPipeline):
|
||||
"""MagiHuman text+image-to-AV pipeline using the T2V DiT weights."""
|
||||
|
||||
def _add_reference_image_stage(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
pc = fastvideo_args.pipeline_config
|
||||
self.add_stage(
|
||||
stage_name="reference_image_stage",
|
||||
stage=MagiHumanReferenceImageStage(
|
||||
vae=self.get_module("vae"),
|
||||
vae_scale_factor=pc.vae_stride[1],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MagiHumanSRPipeline(MagiHumanPipeline):
|
||||
"""Two-stage MagiHuman base + SR-540p text-to-AV pipeline."""
|
||||
|
||||
_required_config_modules = [
|
||||
"text_encoder",
|
||||
"tokenizer",
|
||||
"vae",
|
||||
"transformer",
|
||||
"sr_transformer",
|
||||
"scheduler",
|
||||
"audio_vae",
|
||||
]
|
||||
|
||||
def create_pipeline_stages(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
self._add_input_and_conditioning_stages(fastvideo_args)
|
||||
self._add_base_latent_and_denoising_stages(fastvideo_args)
|
||||
self._add_sr_latent_and_denoising_stages(fastvideo_args)
|
||||
self._add_decode_stages()
|
||||
|
||||
def _add_sr_latent_and_denoising_stages(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
pc = fastvideo_args.pipeline_config
|
||||
dit_arch = pc.dit_config.arch_config
|
||||
sr_transformer = self.get_module("sr_transformer")
|
||||
sr_local_attn_layers = tuple(getattr(pc, "sr_local_attn_layers", ()))
|
||||
if sr_local_attn_layers and hasattr(sr_transformer, "configure_local_attention"):
|
||||
sr_transformer.configure_local_attention(
|
||||
sr_local_attn_layers,
|
||||
frame_receptive_field=pc.frame_receptive_field,
|
||||
)
|
||||
|
||||
self.add_stage(
|
||||
stage_name="sr_latent_preparation_stage",
|
||||
stage=MagiHumanSRLatentPreparationStage(
|
||||
vae=self.get_module("vae"),
|
||||
vae_stride=tuple(pc.vae_stride),
|
||||
patch_size=tuple(dit_arch.patch_size),
|
||||
noise_value=pc.noise_value,
|
||||
sr_audio_noise_scale=pc.sr_audio_noise_scale,
|
||||
sr_height=pc.sr_height,
|
||||
sr_width=pc.sr_width,
|
||||
vae_scale_factor=pc.vae_stride[1],
|
||||
),
|
||||
)
|
||||
self.add_stage(
|
||||
stage_name="sr_denoising_stage",
|
||||
stage=MagiHumanSRDenoisingStage(
|
||||
transformer=sr_transformer,
|
||||
scheduler=self.get_module("scheduler"),
|
||||
patch_size=tuple(dit_arch.patch_size),
|
||||
video_in_channels=dit_arch.video_in_channels,
|
||||
audio_in_channels=dit_arch.audio_in_channels,
|
||||
sr_num_inference_steps=pc.sr_num_inference_steps,
|
||||
sr_video_txt_guidance_scale=pc.sr_video_txt_guidance_scale,
|
||||
use_cfg_trick=pc.use_cfg_trick,
|
||||
cfg_trick_start_frame=pc.cfg_trick_start_frame,
|
||||
cfg_trick_value=pc.cfg_trick_value,
|
||||
cfg_number=pc.cfg_number,
|
||||
coords_style="v1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MagiHumanSRI2VPipeline(MagiHumanSRPipeline):
|
||||
"""Two-stage MagiHuman base + SR-540p text+image-to-AV pipeline."""
|
||||
|
||||
def _add_reference_image_stage(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
pc = fastvideo_args.pipeline_config
|
||||
self.add_stage(
|
||||
stage_name="reference_image_stage",
|
||||
stage=MagiHumanReferenceImageStage(
|
||||
vae=self.get_module("vae"),
|
||||
vae_scale_factor=pc.vae_stride[1],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MagiHumanSR1080pPipeline(MagiHumanSRPipeline):
|
||||
"""Two-stage MagiHuman base + SR-1080p text-to-AV pipeline.
|
||||
|
||||
The stage chain is identical to SR-540p. The paired pipeline config enables
|
||||
block-sparse local-window attention on 32 SR-DiT layers and requests the
|
||||
1080p latent target.
|
||||
"""
|
||||
|
||||
|
||||
class MagiHumanSR1080pI2VPipeline(MagiHumanSRI2VPipeline):
|
||||
"""Two-stage MagiHuman base + SR-1080p text+image-to-AV pipeline."""
|
||||
|
||||
|
||||
EntryClass = [
|
||||
MagiHumanPipeline,
|
||||
MagiHumanI2VPipeline,
|
||||
MagiHumanSRPipeline,
|
||||
MagiHumanSRI2VPipeline,
|
||||
MagiHumanSR1080pPipeline,
|
||||
MagiHumanSR1080pI2VPipeline,
|
||||
]
|
||||
@@ -0,0 +1,236 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""PipelineConfig for the daVinci-MagiHuman base text-to-AV pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import torch
|
||||
|
||||
from fastvideo.configs.models import DiTConfig, EncoderConfig, VAEConfig
|
||||
from fastvideo.configs.models.dits import MagiHumanVideoConfig
|
||||
from fastvideo.configs.models.encoders import (
|
||||
BaseEncoderOutput,
|
||||
T5GemmaEncoderConfig,
|
||||
)
|
||||
from fastvideo.configs.models.vaes import OobleckVAEConfig, WanVAEConfig
|
||||
from fastvideo.configs.pipelines.base import PipelineConfig
|
||||
|
||||
|
||||
def t5gemma_postprocess_text(outputs: BaseEncoderOutput) -> torch.Tensor:
|
||||
"""Return per-prompt last_hidden_state as a batched [B, L, D] tensor.
|
||||
|
||||
MagiHuman pads/trims the embedding to a fixed length in its own
|
||||
`pad_or_trim` helper at pipeline time. Here we simply hand through
|
||||
whatever the tokenizer produced — the latent-prep stage is responsible
|
||||
for pad/trim so that the original context length can be preserved.
|
||||
"""
|
||||
hidden = outputs.last_hidden_state
|
||||
assert torch.isnan(hidden).sum() == 0
|
||||
# Keep the shape the tokenizer emitted; the pipeline stage handles
|
||||
# pad-or-trim to t5_gemma_target_length=640.
|
||||
return hidden
|
||||
|
||||
|
||||
@dataclass
|
||||
class MagiHumanBaseConfig(PipelineConfig):
|
||||
"""Base MagiHuman text-to-AV pipeline config (prompt → video + audio).
|
||||
|
||||
MagiHuman's base model is a joint audio-visual generator. This config
|
||||
wires up both the video VAE (Wan 2.2 TI2V-5B) and the audio VAE
|
||||
(Stable Audio Open 1.0); the pipeline produces an mp4 with a muxed
|
||||
audio track. The framework's `WorkloadType` enum has no `T2AV`
|
||||
variant yet, so the registry entry uses `WorkloadType.T2V` as a
|
||||
placeholder.
|
||||
"""
|
||||
|
||||
# DiT
|
||||
dit_config: DiTConfig = field(default_factory=MagiHumanVideoConfig)
|
||||
# VAE — Wan 2.2 TI2V-5B. Diffusers `vae/config.json` drives arch_config
|
||||
# at load time, including z_dim=48 and scale_factor_temporal=4 /
|
||||
# scale_factor_spatial=16.
|
||||
vae_config: VAEConfig = field(default_factory=WanVAEConfig)
|
||||
vae_tiling: bool = False
|
||||
vae_sp: bool = False
|
||||
|
||||
# Audio VAE — Stable Audio Open 1.0 (Oobleck), shared with the
|
||||
# standalone Stable Audio pipeline. Lazy-loaded from
|
||||
# `stabilityai/stable-audio-open-1.0` (HF gated, Apache 2.0).
|
||||
audio_vae_config: VAEConfig = field(default_factory=OobleckVAEConfig)
|
||||
|
||||
# Denoising (flow-matching UniPC).
|
||||
flow_shift: float | None = 5.0
|
||||
|
||||
# Text encoding
|
||||
text_encoder_configs: tuple[EncoderConfig, ...] = field(default_factory=lambda: (T5GemmaEncoderConfig(), ))
|
||||
postprocess_text_funcs: tuple[Callable[[BaseEncoderOutput], torch.Tensor],
|
||||
...] = field(default_factory=lambda: (t5gemma_postprocess_text, ))
|
||||
|
||||
# Precisions — the DiT runs bf16 internally, the text encoder is
|
||||
# bf16-native, and the VAE decode path benefits from fp32 for long
|
||||
# sequences.
|
||||
precision: str = "bf16"
|
||||
vae_precision: str = "fp32"
|
||||
text_encoder_precisions: tuple[str, ...] = field(default_factory=lambda: ("bf16", ))
|
||||
|
||||
# MagiHuman-specific defaults surfaced for the pipeline stages. These
|
||||
# are pipeline-level knobs sourced from the upstream
|
||||
# `EvaluationConfig` / `DataProxyConfig` (not `ModelConfig`), so they
|
||||
# belong here, NOT on `MagiHumanArchConfig`.
|
||||
t5_gemma_target_length: int = 640
|
||||
fps: int = 25
|
||||
num_inference_steps: int = 32
|
||||
video_txt_guidance_scale: float = 5.0
|
||||
audio_txt_guidance_scale: float = 5.0
|
||||
cfg_number: int = 2
|
||||
|
||||
# VAE / data-proxy knobs (were on ArchConfig before; moved here).
|
||||
vae_stride: tuple[int, int, int] = (4, 16, 16)
|
||||
z_dim: int = 48
|
||||
frame_receptive_field: int = 11
|
||||
coords_style: str = "v2"
|
||||
ref_audio_offset: int = 1000
|
||||
text_offset: int = 0
|
||||
|
||||
# Video CFG step-dependent guidance: low-t steps use a relaxed scale.
|
||||
# Upstream daVinci-MagiHuman/inference/pipeline/video_generate.py:426
|
||||
# uses 5.0 for high-t and 2.0 for low-t with cutoff at t=500.
|
||||
video_guidance_high_t_threshold: int = 500
|
||||
video_guidance_low_t_value: float = 2.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Base text-to-AV does not need the VAE encoder (no reference-image
|
||||
# conditioning). Keep decoder only to save memory.
|
||||
self.vae_config.load_encoder = False
|
||||
self.vae_config.load_decoder = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class MagiHumanBaseI2VConfig(MagiHumanBaseConfig):
|
||||
"""Base MagiHuman text+image-to-AV pipeline config.
|
||||
|
||||
TI2V reuses the T2V DiT weights; the only pipeline-side difference is
|
||||
that a reference image is encoded with the Wan VAE and reinserted into
|
||||
the first video-latent frame before every denoise step.
|
||||
"""
|
||||
|
||||
image_conditioning: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.vae_config.load_encoder = True
|
||||
self.vae_config.load_decoder = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class MagiHumanDistillConfig(MagiHumanBaseConfig):
|
||||
"""DMD-2 distilled MagiHuman text-to-AV pipeline config.
|
||||
|
||||
Same arch as base (identical 331 keys, same shapes, same module tree),
|
||||
but trained via DMD-2 for 8-step inference without classifier-free
|
||||
guidance. Weights are stored in fp32 upstream; the conversion script's
|
||||
`--cast-bf16` flag reduces the checkpoint to ~30 GB on disk.
|
||||
"""
|
||||
|
||||
num_inference_steps: int = 8
|
||||
cfg_number: int = 1 # DMD distilled models skip CFG.
|
||||
# Lower flow_shift matches the distilled DMD schedule; if parity later
|
||||
# shows drift, measure against `scheduler_config.json` generated by the
|
||||
# conversion script for the distill subfolder.
|
||||
flow_shift: float | None = 5.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class MagiHumanDistillI2VConfig(MagiHumanDistillConfig):
|
||||
"""DMD-2 distilled MagiHuman text+image-to-AV pipeline config."""
|
||||
|
||||
image_conditioning: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.vae_config.load_encoder = True
|
||||
self.vae_config.load_decoder = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class MagiHumanSR540pConfig(MagiHumanBaseConfig):
|
||||
"""Two-stage MagiHuman base + SR-540p text-to-AV pipeline config."""
|
||||
|
||||
noise_value: int = 220
|
||||
sr_audio_noise_scale: float = 0.7
|
||||
sr_num_inference_steps: int = 5
|
||||
sr_video_txt_guidance_scale: float = 3.5
|
||||
use_cfg_trick: bool = True
|
||||
cfg_trick_start_frame: int = 13
|
||||
cfg_trick_value: float = 2.0
|
||||
# Upstream example/sr_540p uses sr_height=512, sr_width=896. Despite the
|
||||
# marketing name, these are the VAE/patch-aligned dimensions actually run.
|
||||
sr_height: int = 512
|
||||
sr_width: int = 896
|
||||
sr_local_attn_layers: tuple[int, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MagiHumanSR540pI2VConfig(MagiHumanSR540pConfig):
|
||||
"""Two-stage MagiHuman base + SR-540p text+image-to-AV config."""
|
||||
|
||||
image_conditioning: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.vae_config.load_encoder = True
|
||||
self.vae_config.load_decoder = True
|
||||
|
||||
|
||||
_SR_1080P_LOCAL_ATTN_LAYERS: tuple[int, ...] = (
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
32,
|
||||
33,
|
||||
34,
|
||||
35,
|
||||
36,
|
||||
37,
|
||||
38,
|
||||
39,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MagiHumanSR1080pConfig(MagiHumanSR540pConfig):
|
||||
"""Two-stage MagiHuman base + SR-1080p text-to-AV pipeline config."""
|
||||
|
||||
sr_height: int = 1080
|
||||
sr_width: int = 1920
|
||||
sr_local_attn_layers: tuple[int, ...] = _SR_1080P_LOCAL_ATTN_LAYERS
|
||||
|
||||
|
||||
@dataclass
|
||||
class MagiHumanSR1080pI2VConfig(MagiHumanSR1080pConfig):
|
||||
"""Two-stage MagiHuman base + SR-1080p text+image-to-AV config."""
|
||||
|
||||
image_conditioning: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.vae_config.load_encoder = True
|
||||
self.vae_config.load_decoder = True
|
||||
@@ -0,0 +1,225 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Presets for the daVinci-MagiHuman pipelines."""
|
||||
from fastvideo.api.presets import InferencePreset, PresetStageSpec
|
||||
|
||||
# Keep this in sync with upstream MagiEvaluator.negative_prompt
|
||||
# (daVinci-MagiHuman/inference/pipeline/video_generate.py:222-224): the
|
||||
# video, audio-quality, and speech-delivery blocks all condition CFG.
|
||||
_MAGI_HUMAN_NEGATIVE_PROMPT = ("Bright tones, overexposed, static, blurred details, subtitles, style, works, "
|
||||
"paintings, images, static, overall gray, worst quality, low quality, JPEG "
|
||||
"compression residue, ugly, incomplete, extra fingers, poorly drawn hands, "
|
||||
"poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, "
|
||||
"still picture, messy background, three legs, many people in the background, "
|
||||
"walking backwards, low quality, worst quality, poor quality, noise, background "
|
||||
"noise, hiss, hum, buzz, crackle, static, compression artifacts, MP3 artifacts, "
|
||||
"digital clipping, distortion, muffled, muddy, unclear, echo, reverb, room echo, "
|
||||
"over-reverberated, hollow sound, distant, washed out, harsh, shrill, piercing, "
|
||||
"grating, tinny, thin sound, boomy, bass-heavy, flat EQ, over-compressed, "
|
||||
"abrupt cut, jarring transition, sudden silence, looping artifact, music, "
|
||||
"instrumental, sirens, alarms, crowd noise, unrelated sound effects, chaotic, "
|
||||
"disorganized, messy, cheap sound, emotionless, flat delivery, deadpan, lifeless, "
|
||||
"apathetic, robotic, mechanical, monotone, flat intonation, undynamic, boring, "
|
||||
"reading from a script, AI voice, synthetic, text-to-speech, TTS, insincere, "
|
||||
"fake emotion, exaggerated, overly dramatic, melodramatic, cheesy, cringey, "
|
||||
"hesitant, unconfident, tired, weak voice, stuttering, stammering, mumbling, "
|
||||
"slurred speech, mispronounced, bad articulation, lisp, vocal fry, creaky voice, "
|
||||
"mouth clicks, lip smacks, wet mouth sounds, heavy breathing, audible inhales, "
|
||||
"plosives, p-pops, coughing, clearing throat, sneezing, speaking too fast, rushed, "
|
||||
"speaking too slow, dragged out, unnatural pauses, awkward silence, choppy, "
|
||||
"disjointed, multiple speakers, two voices, background talking, out of tune, "
|
||||
"off-key, autotune artifacts")
|
||||
|
||||
_DENOISE_STAGE = PresetStageSpec(
|
||||
name="denoise",
|
||||
kind="denoising",
|
||||
description="Joint video+audio UniPC flow-matching denoise pass.",
|
||||
allowed_overrides=frozenset({
|
||||
"num_inference_steps",
|
||||
"guidance_scale",
|
||||
}),
|
||||
)
|
||||
|
||||
MAGI_HUMAN_BASE = InferencePreset(
|
||||
name="magi_human_base",
|
||||
version=1,
|
||||
model_family="magi_human",
|
||||
description=("daVinci-MagiHuman base text-to-AV at 256x480, 4s @ 25 fps. "
|
||||
"Produces an mp4 with muxed audio + video. workload_type "
|
||||
"is `t2v` because the framework enum has no `t2av` variant yet."),
|
||||
workload_type="t2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
"seed": 42,
|
||||
"height": 256,
|
||||
# Upstream pipeline.py:61-64 defaults br_width=480, br_height=272,
|
||||
# and video_generate.py:254-261 snaps height to 256 while width stays
|
||||
# 480, so the rendered default is 256x480.
|
||||
"width": 480,
|
||||
# num_frames is derived by the pipeline as `seconds*fps + 1`; we
|
||||
# surface it here for APIs that expect a concrete default.
|
||||
"num_frames": 101,
|
||||
"fps": 25,
|
||||
"guidance_scale": 5.0, # used as video_txt_guidance_scale
|
||||
"num_inference_steps": 32,
|
||||
"negative_prompt": _MAGI_HUMAN_NEGATIVE_PROMPT,
|
||||
},
|
||||
)
|
||||
|
||||
MAGI_HUMAN_DISTILL = InferencePreset(
|
||||
name="magi_human_distill",
|
||||
version=1,
|
||||
model_family="magi_human",
|
||||
description=("daVinci-MagiHuman DMD-2 distilled text-to-AV at 256x480, 4s @ "
|
||||
"25 fps. 8-step inference, no classifier-free guidance. Produces "
|
||||
"an mp4 with muxed audio + video."),
|
||||
workload_type="t2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
"seed": 42,
|
||||
"height": 256,
|
||||
"width": 480,
|
||||
"num_frames": 101,
|
||||
"fps": 25,
|
||||
# DMD: cfg=1 at the pipeline level. guidance_scale is kept at 1.0
|
||||
# for interop; the DenoisingStage ignores it when cfg_number=1.
|
||||
"guidance_scale": 1.0,
|
||||
"num_inference_steps": 8,
|
||||
"negative_prompt": _MAGI_HUMAN_NEGATIVE_PROMPT,
|
||||
},
|
||||
)
|
||||
|
||||
MAGI_HUMAN_BASE_TI2V = InferencePreset(
|
||||
name="magi_human_base_ti2v",
|
||||
version=1,
|
||||
model_family="magi_human",
|
||||
description=("daVinci-MagiHuman base text+image-to-AV at 256x480, 4s @ 25 fps. "
|
||||
"The reference image is VAE-encoded and pinned to the first "
|
||||
"video latent frame at each denoise step."),
|
||||
workload_type="i2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
"seed": 42,
|
||||
"height": 256,
|
||||
"width": 480,
|
||||
"num_frames": 101,
|
||||
"fps": 25,
|
||||
"guidance_scale": 5.0,
|
||||
"num_inference_steps": 32,
|
||||
"negative_prompt": _MAGI_HUMAN_NEGATIVE_PROMPT,
|
||||
},
|
||||
)
|
||||
|
||||
MAGI_HUMAN_DISTILL_TI2V = InferencePreset(
|
||||
name="magi_human_distill_ti2v",
|
||||
version=1,
|
||||
model_family="magi_human",
|
||||
description=("daVinci-MagiHuman DMD-2 distilled text+image-to-AV at 256x480, "
|
||||
"4s @ 25 fps. 8-step inference, no CFG."),
|
||||
workload_type="i2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
"seed": 42,
|
||||
"height": 256,
|
||||
"width": 480,
|
||||
"num_frames": 101,
|
||||
"fps": 25,
|
||||
"guidance_scale": 1.0,
|
||||
"num_inference_steps": 8,
|
||||
"negative_prompt": _MAGI_HUMAN_NEGATIVE_PROMPT,
|
||||
},
|
||||
)
|
||||
|
||||
MAGI_HUMAN_SR_540P = InferencePreset(
|
||||
name="magi_human_sr_540p",
|
||||
version=1,
|
||||
model_family="magi_human",
|
||||
description=("daVinci-MagiHuman two-stage base + SR-540p text-to-AV. "
|
||||
"Base pass runs at 256x480; SR pass refines to upstream's "
|
||||
"aligned 512x896 output with muxed audio."),
|
||||
workload_type="t2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
"seed": 42,
|
||||
"height": 256,
|
||||
"width": 480,
|
||||
"num_frames": 101,
|
||||
"fps": 25,
|
||||
"guidance_scale": 5.0,
|
||||
"num_inference_steps": 32,
|
||||
"negative_prompt": _MAGI_HUMAN_NEGATIVE_PROMPT,
|
||||
},
|
||||
)
|
||||
|
||||
MAGI_HUMAN_SR_540P_TI2V = InferencePreset(
|
||||
name="magi_human_sr_540p_ti2v",
|
||||
version=1,
|
||||
model_family="magi_human",
|
||||
description=("daVinci-MagiHuman two-stage base + SR-540p text+image-to-AV. "
|
||||
"The reference image is encoded at base resolution and then "
|
||||
"re-encoded at SR resolution before the SR denoise pass."),
|
||||
workload_type="i2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
"seed": 42,
|
||||
"height": 256,
|
||||
"width": 480,
|
||||
"num_frames": 101,
|
||||
"fps": 25,
|
||||
"guidance_scale": 5.0,
|
||||
"num_inference_steps": 32,
|
||||
"negative_prompt": _MAGI_HUMAN_NEGATIVE_PROMPT,
|
||||
},
|
||||
)
|
||||
|
||||
MAGI_HUMAN_SR_1080P = InferencePreset(
|
||||
name="magi_human_sr_1080p",
|
||||
version=1,
|
||||
model_family="magi_human",
|
||||
description=("daVinci-MagiHuman two-stage base + SR-1080p text-to-AV. "
|
||||
"The SR DiT uses upstream local-window attention in 32 of "
|
||||
"40 layers and refines to 1080p-class output."),
|
||||
workload_type="t2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
"seed": 42,
|
||||
"height": 256,
|
||||
"width": 480,
|
||||
"num_frames": 101,
|
||||
"fps": 25,
|
||||
"guidance_scale": 5.0,
|
||||
"num_inference_steps": 32,
|
||||
"negative_prompt": _MAGI_HUMAN_NEGATIVE_PROMPT,
|
||||
},
|
||||
)
|
||||
|
||||
MAGI_HUMAN_SR_1080P_TI2V = InferencePreset(
|
||||
name="magi_human_sr_1080p_ti2v",
|
||||
version=1,
|
||||
model_family="magi_human",
|
||||
description=("daVinci-MagiHuman two-stage base + SR-1080p text+image-to-AV. "
|
||||
"The SR DiT uses upstream local-window attention in 32 of "
|
||||
"40 layers; the reference image is re-encoded at SR resolution."),
|
||||
workload_type="i2v",
|
||||
stage_schemas=(_DENOISE_STAGE, ),
|
||||
defaults={
|
||||
"seed": 42,
|
||||
"height": 256,
|
||||
"width": 480,
|
||||
"num_frames": 101,
|
||||
"fps": 25,
|
||||
"guidance_scale": 5.0,
|
||||
"num_inference_steps": 32,
|
||||
"negative_prompt": _MAGI_HUMAN_NEGATIVE_PROMPT,
|
||||
},
|
||||
)
|
||||
|
||||
ALL_PRESETS = (
|
||||
MAGI_HUMAN_BASE,
|
||||
MAGI_HUMAN_DISTILL,
|
||||
MAGI_HUMAN_BASE_TI2V,
|
||||
MAGI_HUMAN_DISTILL_TI2V,
|
||||
MAGI_HUMAN_SR_540P,
|
||||
MAGI_HUMAN_SR_540P_TI2V,
|
||||
MAGI_HUMAN_SR_1080P,
|
||||
MAGI_HUMAN_SR_1080P_TI2V,
|
||||
)
|
||||
@@ -0,0 +1,532 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""End-to-end latent parity test for the daVinci-MagiHuman base text-to-AV pipeline.
|
||||
|
||||
Runs the joint video+audio FlowUniPC denoise loop with CFG=2 on both:
|
||||
- FastVideo MagiHumanDiT loaded from `converted_weights/magi_human_base/transformer/`.
|
||||
- Upstream daVinci-MagiHuman DiTModel loaded from the HF `base/` shards,
|
||||
via the `magi_compiler` / distributed stubs in
|
||||
`tests/local_tests/helpers/magi_human_upstream.py`.
|
||||
|
||||
Both sides use the **same** `FlowUniPCMultistepScheduler` (FastVideo's
|
||||
implementation), identical latent / text inputs, identical scheduler
|
||||
state, and SDPA-routed attention — so drift here is purely the
|
||||
compound of per-call DiT parity drift through the denoise loop + CFG
|
||||
mixing amplification.
|
||||
|
||||
What this catches (that the component-level DiT parity does NOT):
|
||||
- Scheduler integration mistakes (state leaks between video/audio
|
||||
schedulers, wrong shift, wrong `step()` args).
|
||||
- CFG math errors (guidance scale switchover at t=500, per-modality
|
||||
guidance scale wiring, unconditional-path text padding).
|
||||
- Latent-preparation / token-unpacking drift between my
|
||||
`build_packed_inputs` / `unpack_tokens` and the upstream
|
||||
`MagiDataProxy` equivalents.
|
||||
- Compounding behavior: 1% per-call DiT drift compounding through
|
||||
`num_steps * cfg_number` calls.
|
||||
|
||||
Skips when:
|
||||
- `daVinci-MagiHuman/` clone or GAIR/daVinci-MagiHuman base shards
|
||||
are not available locally.
|
||||
- Converted transformer weights are missing (run the conversion
|
||||
script first).
|
||||
- CUDA is unavailable.
|
||||
|
||||
Tolerance: `atol=0.35, rtol=0.05` on bf16 denoise-loop latents. The
|
||||
atol absorbs the observed worst-element drift (~0.31 on a signal of
|
||||
abs_mean ~2.4 — bf16 + CFG amplification + UniPC accumulation). The
|
||||
tight rtol still flags gross structural bugs (sign flip, scheduler
|
||||
state leak, modality branch drop). If tighter parity is wanted,
|
||||
chase the per-call drift first (see the DiT component parity test).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import glob
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from torch.testing import assert_close
|
||||
|
||||
|
||||
# Force SDPA on both sides so the attention kernel is shared.
|
||||
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
|
||||
os.environ.setdefault("MASTER_ADDR", "localhost")
|
||||
os.environ.setdefault("MASTER_PORT", "29519")
|
||||
|
||||
_T5GEMMA_ID = os.getenv("MAGI_HUMAN_T5GEMMA_ID", "google/t5gemma-9b-9b-ul2")
|
||||
_T5_GEMMA_TARGET_LENGTH = 640
|
||||
_SAMPLE_PROMPT = (
|
||||
"A warm afternoon scene: a person sits on a park bench reading a book, "
|
||||
"surrounded by softly swaying trees."
|
||||
)
|
||||
|
||||
|
||||
def _hf_token() -> str | None:
|
||||
for key in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HF_API_KEY"):
|
||||
token = os.environ.get(key)
|
||||
if token:
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def _can_access_t5gemma() -> bool:
|
||||
token = _hf_token()
|
||||
if token is None:
|
||||
return False
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
hf_hub_download(
|
||||
repo_id=_T5GEMMA_ID,
|
||||
filename="config.json",
|
||||
token=token,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _pad_or_trim_dim1(t: torch.Tensor, target: int) -> tuple[torch.Tensor, int]:
|
||||
"""Mirror MagiHumanLatentPreparationStage's text pad-or-trim."""
|
||||
current = t.size(1)
|
||||
if current < target:
|
||||
pad = [0, 0, 0, target - current]
|
||||
return F.pad(t, pad, "constant", 0.0), current
|
||||
return t[:, :target], target
|
||||
|
||||
|
||||
def _encode_magi_human_prompt_pair(device: torch.device):
|
||||
"""Encode the production preset prompt pair once via T5-Gemma."""
|
||||
if not _can_access_t5gemma():
|
||||
pytest.skip(
|
||||
f"{_T5GEMMA_ID} not accessible — gated Google repo; set "
|
||||
"HF_TOKEN / HF_API_KEY and accept the terms of use."
|
||||
)
|
||||
|
||||
for src in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HF_API_KEY"):
|
||||
token = os.environ.get(src)
|
||||
if token:
|
||||
os.environ.setdefault("HF_TOKEN", token)
|
||||
os.environ.setdefault("HUGGINGFACE_HUB_TOKEN", token)
|
||||
break
|
||||
|
||||
try:
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
from fastvideo.configs.models.encoders.t5gemma import (
|
||||
T5GemmaEncoderConfig,
|
||||
)
|
||||
from fastvideo.models.encoders.t5gemma import (
|
||||
T5GemmaEncoderModel,
|
||||
)
|
||||
from fastvideo.pipelines.basic.magi_human.presets import (
|
||||
_MAGI_HUMAN_NEGATIVE_PROMPT,
|
||||
)
|
||||
except Exception as exc:
|
||||
pytest.skip(f"T5-Gemma prompt encoding dependencies unavailable: {exc}")
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained(_T5GEMMA_ID)
|
||||
enc_config = T5GemmaEncoderConfig()
|
||||
enc_config.arch_config.t5gemma_model_path = _T5GEMMA_ID
|
||||
encoder = T5GemmaEncoderModel(enc_config)
|
||||
|
||||
def encode(text: str, text_encoder=encoder) -> tuple[torch.Tensor, int]:
|
||||
inputs = tokenizer(
|
||||
[text],
|
||||
return_tensors="pt",
|
||||
padding=True,
|
||||
truncation=False,
|
||||
).to(device)
|
||||
with torch.inference_mode():
|
||||
hidden = text_encoder(
|
||||
input_ids=inputs["input_ids"],
|
||||
attention_mask=inputs.get("attention_mask"),
|
||||
).last_hidden_state
|
||||
return _pad_or_trim_dim1(hidden.to(torch.float32), _T5_GEMMA_TARGET_LENGTH)
|
||||
|
||||
txt_feat, txt_feat_len = encode(_SAMPLE_PROMPT)
|
||||
neg_txt_feat, neg_txt_feat_len = encode(_MAGI_HUMAN_NEGATIVE_PROMPT)
|
||||
del encoder
|
||||
_cleanup_gpu()
|
||||
return txt_feat, txt_feat_len, neg_txt_feat, neg_txt_feat_len
|
||||
|
||||
|
||||
def _find_base_shard_dir() -> Path | None:
|
||||
"""Return the local path to GAIR/daVinci-MagiHuman/base/ with shards present, or None."""
|
||||
override = os.getenv("MAGI_HUMAN_BASE_SHARD_DIR")
|
||||
if override:
|
||||
p = Path(override)
|
||||
return p if p.is_dir() else None
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
snap = snapshot_download(
|
||||
repo_id="GAIR/daVinci-MagiHuman",
|
||||
allow_patterns=[
|
||||
"base/*.safetensors",
|
||||
"base/model.safetensors.index.json",
|
||||
],
|
||||
)
|
||||
candidate = Path(snap) / "base"
|
||||
if candidate.is_dir() and any(candidate.glob("*.safetensors")):
|
||||
return candidate
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _cleanup_gpu() -> None:
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def _dit_forward_fv(
|
||||
dit, video_latent, audio_latent, audio_feat_len,
|
||||
txt_feat, txt_feat_len, patch_size, coords_style,
|
||||
video_in_channels, audio_in_channels,
|
||||
):
|
||||
"""One FastVideo DiT call — same as MagiHumanDenoisingStage._dit_forward."""
|
||||
from fastvideo.pipelines.basic.magi_human.stages.latent_preparation import (
|
||||
build_packed_inputs, unpack_tokens,
|
||||
)
|
||||
x, coords, mm = build_packed_inputs(
|
||||
video_latent=video_latent, audio_latent=audio_latent,
|
||||
audio_feat_len=audio_feat_len, txt_feat=txt_feat,
|
||||
txt_feat_len=txt_feat_len, patch_size=patch_size,
|
||||
coords_style=coords_style,
|
||||
)
|
||||
video_token_num = x.shape[0] - audio_feat_len - txt_feat_len
|
||||
out = dit(x, coords, mm)
|
||||
return unpack_tokens(
|
||||
out, video_token_num=video_token_num,
|
||||
audio_feat_len=audio_feat_len,
|
||||
video_in_channels=video_in_channels,
|
||||
audio_in_channels=audio_in_channels,
|
||||
latent_shape=tuple(video_latent.shape),
|
||||
patch_size=patch_size,
|
||||
)
|
||||
|
||||
|
||||
def _dit_forward_upstream(
|
||||
dit, video_latent, audio_latent, audio_feat_len,
|
||||
txt_feat, txt_feat_len, patch_size, coords_style,
|
||||
video_in_channels, audio_in_channels,
|
||||
):
|
||||
"""One upstream DiT call — identical input construction + output
|
||||
unpacking to the FastVideo path. The only thing that differs is
|
||||
the DiT module and the extra `varlen_handler` / `local_attn_handler`
|
||||
kwargs the upstream expects.
|
||||
"""
|
||||
from fastvideo.pipelines.basic.magi_human.stages.latent_preparation import (
|
||||
build_packed_inputs, unpack_tokens,
|
||||
)
|
||||
from inference.common import VarlenHandler
|
||||
x, coords, mm = build_packed_inputs(
|
||||
video_latent=video_latent, audio_latent=audio_latent,
|
||||
audio_feat_len=audio_feat_len, txt_feat=txt_feat,
|
||||
txt_feat_len=txt_feat_len, patch_size=patch_size,
|
||||
coords_style=coords_style,
|
||||
)
|
||||
video_token_num = x.shape[0] - audio_feat_len - txt_feat_len
|
||||
total = x.shape[0]
|
||||
cu = torch.tensor([0, total], dtype=torch.int32, device=x.device)
|
||||
varlen = VarlenHandler(
|
||||
cu_seqlens_q=cu, cu_seqlens_k=cu,
|
||||
max_seqlen_q=total, max_seqlen_k=total,
|
||||
)
|
||||
out = dit(
|
||||
x=x, coords_mapping=coords, modality_mapping=mm,
|
||||
varlen_handler=varlen, local_attn_handler=None,
|
||||
)
|
||||
return unpack_tokens(
|
||||
out, video_token_num=video_token_num,
|
||||
audio_feat_len=audio_feat_len,
|
||||
video_in_channels=video_in_channels,
|
||||
audio_in_channels=audio_in_channels,
|
||||
latent_shape=tuple(video_latent.shape),
|
||||
patch_size=patch_size,
|
||||
)
|
||||
|
||||
|
||||
def _build_fastvideo_schedulers(shift: float, num_inference_steps: int, device):
|
||||
"""Mirror current FastVideo production at `magi_human_pipeline.py:146-149`
|
||||
and `denoising.py:105-116`: default scheduler constructor (`shift=1`,
|
||||
no-op) followed by `set_timesteps(..., shift=shift)` so the temporal
|
||||
shift is applied exactly once. The earlier double-shift pattern was
|
||||
reverted with the Wave 11 single-shift fix; if both __init__ and
|
||||
set_timesteps applied non-trivial shift, the schedule would diverge
|
||||
from upstream.
|
||||
"""
|
||||
from fastvideo.models.schedulers.scheduling_flow_unipc_multistep import (
|
||||
FlowUniPCMultistepScheduler,
|
||||
)
|
||||
video_sched = FlowUniPCMultistepScheduler()
|
||||
audio_sched = FlowUniPCMultistepScheduler()
|
||||
video_sched.set_timesteps(num_inference_steps, device=device, shift=shift)
|
||||
audio_sched.set_timesteps(num_inference_steps, device=device, shift=shift)
|
||||
return video_sched, audio_sched
|
||||
|
||||
|
||||
def _build_upstream_schedulers(shift: float, num_inference_steps: int, device):
|
||||
"""Construct schedulers the way the official `MagiEvaluator.eval_with_text`
|
||||
does (`daVinci-MagiHuman/inference/pipeline/video_generate.py:404-407`):
|
||||
`FlowUniPCMultistepScheduler()` with default shift=1.0 in __init__
|
||||
(no-op), then `set_timesteps(num_inference_steps, device, shift=self.shift)`
|
||||
applies shift exactly once. Uses FastVideo's scheduler class for
|
||||
the orchestration (algorithmically identical to the upstream copy
|
||||
of the same Diffusers-derived class) but matches the upstream's
|
||||
*call pattern*.
|
||||
"""
|
||||
from fastvideo.models.schedulers.scheduling_flow_unipc_multistep import (
|
||||
FlowUniPCMultistepScheduler,
|
||||
)
|
||||
video_sched = FlowUniPCMultistepScheduler()
|
||||
audio_sched = FlowUniPCMultistepScheduler()
|
||||
video_sched.set_timesteps(num_inference_steps, device=device, shift=shift)
|
||||
audio_sched.set_timesteps(num_inference_steps, device=device, shift=shift)
|
||||
return video_sched, audio_sched
|
||||
|
||||
|
||||
def _run_denoise_loop(
|
||||
dit, dit_forward_fn, video_latent, audio_latent,
|
||||
txt_feat, txt_feat_len, neg_txt_feat, neg_txt_feat_len,
|
||||
*, video_sched, audio_sched, cfg_number,
|
||||
video_txt_guidance_scale, audio_txt_guidance_scale,
|
||||
patch_size, coords_style, video_in_channels, audio_in_channels,
|
||||
image_latent=None,
|
||||
):
|
||||
"""Joint video+audio FlowUniPC denoise. The schedulers are passed
|
||||
in pre-constructed so each side can mirror its production scheduler
|
||||
init pattern (see `_build_*_schedulers`).
|
||||
"""
|
||||
from fastvideo.forward_context import set_forward_context
|
||||
audio_feat_len = int(audio_latent.shape[1])
|
||||
|
||||
with torch.inference_mode():
|
||||
for idx, t in enumerate(video_sched.timesteps):
|
||||
if image_latent is not None:
|
||||
video_latent[:, :, :1] = image_latent.to(
|
||||
device=video_latent.device,
|
||||
dtype=video_latent.dtype,
|
||||
)[:, :, :1]
|
||||
t_int = int(t.item()) if torch.is_tensor(t) else int(t)
|
||||
with set_forward_context(current_timestep=t_int, attn_metadata=None):
|
||||
v_cond_video, v_cond_audio = dit_forward_fn(
|
||||
dit, video_latent, audio_latent, audio_feat_len,
|
||||
txt_feat, txt_feat_len, patch_size, coords_style,
|
||||
video_in_channels, audio_in_channels,
|
||||
)
|
||||
if cfg_number == 2:
|
||||
v_uncond_video, v_uncond_audio = dit_forward_fn(
|
||||
dit, video_latent, audio_latent, audio_feat_len,
|
||||
neg_txt_feat, neg_txt_feat_len, patch_size, coords_style,
|
||||
video_in_channels, audio_in_channels,
|
||||
)
|
||||
# Upstream's video-guidance drop-at-t<=500 trick.
|
||||
video_guidance = (
|
||||
video_txt_guidance_scale if t > 500 else 2.0
|
||||
)
|
||||
v_video = v_uncond_video + video_guidance * (
|
||||
v_cond_video - v_uncond_video
|
||||
)
|
||||
v_audio = v_uncond_audio + audio_txt_guidance_scale * (
|
||||
v_cond_audio - v_uncond_audio
|
||||
)
|
||||
else:
|
||||
v_video = v_cond_video
|
||||
v_audio = v_cond_audio
|
||||
|
||||
video_latent = video_sched.step(
|
||||
v_video, t, video_latent, return_dict=False,
|
||||
)[0]
|
||||
audio_latent = audio_sched.step(
|
||||
v_audio, t, audio_latent, return_dict=False,
|
||||
)[0]
|
||||
if image_latent is not None:
|
||||
video_latent[:, :, :1] = image_latent.to(
|
||||
device=video_latent.device,
|
||||
dtype=video_latent.dtype,
|
||||
)[:, :, :1]
|
||||
return video_latent, audio_latent
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason="MagiHuman pipeline parity requires CUDA.",
|
||||
)
|
||||
def test_magi_human_pipeline_latent_parity():
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
upstream_src = repo_root / "daVinci-MagiHuman"
|
||||
if not upstream_src.exists():
|
||||
pytest.skip(
|
||||
"Upstream daVinci-MagiHuman/ clone missing. Run "
|
||||
"`git clone --depth 1 https://github.com/GAIR-NLP/daVinci-MagiHuman.git`"
|
||||
)
|
||||
|
||||
base_shard_dir = _find_base_shard_dir()
|
||||
if base_shard_dir is None or not base_shard_dir.is_dir():
|
||||
pytest.skip(
|
||||
"GAIR/daVinci-MagiHuman base/ shards not available locally."
|
||||
)
|
||||
|
||||
converted_dir = Path(os.getenv(
|
||||
"MAGI_HUMAN_DIFFUSERS_PATH",
|
||||
repo_root / "converted_weights" / "magi_human_base",
|
||||
))
|
||||
transformer_dir = converted_dir / "transformer"
|
||||
if not transformer_dir.is_dir():
|
||||
pytest.skip(f"Converted transformer dir missing at {transformer_dir}")
|
||||
|
||||
from tests.local_tests.helpers.magi_human_upstream import (
|
||||
install_stubs, load_upstream_dit,
|
||||
)
|
||||
install_stubs()
|
||||
|
||||
# --- Shared pipeline inputs ---
|
||||
device = torch.device("cuda:0")
|
||||
torch.manual_seed(0)
|
||||
|
||||
# Deliberately tiny so 2 * CFG=2 = 4 DiT calls per side fit in
|
||||
# CI/dev runtime budget.
|
||||
z_dim = 48
|
||||
patch_size = (1, 2, 2)
|
||||
lat_T, lat_H, lat_W = 2, 6, 6
|
||||
video_latent = torch.randn(
|
||||
(1, z_dim, lat_T, lat_H, lat_W),
|
||||
dtype=torch.float32, device=device,
|
||||
)
|
||||
audio_latent = torch.randn(
|
||||
(1, 4, 64), dtype=torch.float32, device=device,
|
||||
)
|
||||
# Production-facing text embeddings: encode the example prompt and the
|
||||
# preset negative prompt via T5-Gemma once, then feed the identical cached
|
||||
# tensors to upstream and FastVideo. This keeps the DiT comparison focused
|
||||
# while still validating prompt/preset content such as the full
|
||||
# three-block MagiHuman negative prompt.
|
||||
txt_feat, txt_feat_len, neg_txt_feat, neg_txt_feat_len = (
|
||||
_encode_magi_human_prompt_pair(device)
|
||||
)
|
||||
|
||||
num_inference_steps = 4 # 4 steps × CFG=2 = 8 DiT calls / side; surfaces compounding drift that 1-step hides
|
||||
shift = 5.0
|
||||
common_kwargs = dict(
|
||||
cfg_number=2,
|
||||
video_txt_guidance_scale=5.0,
|
||||
audio_txt_guidance_scale=5.0,
|
||||
patch_size=patch_size,
|
||||
coords_style="v2",
|
||||
video_in_channels=192,
|
||||
audio_in_channels=64,
|
||||
)
|
||||
|
||||
# --- Upstream side first (so we can free it before loading FastVideo). ---
|
||||
# Upstream uses single-shift scheduler init (matches MagiEvaluator).
|
||||
up_video_sched, up_audio_sched = _build_upstream_schedulers(
|
||||
shift=shift, num_inference_steps=num_inference_steps, device=device,
|
||||
)
|
||||
print("Loading upstream DiTModel from base shards...")
|
||||
upstream_dit = load_upstream_dit(base_shard_dir, device=device, dtype=None)
|
||||
print("Running upstream denoise loop...")
|
||||
ref_video, ref_audio = _run_denoise_loop(
|
||||
upstream_dit, _dit_forward_upstream,
|
||||
video_latent.clone(), audio_latent.clone(),
|
||||
txt_feat.clone(), txt_feat_len,
|
||||
neg_txt_feat.clone(), neg_txt_feat_len,
|
||||
video_sched=up_video_sched, audio_sched=up_audio_sched,
|
||||
**common_kwargs,
|
||||
)
|
||||
ref_video = ref_video.detach().float().cpu()
|
||||
ref_audio = ref_audio.detach().float().cpu()
|
||||
del upstream_dit
|
||||
_cleanup_gpu()
|
||||
|
||||
# --- FastVideo side ---
|
||||
# FastVideo uses double-shift scheduler init (matches
|
||||
# `MagiHumanDenoisingStage` in production: shift in __init__ via
|
||||
# `magi_human_pipeline.initialize_pipeline` AND in set_timesteps).
|
||||
fv_video_sched, fv_audio_sched = _build_fastvideo_schedulers(
|
||||
shift=shift, num_inference_steps=num_inference_steps, device=device,
|
||||
)
|
||||
from fastvideo.configs.models.dits.magi_human import MagiHumanVideoConfig
|
||||
from fastvideo.models.dits.magi_human import MagiHumanDiT
|
||||
from safetensors.torch import load_file
|
||||
|
||||
print("Loading FastVideo MagiHumanDiT from converted transformer/...")
|
||||
fv_cfg = MagiHumanVideoConfig()
|
||||
fv_dit = MagiHumanDiT(fv_cfg)
|
||||
fv_state = {}
|
||||
for shard in sorted(glob.glob(str(transformer_dir / "*.safetensors"))):
|
||||
fv_state.update(load_file(shard))
|
||||
missing, unexpected = fv_dit.load_state_dict(fv_state, strict=False)
|
||||
assert not missing, f"FastVideo DiT missing {len(missing)} keys: {missing[:5]}"
|
||||
assert not unexpected, f"FastVideo DiT unexpected {len(unexpected)} keys: {unexpected[:5]}"
|
||||
fv_dit = fv_dit.to(device=device)
|
||||
fv_dit.eval()
|
||||
|
||||
print("Running FastVideo denoise loop...")
|
||||
fv_video, fv_audio = _run_denoise_loop(
|
||||
fv_dit, _dit_forward_fv,
|
||||
video_latent.clone(), audio_latent.clone(),
|
||||
txt_feat.clone(), txt_feat_len,
|
||||
neg_txt_feat.clone(), neg_txt_feat_len,
|
||||
video_sched=fv_video_sched, audio_sched=fv_audio_sched,
|
||||
**common_kwargs,
|
||||
)
|
||||
fv_video = fv_video.detach().float().cpu()
|
||||
fv_audio = fv_audio.detach().float().cpu()
|
||||
|
||||
# --- Report + assertions ---
|
||||
v_diff = (ref_video - fv_video).abs()
|
||||
a_diff = (ref_audio - fv_audio).abs()
|
||||
print(
|
||||
f"video ref_abs={ref_video.abs().mean().item():.4f} "
|
||||
f"fv_abs={fv_video.abs().mean().item():.4f} "
|
||||
f"diff_max={v_diff.max().item():.4f} "
|
||||
f"diff_mean={v_diff.mean().item():.4f} "
|
||||
f"diff_median={v_diff.median().item():.4f}"
|
||||
)
|
||||
print(
|
||||
f"audio ref_abs={ref_audio.abs().mean().item():.4f} "
|
||||
f"fv_abs={fv_audio.abs().mean().item():.4f} "
|
||||
f"diff_max={a_diff.max().item():.4f} "
|
||||
f"diff_mean={a_diff.mean().item():.4f} "
|
||||
f"diff_median={a_diff.median().item():.4f}"
|
||||
)
|
||||
|
||||
assert ref_video.shape == fv_video.shape
|
||||
assert ref_audio.shape == fv_audio.shape
|
||||
|
||||
# Tolerance budget for 1-step / CFG=2 (bf16 DiT + bf16 CFG mix):
|
||||
# * Single-DiT bf16 drift: diff_mean ~0.008 on `abs ~ 1.0`
|
||||
# (see DiT component parity, `test_magi_human_dit_parity`).
|
||||
# * CFG mixes `v = v_uncond + guidance * (v_cond - v_uncond)`
|
||||
# with guidance=5; cond and uncond drift independently in bf16,
|
||||
# so the post-CFG `diff_mean` scales by ~guidance (~5x).
|
||||
# * One FlowUniPC scheduler step passes that through unchanged.
|
||||
# `diff_max` is the noisiest statistic for bf16 transformer parity
|
||||
# (a single fma quantization can blow it up). Use it only as a loose
|
||||
# guard. The two ratio guards below catch real structural bugs:
|
||||
# `abs_mean` drift signals scale errors / dropped branches, and
|
||||
# `diff_mean / ref_abs` signals systematic per-element bias far
|
||||
# beyond what bf16+CFG noise can produce.
|
||||
assert_close(fv_video, ref_video, atol=0.40, rtol=0.05)
|
||||
assert_close(fv_audio, ref_audio, atol=0.40, rtol=0.05)
|
||||
|
||||
# Global-magnitude guard — tightest single assertion. A gross bug
|
||||
# (scheduler state leak, dropped modality branch, CFG sign flip)
|
||||
# would shift `abs_mean` far beyond the bf16+CFG noise floor.
|
||||
ref_v_abs = ref_video.abs().mean().item()
|
||||
ref_a_abs = ref_audio.abs().mean().item()
|
||||
rel_v = abs(ref_v_abs - fv_video.abs().mean().item()) / max(ref_v_abs, 1e-6)
|
||||
rel_a = abs(ref_a_abs - fv_audio.abs().mean().item()) / max(ref_a_abs, 1e-6)
|
||||
assert rel_v < 0.01, f"video abs_mean drift {rel_v:.2%} > 1%"
|
||||
assert rel_a < 0.01, f"audio abs_mean drift {rel_a:.2%} > 1%"
|
||||
|
||||
# Per-element mean-bias guard — catches systematic shift that
|
||||
# `abs_mean` misses (e.g. equal-magnitude flip across many elements).
|
||||
mean_rel_v = v_diff.mean().item() / max(ref_v_abs, 1e-6)
|
||||
mean_rel_a = a_diff.mean().item() / max(ref_a_abs, 1e-6)
|
||||
assert mean_rel_v < 0.04, f"video mean_diff/ref_abs {mean_rel_v:.2%} > 4%"
|
||||
assert mean_rel_a < 0.04, f"audio mean_diff/ref_abs {mean_rel_a:.2%} > 4%"
|
||||
@@ -0,0 +1,246 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Smoke / preflight tests for the daVinci-MagiHuman base text-to-AV pipeline.
|
||||
|
||||
Two tests:
|
||||
|
||||
* `test_magi_human_typed_surface_preflight` — pure-Python, no GPU, no
|
||||
weights. Verifies that the scaffold is importable, that the preset
|
||||
registers cleanly, and that the DiT module tree matches the upstream
|
||||
HuggingFace checkpoint shape-for-shape on `meta` device. This is what
|
||||
CI should run on every PR.
|
||||
|
||||
* `test_magi_human_pipeline_smoke` — end-to-end pipeline construction +
|
||||
a tiny generate_video call, gated on local converted-weights paths.
|
||||
Skips cleanly when weights are missing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
|
||||
def test_magi_human_typed_surface_preflight() -> None:
|
||||
"""Import + registry + module-tree surface check.
|
||||
|
||||
Covers regressions that would otherwise only surface on a GPU host:
|
||||
preset drop from ALL_PRESETS, renamed modules, registry mis-wiring,
|
||||
or DiT module-tree drift from the upstream checkpoint.
|
||||
"""
|
||||
import fastvideo.registry # noqa: F401 — triggers preset registration
|
||||
from fastvideo.api.presets import get_preset, get_presets_for_family
|
||||
from fastvideo.configs.models.dits.magi_human import (
|
||||
MagiHumanArchConfig,
|
||||
MagiHumanVideoConfig,
|
||||
)
|
||||
from fastvideo.configs.models.encoders.t5gemma import (
|
||||
T5GemmaEncoderArchConfig,
|
||||
T5GemmaEncoderConfig,
|
||||
)
|
||||
from fastvideo.models.dits.magi_human import MagiHumanDiT
|
||||
from fastvideo.pipelines.basic.magi_human.magi_human_pipeline import ( # noqa: F401
|
||||
MagiHumanI2VPipeline,
|
||||
MagiHumanPipeline,
|
||||
MagiHumanSRI2VPipeline,
|
||||
MagiHumanSRPipeline,
|
||||
)
|
||||
from fastvideo.pipelines.basic.magi_human.pipeline_configs import (
|
||||
MagiHumanBaseConfig,
|
||||
MagiHumanBaseI2VConfig,
|
||||
MagiHumanDistillI2VConfig,
|
||||
MagiHumanSR540pConfig,
|
||||
MagiHumanSR540pI2VConfig,
|
||||
)
|
||||
from fastvideo.pipelines.basic.magi_human.stages import ( # noqa: F401
|
||||
MagiHumanDenoisingStage,
|
||||
MagiHumanLatentPreparationStage,
|
||||
MagiHumanReferenceImageStage,
|
||||
MagiHumanSRDenoisingStage,
|
||||
MagiHumanSRLatentPreparationStage,
|
||||
)
|
||||
|
||||
# Presets are registered under the expected family.
|
||||
names = {p.name for p in get_presets_for_family("magi_human")}
|
||||
assert names == {
|
||||
"magi_human_base",
|
||||
"magi_human_distill",
|
||||
"magi_human_base_ti2v",
|
||||
"magi_human_distill_ti2v",
|
||||
"magi_human_sr_540p",
|
||||
"magi_human_sr_540p_ti2v",
|
||||
"magi_human_sr_1080p",
|
||||
"magi_human_sr_1080p_ti2v",
|
||||
}
|
||||
|
||||
base_preset = get_preset("magi_human_base", "magi_human")
|
||||
assert base_preset.workload_type == "t2v"
|
||||
assert base_preset.defaults["num_inference_steps"] == 32
|
||||
assert base_preset.defaults["fps"] == 25
|
||||
|
||||
distill_preset = get_preset("magi_human_distill", "magi_human")
|
||||
assert distill_preset.workload_type == "t2v"
|
||||
assert distill_preset.defaults["num_inference_steps"] == 8
|
||||
assert distill_preset.defaults["guidance_scale"] == 1.0
|
||||
|
||||
base_ti2v_preset = get_preset("magi_human_base_ti2v", "magi_human")
|
||||
assert base_ti2v_preset.workload_type == "i2v"
|
||||
assert base_ti2v_preset.defaults["num_inference_steps"] == 32
|
||||
|
||||
distill_ti2v_preset = get_preset("magi_human_distill_ti2v", "magi_human")
|
||||
assert distill_ti2v_preset.workload_type == "i2v"
|
||||
assert distill_ti2v_preset.defaults["num_inference_steps"] == 8
|
||||
|
||||
sr_preset = get_preset("magi_human_sr_540p", "magi_human")
|
||||
assert sr_preset.workload_type == "t2v"
|
||||
assert sr_preset.defaults["num_inference_steps"] == 32
|
||||
|
||||
sr_ti2v_preset = get_preset("magi_human_sr_540p_ti2v", "magi_human")
|
||||
assert sr_ti2v_preset.workload_type == "i2v"
|
||||
assert sr_ti2v_preset.defaults["num_inference_steps"] == 32
|
||||
|
||||
# Distill pipeline config: same arch as base, CFG=1, 8 steps.
|
||||
from fastvideo.pipelines.basic.magi_human.pipeline_configs import MagiHumanDistillConfig
|
||||
distill_pc = MagiHumanDistillConfig()
|
||||
assert distill_pc.num_inference_steps == 8
|
||||
assert distill_pc.cfg_number == 1
|
||||
assert distill_pc.dit_config.arch_config.num_layers == 40
|
||||
|
||||
base_i2v_pc = MagiHumanBaseI2VConfig()
|
||||
assert base_i2v_pc.image_conditioning is True
|
||||
assert base_i2v_pc.vae_config.load_encoder is True
|
||||
assert base_i2v_pc.vae_config.load_decoder is True
|
||||
|
||||
distill_i2v_pc = MagiHumanDistillI2VConfig()
|
||||
assert distill_i2v_pc.num_inference_steps == 8
|
||||
assert distill_i2v_pc.cfg_number == 1
|
||||
assert distill_i2v_pc.image_conditioning is True
|
||||
assert distill_i2v_pc.vae_config.load_encoder is True
|
||||
|
||||
sr_pc = MagiHumanSR540pConfig()
|
||||
assert sr_pc.num_inference_steps == 32
|
||||
assert sr_pc.sr_num_inference_steps == 5
|
||||
assert sr_pc.noise_value == 220
|
||||
assert sr_pc.sr_audio_noise_scale == 0.7
|
||||
assert sr_pc.sr_video_txt_guidance_scale == 3.5
|
||||
assert sr_pc.sr_height == 512
|
||||
assert sr_pc.sr_width == 896
|
||||
|
||||
sr_i2v_pc = MagiHumanSR540pI2VConfig()
|
||||
assert sr_i2v_pc.image_conditioning is True
|
||||
assert sr_i2v_pc.vae_config.load_encoder is True
|
||||
|
||||
# Config constructs with the documented defaults.
|
||||
pc = MagiHumanBaseConfig()
|
||||
assert pc.flow_shift == 5.0
|
||||
assert pc.cfg_number == 2
|
||||
assert pc.num_inference_steps == 32
|
||||
assert pc.dit_config.arch_config.num_layers == 40
|
||||
assert pc.dit_config.arch_config.hidden_size == 5120
|
||||
assert pc.dit_config.arch_config.num_attention_heads == 40
|
||||
assert pc.dit_config.arch_config.num_heads_kv == 8
|
||||
assert pc.dit_config.arch_config.mm_layers == (0, 1, 2, 3, 36, 37, 38, 39)
|
||||
assert pc.text_encoder_configs[0].arch_config.hidden_size == 3584
|
||||
|
||||
# The DiT module tree matches the upstream HF base/ checkpoint
|
||||
# shape-for-shape. This is checkpoint-loading parity, not numerical
|
||||
# parity — but a regression here means loaded weights won't align.
|
||||
dit_cfg = MagiHumanVideoConfig()
|
||||
with torch.device("meta"):
|
||||
dit = MagiHumanDiT(dit_cfg)
|
||||
fv_shapes = {n: tuple(p.shape) for n, p in dit.state_dict().items()}
|
||||
|
||||
index_path = _hf_index_path_or_none()
|
||||
if index_path is None:
|
||||
pytest.skip("HF repo unavailable (no network / no token) — "
|
||||
"skipping cross-check against GAIR/daVinci-MagiHuman.")
|
||||
with open(index_path) as f:
|
||||
wmap = json.load(f)["weight_map"]
|
||||
hf_keys = set(wmap.keys())
|
||||
fv_keys = set(fv_shapes.keys())
|
||||
|
||||
missing_in_fv = sorted(hf_keys - fv_keys)
|
||||
extra_in_fv = sorted(fv_keys - hf_keys)
|
||||
assert not missing_in_fv, f"fastvideo missing keys: {missing_in_fv[:5]}"
|
||||
assert not extra_in_fv, f"fastvideo extra keys: {extra_in_fv[:5]}"
|
||||
assert len(fv_keys) == 331
|
||||
|
||||
|
||||
def _hf_index_path_or_none() -> str | None:
|
||||
"""Return a local path to the base/ index.json, or None if unavailable."""
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
except ImportError:
|
||||
return None
|
||||
try:
|
||||
return hf_hub_download(
|
||||
repo_id="GAIR/daVinci-MagiHuman",
|
||||
filename="base/model.safetensors.index.json",
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason="MagiHuman pipeline smoke test requires CUDA.",
|
||||
)
|
||||
def test_magi_human_pipeline_smoke() -> None:
|
||||
"""End-to-end smoke: build the pipeline and run a tiny denoise.
|
||||
|
||||
Skips cleanly when the converted-weights directory is not present.
|
||||
"""
|
||||
diffusers_path = os.getenv(
|
||||
"MAGI_HUMAN_DIFFUSERS_PATH",
|
||||
"converted_weights/magi_human_base",
|
||||
)
|
||||
if not os.path.isdir(diffusers_path):
|
||||
pytest.skip(
|
||||
f"Missing converted MagiHuman repo at {diffusers_path}. "
|
||||
f"Run scripts/checkpoint_conversion/convert_magi_human_to_diffusers.py "
|
||||
f"first."
|
||||
)
|
||||
if not os.path.isfile(os.path.join(diffusers_path, "model_index.json")):
|
||||
pytest.skip(f"Missing model_index.json in {diffusers_path}")
|
||||
|
||||
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
|
||||
|
||||
from fastvideo import VideoGenerator
|
||||
|
||||
# Small shapes to keep the smoke test cheap.
|
||||
prompt = "A cheerful person waving at the camera in a well-lit room."
|
||||
seed = 42
|
||||
height = 256
|
||||
width = 448
|
||||
num_frames = 13 # seconds=1, 12fps for smoke; the pipeline derives it
|
||||
fps = 12.0
|
||||
steps = 2
|
||||
|
||||
generator = VideoGenerator.from_pretrained(
|
||||
diffusers_path,
|
||||
num_gpus=1,
|
||||
use_fsdp_inference=False,
|
||||
dit_cpu_offload=False,
|
||||
vae_cpu_offload=False,
|
||||
text_encoder_cpu_offload=False,
|
||||
)
|
||||
try:
|
||||
result = generator.generate_video(
|
||||
prompt=prompt,
|
||||
output_path="outputs_video/magi_human_smoke",
|
||||
save_video=False,
|
||||
height=height,
|
||||
width=width,
|
||||
num_frames=num_frames,
|
||||
fps=fps,
|
||||
num_inference_steps=steps,
|
||||
seed=seed,
|
||||
)
|
||||
finally:
|
||||
generator.shutdown()
|
||||
|
||||
samples = result["samples"]
|
||||
assert samples.ndim == 5, f"expected [B,C,T,H,W], got {samples.shape}"
|
||||
assert samples.shape[0] == 1
|
||||
@@ -0,0 +1,162 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Parity test: FastVideo MagiHuman Stable-Audio wrapper vs the official
|
||||
daVinci-MagiHuman Stable-Audio usage path.
|
||||
|
||||
The sibling `test_magi_human_sa_audio_parity.py` compares FastVideo's
|
||||
`SAAudioVAEModel` against Diffusers `AutoencoderOobleck`, which validates the
|
||||
low-level VAE weights/API. This test instead follows the official
|
||||
daVinci-MagiHuman integration layer:
|
||||
|
||||
* `inference.model.sa_audio.SAAudioFeatureExtractor` is constructed from the
|
||||
full Stable Audio checkpoint (`model_config.json` + `model.safetensors`).
|
||||
* The official loader rebuilds its local `AudioAutoencoder` from
|
||||
`model.pretransform.config` and filters `pretransform.model.*` weights.
|
||||
* The official decode entry point is `SAAudioFeatureExtractor.decode(latents)`,
|
||||
which calls `vae_model.decode(latents)` directly. There is no latent
|
||||
mean/std normalization or reference-audio injection inside this decode layer.
|
||||
* Pipeline post-processing is outside the SA module: `MagiEvaluator` transposes
|
||||
`[B, L, C] -> [C, L]` before decode, then transposes waveform samples and
|
||||
applies `resample_audio_sinc(..., 441 / 512)`.
|
||||
|
||||
This catches drift between FastVideo's full SA wrapper path and the official
|
||||
repo's custom Stable-Audio wrapper/module, not just the bare Diffusers VAE.
|
||||
|
||||
Skips when:
|
||||
* CUDA is unavailable.
|
||||
* `daVinci-MagiHuman/` is not checked out under the repo root.
|
||||
* `stabilityai/stable-audio-open-1.0` is inaccessible (gated; user must have
|
||||
accepted terms and set HF_TOKEN / HUGGINGFACE_HUB_TOKEN / HF_API_KEY).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
|
||||
|
||||
_SA_AUDIO_ID = "stabilityai/stable-audio-open-1.0"
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _upstream_root() -> Path:
|
||||
return _repo_root() / "daVinci-MagiHuman"
|
||||
|
||||
|
||||
def _hf_token():
|
||||
for key in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HF_API_KEY"):
|
||||
value = os.environ.get(key)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _can_access() -> bool:
|
||||
token = _hf_token()
|
||||
if token is None:
|
||||
return False
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
hf_hub_download(
|
||||
repo_id=_SA_AUDIO_ID,
|
||||
filename="model_config.json",
|
||||
token=token,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _stable_audio_snapshot() -> str:
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
return snapshot_download(
|
||||
repo_id=_SA_AUDIO_ID,
|
||||
token=_hf_token(),
|
||||
allow_patterns=["model_config.json", "model.safetensors"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason="MagiHuman official Stable-Audio parity requires CUDA.",
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
not _upstream_root().exists(),
|
||||
reason="daVinci-MagiHuman checkout is required under the repo root.",
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
not _can_access(),
|
||||
reason=(
|
||||
f"{_SA_AUDIO_ID} not accessible — gated Stability AI repo; set "
|
||||
"HF_TOKEN / HF_API_KEY and accept the terms on "
|
||||
f"https://huggingface.co/{_SA_AUDIO_ID}."
|
||||
),
|
||||
)
|
||||
def test_magi_human_sa_audio_official_decode_parity():
|
||||
# Make sure both HF helpers and FastVideo's loader see the same token alias.
|
||||
for src in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HF_API_KEY"):
|
||||
value = os.environ.get(src)
|
||||
if value:
|
||||
os.environ.setdefault("HF_TOKEN", value)
|
||||
os.environ.setdefault("HUGGINGFACE_HUB_TOKEN", value)
|
||||
break
|
||||
|
||||
device = torch.device("cuda:0")
|
||||
|
||||
# --- Official daVinci-MagiHuman path: custom SAAudioFeatureExtractor
|
||||
# rebuilds AudioAutoencoder and filters `pretransform.model.*` from
|
||||
# the full Stable Audio checkpoint.
|
||||
from tests.local_tests.helpers.magi_human_upstream import install_stubs
|
||||
|
||||
install_stubs()
|
||||
from inference.model.sa_audio import SAAudioFeatureExtractor
|
||||
|
||||
upstream_vae = SAAudioFeatureExtractor(
|
||||
device=device,
|
||||
model_path=_stable_audio_snapshot(),
|
||||
)
|
||||
|
||||
# --- FastVideo MagiHuman wrapper path: lazy loader around the native
|
||||
# OobleckVAE port, exactly what the MagiHuman pipeline constructs.
|
||||
from fastvideo.configs.models.vaes import OobleckVAEConfig
|
||||
from fastvideo.models.vaes.sa_audio import SAAudioVAEModel
|
||||
|
||||
fv_config = OobleckVAEConfig()
|
||||
fv_config.pretrained_path = _SA_AUDIO_ID
|
||||
fv_config.pretrained_dtype = "float32"
|
||||
fv_vae = SAAudioVAEModel(fv_config)
|
||||
|
||||
torch.manual_seed(0)
|
||||
latent = torch.randn(
|
||||
(1, fv_config.arch_config.decoder_input_channels, 8),
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
|
||||
with torch.inference_mode():
|
||||
upstream_out = upstream_vae.decode(latent).detach().float().cpu()
|
||||
fv_out = fv_vae.decode(latent).detach().float().cpu()
|
||||
|
||||
print(
|
||||
f"upstream shape={tuple(upstream_out.shape)} "
|
||||
f"abs_mean={upstream_out.abs().mean().item():.6f} "
|
||||
f"range=[{upstream_out.min().item():.4f}, "
|
||||
f"{upstream_out.max().item():.4f}]"
|
||||
)
|
||||
print(
|
||||
f"fv shape={tuple(fv_out.shape)} "
|
||||
f"abs_mean={fv_out.abs().mean().item():.6f} "
|
||||
f"range=[{fv_out.min().item():.4f}, {fv_out.max().item():.4f}]"
|
||||
)
|
||||
diff = (upstream_out - fv_out).abs()
|
||||
print(f"diff max={diff.max().item():.6e} mean={diff.mean().item():.6e}")
|
||||
|
||||
assert upstream_out.shape == fv_out.shape
|
||||
assert_close(fv_out, upstream_out, atol=1e-5, rtol=1e-5)
|
||||
@@ -0,0 +1,123 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Parity test: MagiHuman's audio-VAE path (FastVideo `SAAudioVAEModel`
|
||||
lazy-loader around the native `OobleckVAE` port, shared with the
|
||||
standalone Stable Audio pipeline) vs `diffusers.AutoencoderOobleck.
|
||||
from_pretrained(...)` on the Stable Audio Open 1.0 VAE.
|
||||
|
||||
Companion to `tests/local_tests/vaes/test_oobleck_vae_parity.py`, which
|
||||
already validates `OobleckVAE` itself; this test exercises the wrapper
|
||||
layer that MagiHuman uses (lazy load, device migration, decode output
|
||||
unwrap) so wrapper-level regressions don't slip past the underlying-VAE
|
||||
parity test.
|
||||
|
||||
Skips when:
|
||||
* CUDA is unavailable (VAE is 156M params, small enough for CPU but
|
||||
we keep the test GPU-only to match the pipeline's runtime).
|
||||
* `stabilityai/stable-audio-open-1.0` is inaccessible (gated; user
|
||||
must have accepted terms on the HF repo page).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
|
||||
|
||||
_SA_AUDIO_ID = "stabilityai/stable-audio-open-1.0"
|
||||
|
||||
|
||||
def _hf_token():
|
||||
for k in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HF_API_KEY"):
|
||||
v = os.environ.get(k)
|
||||
if v:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def _can_access() -> bool:
|
||||
token = _hf_token()
|
||||
if token is None:
|
||||
return False
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
hf_hub_download(
|
||||
repo_id=_SA_AUDIO_ID, filename="vae/config.json", token=token,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason="MagiHuman Stable-Audio VAE parity requires CUDA.",
|
||||
)
|
||||
@pytest.mark.skipif(
|
||||
not _can_access(),
|
||||
reason=(f"{_SA_AUDIO_ID} not accessible — gated Stability AI repo; "
|
||||
"set HF_TOKEN / HF_API_KEY and accept the terms on "
|
||||
f"https://huggingface.co/{_SA_AUDIO_ID}."),
|
||||
)
|
||||
def test_magi_human_sa_audio_vae_decode_parity():
|
||||
# Make sure HF_TOKEN is the alias the Diffusers loader actually reads.
|
||||
for src in ("HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HF_API_KEY"):
|
||||
v = os.environ.get(src)
|
||||
if v:
|
||||
os.environ.setdefault("HF_TOKEN", v)
|
||||
os.environ.setdefault("HUGGINGFACE_HUB_TOKEN", v)
|
||||
break
|
||||
|
||||
device = torch.device("cuda:0")
|
||||
|
||||
# --- Reference: direct HF Diffusers call, same as upstream's
|
||||
# SAAudioFeatureExtractor but via the Diffusers Oobleck port. ---
|
||||
from diffusers import AutoencoderOobleck
|
||||
ref_vae = AutoencoderOobleck.from_pretrained(
|
||||
_SA_AUDIO_ID, subfolder="vae", torch_dtype=torch.float32,
|
||||
).to(device).eval()
|
||||
|
||||
# --- FastVideo wrapper path (shared with the standalone Stable
|
||||
# Audio pipeline that landed in main: `OobleckVAEConfig` +
|
||||
# `SAAudioVAEModel` lazy-loader around the first-class
|
||||
# `OobleckVAE` port). ---
|
||||
from fastvideo.configs.models.vaes import OobleckVAEConfig
|
||||
from fastvideo.models.vaes.sa_audio import SAAudioVAEModel
|
||||
fv_config = OobleckVAEConfig()
|
||||
fv_config.pretrained_path = _SA_AUDIO_ID
|
||||
# The default `pretrained_dtype="float16"` matches official stable-
|
||||
# audio-tools, but this parity test runs the reference path in fp32
|
||||
# — so override here.
|
||||
fv_config.pretrained_dtype = "float32"
|
||||
fv_vae = SAAudioVAEModel(fv_config)
|
||||
|
||||
# --- Tiny shared latent ---
|
||||
torch.manual_seed(0)
|
||||
# decoder_input_channels=64, latent length ~8 frames for a quick test.
|
||||
latent = torch.randn(
|
||||
(1, fv_config.arch_config.decoder_input_channels, 8),
|
||||
dtype=torch.float32, device=device,
|
||||
)
|
||||
|
||||
with torch.inference_mode():
|
||||
ref_out = ref_vae.decode(latent).sample.detach().float().cpu()
|
||||
fv_out = fv_vae.decode(latent).detach().float().cpu()
|
||||
|
||||
print(
|
||||
f"ref shape={tuple(ref_out.shape)} "
|
||||
f"abs_mean={ref_out.abs().mean().item():.6f} "
|
||||
f"range=[{ref_out.min().item():.4f}, {ref_out.max().item():.4f}]"
|
||||
)
|
||||
print(
|
||||
f"fv shape={tuple(fv_out.shape)} "
|
||||
f"abs_mean={fv_out.abs().mean().item():.6f} "
|
||||
f"range=[{fv_out.min().item():.4f}, {fv_out.max().item():.4f}]"
|
||||
)
|
||||
diff = (ref_out - fv_out).abs()
|
||||
print(f"diff max={diff.max().item():.6e} mean={diff.mean().item():.6e}")
|
||||
|
||||
assert ref_out.shape == fv_out.shape
|
||||
# Both sides call the same HF class on the same weights in fp32 —
|
||||
# should agree to machine epsilon.
|
||||
assert_close(fv_out, ref_out, atol=1e-5, rtol=1e-5)
|
||||
@@ -0,0 +1,340 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""SR-1080p local-window latent-loop parity for daVinci-MagiHuman.
|
||||
|
||||
This mirrors the SR-540p two-stage parity test but enables upstream's
|
||||
SR2_1080 local-attention layer set on the SR DiT. The reference side uses the
|
||||
test helper's SDPA implementation of FFAHandler's segmented accumulator, so the
|
||||
assertion is a kernel-noise tolerance rather than bit-exact.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from torch.testing import assert_close
|
||||
|
||||
from fastvideo.pipelines.basic.magi_human.pipeline_configs import (
|
||||
_SR_1080P_LOCAL_ATTN_LAYERS,
|
||||
)
|
||||
from tests.local_tests.magi_human.test_magi_human_pipeline_parity import (
|
||||
_build_fastvideo_schedulers,
|
||||
_build_upstream_schedulers,
|
||||
_cleanup_gpu,
|
||||
_dit_forward_fv,
|
||||
_dit_forward_upstream,
|
||||
_find_base_shard_dir,
|
||||
_run_denoise_loop,
|
||||
)
|
||||
from tests.local_tests.magi_human.test_magi_human_sr540p_pipeline_parity import (
|
||||
_load_fv_dit,
|
||||
_prepare_sr_latents,
|
||||
_run_sr_denoise_loop,
|
||||
)
|
||||
|
||||
|
||||
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
|
||||
os.environ.setdefault("MASTER_ADDR", "localhost")
|
||||
os.environ.setdefault("MASTER_PORT", "29522")
|
||||
|
||||
|
||||
def _find_sr1080p_shard_dir() -> Path | None:
|
||||
override = os.getenv("MAGI_HUMAN_SR1080P_SHARD_DIR")
|
||||
if override:
|
||||
path = Path(override)
|
||||
return path if path.is_dir() else None
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
snap = snapshot_download(
|
||||
repo_id="GAIR/daVinci-MagiHuman",
|
||||
allow_patterns=[
|
||||
"1080p_sr/*.safetensors",
|
||||
"1080p_sr/model.safetensors.index.json",
|
||||
],
|
||||
)
|
||||
candidate = Path(snap) / "1080p_sr"
|
||||
if candidate.is_dir() and any(candidate.glob("*.safetensors")):
|
||||
return candidate
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _dit_forward_upstream_local(
|
||||
dit,
|
||||
video_latent,
|
||||
audio_latent,
|
||||
audio_feat_len,
|
||||
txt_feat,
|
||||
txt_feat_len,
|
||||
patch_size,
|
||||
coords_style,
|
||||
video_in_channels,
|
||||
audio_in_channels,
|
||||
):
|
||||
from fastvideo.pipelines.basic.magi_human.stages.latent_preparation import (
|
||||
build_packed_inputs,
|
||||
unpack_tokens,
|
||||
)
|
||||
from inference.common import VarlenHandler
|
||||
from inference.pipeline.data_proxy import calc_local_attn_ffa_handler
|
||||
|
||||
x, coords, mm = build_packed_inputs(
|
||||
video_latent=video_latent,
|
||||
audio_latent=audio_latent,
|
||||
audio_feat_len=audio_feat_len,
|
||||
txt_feat=txt_feat,
|
||||
txt_feat_len=txt_feat_len,
|
||||
patch_size=patch_size,
|
||||
coords_style=coords_style,
|
||||
)
|
||||
video_token_num = x.shape[0] - audio_feat_len - txt_feat_len
|
||||
total = x.shape[0]
|
||||
cu = torch.tensor([0, total], dtype=torch.int32, device=x.device)
|
||||
varlen = VarlenHandler(
|
||||
cu_seqlens_q=cu,
|
||||
cu_seqlens_k=cu,
|
||||
max_seqlen_q=total,
|
||||
max_seqlen_k=total,
|
||||
)
|
||||
local_attn = calc_local_attn_ffa_handler(
|
||||
video_token_num,
|
||||
audio_feat_len + txt_feat_len,
|
||||
video_latent.shape[2] // patch_size[0],
|
||||
11,
|
||||
)
|
||||
out = dit(
|
||||
x=x,
|
||||
coords_mapping=coords,
|
||||
modality_mapping=mm,
|
||||
varlen_handler=varlen,
|
||||
local_attn_handler=local_attn,
|
||||
)
|
||||
return unpack_tokens(
|
||||
out,
|
||||
video_token_num=video_token_num,
|
||||
audio_feat_len=audio_feat_len,
|
||||
video_in_channels=video_in_channels,
|
||||
audio_in_channels=audio_in_channels,
|
||||
latent_shape=tuple(video_latent.shape),
|
||||
patch_size=patch_size,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason="MagiHuman SR-1080p pipeline parity requires CUDA.",
|
||||
)
|
||||
@pytest.mark.parametrize("use_image", [False, True], ids=["t2v", "ti2v"])
|
||||
def test_magi_human_sr1080p_pipeline_latent_parity(use_image: bool):
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
if not (repo_root / "daVinci-MagiHuman").exists():
|
||||
pytest.skip("Upstream daVinci-MagiHuman/ clone missing.")
|
||||
|
||||
base_shard_dir = _find_base_shard_dir()
|
||||
sr_shard_dir = _find_sr1080p_shard_dir()
|
||||
if base_shard_dir is None or not base_shard_dir.is_dir():
|
||||
pytest.skip("GAIR/daVinci-MagiHuman base/ shards not available locally.")
|
||||
if sr_shard_dir is None or not sr_shard_dir.is_dir():
|
||||
pytest.skip("GAIR/daVinci-MagiHuman 1080p_sr/ shards not available locally.")
|
||||
|
||||
converted_dir = Path(os.getenv(
|
||||
"MAGI_HUMAN_SR1080P_DIFFUSERS_PATH",
|
||||
repo_root / "converted_weights" / "magi_human_sr_1080p",
|
||||
))
|
||||
transformer_dir = converted_dir / "transformer"
|
||||
sr_transformer_dir = converted_dir / "sr_transformer"
|
||||
if not transformer_dir.is_dir():
|
||||
pytest.skip(f"Converted base transformer dir missing at {transformer_dir}")
|
||||
if not sr_transformer_dir.is_dir():
|
||||
pytest.skip(f"Converted SR transformer dir missing at {sr_transformer_dir}")
|
||||
|
||||
from tests.local_tests.helpers.magi_human_upstream import (
|
||||
install_stubs,
|
||||
load_upstream_dit,
|
||||
)
|
||||
install_stubs()
|
||||
|
||||
device = torch.device("cuda:0")
|
||||
torch.manual_seed(1080)
|
||||
z_dim = 48
|
||||
patch_size = (1, 2, 2)
|
||||
base_lat_T, base_lat_H, base_lat_W = 24, 4, 4
|
||||
sr_lat_H, sr_lat_W = 6, 8
|
||||
video_latent = torch.randn(
|
||||
(1, z_dim, base_lat_T, base_lat_H, base_lat_W),
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
audio_latent = torch.randn((1, 32, 64), dtype=torch.float32, device=device)
|
||||
base_image_latent = None
|
||||
sr_image_latent = None
|
||||
if use_image:
|
||||
base_image_latent = torch.randn(
|
||||
(1, z_dim, 1, base_lat_H, base_lat_W),
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
sr_image_latent = F.interpolate(
|
||||
base_image_latent,
|
||||
size=(1, sr_lat_H, sr_lat_W),
|
||||
mode="trilinear",
|
||||
align_corners=True,
|
||||
)
|
||||
|
||||
txt_feat_len = 7
|
||||
neg_txt_feat_len = 11
|
||||
txt_feat = torch.randn((1, 640, 3584), dtype=torch.float32, device=device)
|
||||
neg_txt_feat = torch.randn((1, 640, 3584), dtype=torch.float32, device=device)
|
||||
base_steps = 4
|
||||
sr_steps = 2
|
||||
shift = 5.0
|
||||
base_kwargs = dict(
|
||||
cfg_number=2,
|
||||
video_txt_guidance_scale=5.0,
|
||||
audio_txt_guidance_scale=5.0,
|
||||
patch_size=patch_size,
|
||||
coords_style="v2",
|
||||
video_in_channels=192,
|
||||
audio_in_channels=64,
|
||||
image_latent=base_image_latent,
|
||||
)
|
||||
sr_kwargs = dict(
|
||||
patch_size=patch_size,
|
||||
coords_style="v1",
|
||||
video_in_channels=192,
|
||||
audio_in_channels=64,
|
||||
image_latent=sr_image_latent,
|
||||
)
|
||||
|
||||
up_video_sched, up_audio_sched = _build_upstream_schedulers(
|
||||
shift=shift,
|
||||
num_inference_steps=base_steps,
|
||||
device=device,
|
||||
)
|
||||
upstream_base = load_upstream_dit(base_shard_dir, device=device, dtype=None)
|
||||
ref_base_video, ref_base_audio = _run_denoise_loop(
|
||||
upstream_base,
|
||||
_dit_forward_upstream,
|
||||
video_latent.clone(),
|
||||
audio_latent.clone(),
|
||||
txt_feat.clone(),
|
||||
txt_feat_len,
|
||||
neg_txt_feat.clone(),
|
||||
neg_txt_feat_len,
|
||||
video_sched=up_video_sched,
|
||||
audio_sched=up_audio_sched,
|
||||
**base_kwargs,
|
||||
)
|
||||
del upstream_base
|
||||
_cleanup_gpu()
|
||||
|
||||
torch.manual_seed(1081)
|
||||
ref_sr_video_in, ref_sr_audio_in = _prepare_sr_latents(
|
||||
ref_base_video,
|
||||
ref_base_audio,
|
||||
latent_h=sr_lat_H,
|
||||
latent_w=sr_lat_W,
|
||||
noise_value=220,
|
||||
)
|
||||
up_sr_video_sched, _ = _build_upstream_schedulers(
|
||||
shift=shift,
|
||||
num_inference_steps=sr_steps,
|
||||
device=device,
|
||||
)
|
||||
upstream_sr = load_upstream_dit(
|
||||
sr_shard_dir,
|
||||
device=device,
|
||||
dtype=None,
|
||||
local_attn_layers=_SR_1080P_LOCAL_ATTN_LAYERS,
|
||||
)
|
||||
ref_video, ref_audio = _run_sr_denoise_loop(
|
||||
upstream_sr,
|
||||
_dit_forward_upstream_local,
|
||||
ref_sr_video_in.clone(),
|
||||
ref_sr_audio_in.clone(),
|
||||
txt_feat.clone(),
|
||||
txt_feat_len,
|
||||
neg_txt_feat.clone(),
|
||||
neg_txt_feat_len,
|
||||
video_sched=up_sr_video_sched,
|
||||
**sr_kwargs,
|
||||
)
|
||||
ref_video = ref_video.detach().float().cpu()
|
||||
ref_audio = ref_audio.detach().float().cpu()
|
||||
del upstream_sr
|
||||
_cleanup_gpu()
|
||||
|
||||
fv_video_sched, fv_audio_sched = _build_fastvideo_schedulers(
|
||||
shift=shift,
|
||||
num_inference_steps=base_steps,
|
||||
device=device,
|
||||
)
|
||||
fv_base = _load_fv_dit(transformer_dir, device)
|
||||
fv_base_video, fv_base_audio = _run_denoise_loop(
|
||||
fv_base,
|
||||
_dit_forward_fv,
|
||||
video_latent.clone(),
|
||||
audio_latent.clone(),
|
||||
txt_feat.clone(),
|
||||
txt_feat_len,
|
||||
neg_txt_feat.clone(),
|
||||
neg_txt_feat_len,
|
||||
video_sched=fv_video_sched,
|
||||
audio_sched=fv_audio_sched,
|
||||
**base_kwargs,
|
||||
)
|
||||
del fv_base
|
||||
_cleanup_gpu()
|
||||
|
||||
torch.manual_seed(1081)
|
||||
fv_sr_video_in, fv_sr_audio_in = _prepare_sr_latents(
|
||||
fv_base_video,
|
||||
fv_base_audio,
|
||||
latent_h=sr_lat_H,
|
||||
latent_w=sr_lat_W,
|
||||
noise_value=220,
|
||||
)
|
||||
fv_sr_video_sched, _ = _build_fastvideo_schedulers(
|
||||
shift=shift,
|
||||
num_inference_steps=sr_steps,
|
||||
device=device,
|
||||
)
|
||||
fv_sr = _load_fv_dit(sr_transformer_dir, device)
|
||||
fv_sr.configure_local_attention(_SR_1080P_LOCAL_ATTN_LAYERS, frame_receptive_field=11)
|
||||
fv_video, fv_audio = _run_sr_denoise_loop(
|
||||
fv_sr,
|
||||
_dit_forward_fv,
|
||||
fv_sr_video_in.clone(),
|
||||
fv_sr_audio_in.clone(),
|
||||
txt_feat.clone(),
|
||||
txt_feat_len,
|
||||
neg_txt_feat.clone(),
|
||||
neg_txt_feat_len,
|
||||
video_sched=fv_sr_video_sched,
|
||||
**sr_kwargs,
|
||||
)
|
||||
fv_video = fv_video.detach().float().cpu()
|
||||
fv_audio = fv_audio.detach().float().cpu()
|
||||
|
||||
v_diff = (ref_video - fv_video).abs()
|
||||
a_diff = (ref_audio - fv_audio).abs()
|
||||
print(
|
||||
f"sr1080p {('ti2v' if use_image else 't2v')} "
|
||||
f"video diff_max={v_diff.max().item():.4f} diff_mean={v_diff.mean().item():.4f}"
|
||||
)
|
||||
print(
|
||||
f"sr1080p {('ti2v' if use_image else 't2v')} "
|
||||
f"audio diff_max={a_diff.max().item():.4f} diff_mean={a_diff.mean().item():.4f}"
|
||||
)
|
||||
|
||||
assert ref_video.shape == fv_video.shape
|
||||
assert ref_audio.shape == fv_audio.shape
|
||||
assert v_diff.max().item() < 0.05
|
||||
assert_close(fv_audio, ref_audio, atol=0.0, rtol=0.0)
|
||||
if use_image:
|
||||
assert_close(fv_video[:, :, :1], sr_image_latent.detach().cpu(), atol=0.0, rtol=0.0)
|
||||
assert_close(ref_video[:, :, :1], sr_image_latent.detach().cpu(), atol=0.0, rtol=0.0)
|
||||
@@ -0,0 +1,400 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Two-stage SR-540p latent-loop parity for daVinci-MagiHuman."""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from torch.testing import assert_close
|
||||
|
||||
from fastvideo.pipelines.basic.magi_human.stages.sr_latent_preparation import (
|
||||
ZeroSNRDDPMDiscretization,
|
||||
)
|
||||
from tests.local_tests.magi_human.test_magi_human_pipeline_parity import (
|
||||
_build_fastvideo_schedulers,
|
||||
_build_upstream_schedulers,
|
||||
_cleanup_gpu,
|
||||
_dit_forward_fv,
|
||||
_dit_forward_upstream,
|
||||
_find_base_shard_dir,
|
||||
_run_denoise_loop,
|
||||
)
|
||||
|
||||
|
||||
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
|
||||
os.environ.setdefault("MASTER_ADDR", "localhost")
|
||||
os.environ.setdefault("MASTER_PORT", "29521")
|
||||
|
||||
|
||||
def _find_sr540p_shard_dir() -> Path | None:
|
||||
override = os.getenv("MAGI_HUMAN_SR540P_SHARD_DIR")
|
||||
if override:
|
||||
path = Path(override)
|
||||
return path if path.is_dir() else None
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
snap = snapshot_download(
|
||||
repo_id="GAIR/daVinci-MagiHuman",
|
||||
allow_patterns=[
|
||||
"540p_sr/*.safetensors",
|
||||
"540p_sr/model.safetensors.index.json",
|
||||
],
|
||||
)
|
||||
candidate = Path(snap) / "540p_sr"
|
||||
if candidate.is_dir() and any(candidate.glob("*.safetensors")):
|
||||
return candidate
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _prepare_sr_latents(
|
||||
br_video: torch.Tensor,
|
||||
br_audio: torch.Tensor,
|
||||
*,
|
||||
latent_h: int,
|
||||
latent_w: int,
|
||||
noise_value: int,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
latent_video = F.interpolate(
|
||||
br_video,
|
||||
size=(br_video.shape[2], latent_h, latent_w),
|
||||
mode="trilinear",
|
||||
align_corners=True,
|
||||
)
|
||||
if noise_value != 0:
|
||||
noise = torch.randn_like(latent_video, device=latent_video.device)
|
||||
sigmas = ZeroSNRDDPMDiscretization()(
|
||||
1000,
|
||||
do_append_zero=False,
|
||||
flip=True,
|
||||
device=latent_video.device,
|
||||
)
|
||||
sigma = sigmas[noise_value]
|
||||
latent_video = latent_video * sigma + noise * (1 - sigma**2)**0.5
|
||||
sr_audio = torch.randn_like(br_audio, device=br_audio.device) * 0.7 + br_audio * 0.3
|
||||
return latent_video, sr_audio
|
||||
|
||||
|
||||
def _run_sr_denoise_loop(
|
||||
dit,
|
||||
dit_forward_fn,
|
||||
video_latent,
|
||||
audio_latent,
|
||||
txt_feat,
|
||||
txt_feat_len,
|
||||
neg_txt_feat,
|
||||
neg_txt_feat_len,
|
||||
*,
|
||||
video_sched,
|
||||
patch_size,
|
||||
coords_style,
|
||||
video_in_channels,
|
||||
audio_in_channels,
|
||||
image_latent=None,
|
||||
):
|
||||
from fastvideo.forward_context import set_forward_context
|
||||
|
||||
audio_feat_len = int(audio_latent.shape[1])
|
||||
latent_length = video_latent.shape[2]
|
||||
guidance = torch.tensor(3.5, device=video_latent.device).expand(
|
||||
1,
|
||||
1,
|
||||
latent_length,
|
||||
1,
|
||||
1,
|
||||
).clone()
|
||||
guidance[:, :, :13] = 2.0
|
||||
|
||||
with torch.inference_mode():
|
||||
for t in video_sched.timesteps:
|
||||
if image_latent is not None:
|
||||
video_latent[:, :, :1] = image_latent.to(
|
||||
device=video_latent.device,
|
||||
dtype=video_latent.dtype,
|
||||
)[:, :, :1]
|
||||
with set_forward_context(
|
||||
current_timestep=int(t.item()) if torch.is_tensor(t) else int(t),
|
||||
attn_metadata=None,
|
||||
):
|
||||
v_cond_video, _ = dit_forward_fn(
|
||||
dit,
|
||||
video_latent,
|
||||
audio_latent,
|
||||
audio_feat_len,
|
||||
txt_feat,
|
||||
txt_feat_len,
|
||||
patch_size,
|
||||
coords_style,
|
||||
video_in_channels,
|
||||
audio_in_channels,
|
||||
)
|
||||
v_uncond_video, _ = dit_forward_fn(
|
||||
dit,
|
||||
video_latent,
|
||||
audio_latent,
|
||||
audio_feat_len,
|
||||
neg_txt_feat,
|
||||
neg_txt_feat_len,
|
||||
patch_size,
|
||||
coords_style,
|
||||
video_in_channels,
|
||||
audio_in_channels,
|
||||
)
|
||||
v_video = v_uncond_video + guidance * (v_cond_video - v_uncond_video)
|
||||
video_latent = video_sched.step(
|
||||
v_video,
|
||||
t,
|
||||
video_latent,
|
||||
return_dict=False,
|
||||
)[0]
|
||||
if image_latent is not None:
|
||||
video_latent[:, :, :1] = image_latent.to(
|
||||
device=video_latent.device,
|
||||
dtype=video_latent.dtype,
|
||||
)[:, :, :1]
|
||||
return video_latent, audio_latent
|
||||
|
||||
|
||||
def _load_fv_dit(transformer_dir: Path, device: torch.device):
|
||||
from fastvideo.configs.models.dits.magi_human import MagiHumanVideoConfig
|
||||
from fastvideo.models.dits.magi_human import MagiHumanDiT
|
||||
from safetensors.torch import load_file
|
||||
|
||||
dit = MagiHumanDiT(MagiHumanVideoConfig())
|
||||
state = {}
|
||||
for shard in sorted(glob.glob(str(transformer_dir / "*.safetensors"))):
|
||||
state.update(load_file(shard))
|
||||
missing, unexpected = dit.load_state_dict(state, strict=False)
|
||||
assert not missing, f"FastVideo DiT missing {len(missing)} keys: {missing[:5]}"
|
||||
assert not unexpected, f"FastVideo DiT unexpected {len(unexpected)} keys: {unexpected[:5]}"
|
||||
return dit.to(device=device).eval()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason="MagiHuman SR-540p pipeline parity requires CUDA.",
|
||||
)
|
||||
@pytest.mark.parametrize("use_image", [False, True], ids=["t2v", "ti2v"])
|
||||
def test_magi_human_sr540p_pipeline_latent_parity(use_image: bool):
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
if not (repo_root / "daVinci-MagiHuman").exists():
|
||||
pytest.skip("Upstream daVinci-MagiHuman/ clone missing.")
|
||||
|
||||
base_shard_dir = _find_base_shard_dir()
|
||||
sr_shard_dir = _find_sr540p_shard_dir()
|
||||
if base_shard_dir is None or not base_shard_dir.is_dir():
|
||||
pytest.skip("GAIR/daVinci-MagiHuman base/ shards not available locally.")
|
||||
if sr_shard_dir is None or not sr_shard_dir.is_dir():
|
||||
pytest.skip("GAIR/daVinci-MagiHuman 540p_sr/ shards not available locally.")
|
||||
|
||||
converted_dir = Path(os.getenv(
|
||||
"MAGI_HUMAN_SR540P_DIFFUSERS_PATH",
|
||||
repo_root / "converted_weights" / "magi_human_sr_540p",
|
||||
))
|
||||
transformer_dir = converted_dir / "transformer"
|
||||
sr_transformer_dir = converted_dir / "sr_transformer"
|
||||
if not transformer_dir.is_dir():
|
||||
pytest.skip(f"Converted base transformer dir missing at {transformer_dir}")
|
||||
if not sr_transformer_dir.is_dir():
|
||||
pytest.skip(f"Converted SR transformer dir missing at {sr_transformer_dir}")
|
||||
|
||||
from tests.local_tests.helpers.magi_human_upstream import (
|
||||
install_stubs,
|
||||
load_upstream_dit,
|
||||
)
|
||||
install_stubs()
|
||||
|
||||
device = torch.device("cuda:0")
|
||||
torch.manual_seed(540)
|
||||
z_dim = 48
|
||||
patch_size = (1, 2, 2)
|
||||
base_lat_T, base_lat_H, base_lat_W = 2, 6, 6
|
||||
sr_lat_H, sr_lat_W = 8, 10
|
||||
video_latent = torch.randn(
|
||||
(1, z_dim, base_lat_T, base_lat_H, base_lat_W),
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
audio_latent = torch.randn((1, 4, 64), dtype=torch.float32, device=device)
|
||||
base_image_latent = None
|
||||
sr_image_latent = None
|
||||
if use_image:
|
||||
base_image_latent = torch.randn(
|
||||
(1, z_dim, 1, base_lat_H, base_lat_W),
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
sr_image_latent = F.interpolate(
|
||||
base_image_latent,
|
||||
size=(1, sr_lat_H, sr_lat_W),
|
||||
mode="trilinear",
|
||||
align_corners=True,
|
||||
)
|
||||
|
||||
txt_feat_len = 7
|
||||
neg_txt_feat_len = 11
|
||||
txt_feat = torch.randn(
|
||||
(1, 640, 3584),
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
neg_txt_feat = torch.randn(
|
||||
(1, 640, 3584),
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
base_steps = 4
|
||||
sr_steps = 2
|
||||
shift = 5.0
|
||||
base_kwargs = dict(
|
||||
cfg_number=2,
|
||||
video_txt_guidance_scale=5.0,
|
||||
audio_txt_guidance_scale=5.0,
|
||||
patch_size=patch_size,
|
||||
coords_style="v2",
|
||||
video_in_channels=192,
|
||||
audio_in_channels=64,
|
||||
image_latent=base_image_latent,
|
||||
)
|
||||
sr_kwargs = dict(
|
||||
patch_size=patch_size,
|
||||
coords_style="v1",
|
||||
video_in_channels=192,
|
||||
audio_in_channels=64,
|
||||
image_latent=sr_image_latent,
|
||||
)
|
||||
|
||||
up_video_sched, up_audio_sched = _build_upstream_schedulers(
|
||||
shift=shift,
|
||||
num_inference_steps=base_steps,
|
||||
device=device,
|
||||
)
|
||||
upstream_base = load_upstream_dit(base_shard_dir, device=device, dtype=None)
|
||||
ref_base_video, ref_base_audio = _run_denoise_loop(
|
||||
upstream_base,
|
||||
_dit_forward_upstream,
|
||||
video_latent.clone(),
|
||||
audio_latent.clone(),
|
||||
txt_feat.clone(),
|
||||
txt_feat_len,
|
||||
neg_txt_feat.clone(),
|
||||
neg_txt_feat_len,
|
||||
video_sched=up_video_sched,
|
||||
audio_sched=up_audio_sched,
|
||||
**base_kwargs,
|
||||
)
|
||||
del upstream_base
|
||||
_cleanup_gpu()
|
||||
|
||||
torch.manual_seed(541)
|
||||
ref_sr_video_in, ref_sr_audio_in = _prepare_sr_latents(
|
||||
ref_base_video,
|
||||
ref_base_audio,
|
||||
latent_h=sr_lat_H,
|
||||
latent_w=sr_lat_W,
|
||||
noise_value=220,
|
||||
)
|
||||
up_sr_video_sched, _ = _build_upstream_schedulers(
|
||||
shift=shift,
|
||||
num_inference_steps=sr_steps,
|
||||
device=device,
|
||||
)
|
||||
upstream_sr = load_upstream_dit(sr_shard_dir, device=device, dtype=None)
|
||||
ref_video, ref_audio = _run_sr_denoise_loop(
|
||||
upstream_sr,
|
||||
_dit_forward_upstream,
|
||||
ref_sr_video_in.clone(),
|
||||
ref_sr_audio_in.clone(),
|
||||
txt_feat.clone(),
|
||||
txt_feat_len,
|
||||
neg_txt_feat.clone(),
|
||||
neg_txt_feat_len,
|
||||
video_sched=up_sr_video_sched,
|
||||
**sr_kwargs,
|
||||
)
|
||||
ref_video = ref_video.detach().float().cpu()
|
||||
ref_audio = ref_audio.detach().float().cpu()
|
||||
del upstream_sr
|
||||
_cleanup_gpu()
|
||||
|
||||
fv_video_sched, fv_audio_sched = _build_fastvideo_schedulers(
|
||||
shift=shift,
|
||||
num_inference_steps=base_steps,
|
||||
device=device,
|
||||
)
|
||||
fv_base = _load_fv_dit(transformer_dir, device)
|
||||
fv_base_video, fv_base_audio = _run_denoise_loop(
|
||||
fv_base,
|
||||
_dit_forward_fv,
|
||||
video_latent.clone(),
|
||||
audio_latent.clone(),
|
||||
txt_feat.clone(),
|
||||
txt_feat_len,
|
||||
neg_txt_feat.clone(),
|
||||
neg_txt_feat_len,
|
||||
video_sched=fv_video_sched,
|
||||
audio_sched=fv_audio_sched,
|
||||
**base_kwargs,
|
||||
)
|
||||
del fv_base
|
||||
_cleanup_gpu()
|
||||
|
||||
torch.manual_seed(541)
|
||||
fv_sr_video_in, fv_sr_audio_in = _prepare_sr_latents(
|
||||
fv_base_video,
|
||||
fv_base_audio,
|
||||
latent_h=sr_lat_H,
|
||||
latent_w=sr_lat_W,
|
||||
noise_value=220,
|
||||
)
|
||||
fv_sr_video_sched, _ = _build_fastvideo_schedulers(
|
||||
shift=shift,
|
||||
num_inference_steps=sr_steps,
|
||||
device=device,
|
||||
)
|
||||
fv_sr = _load_fv_dit(sr_transformer_dir, device)
|
||||
fv_video, fv_audio = _run_sr_denoise_loop(
|
||||
fv_sr,
|
||||
_dit_forward_fv,
|
||||
fv_sr_video_in.clone(),
|
||||
fv_sr_audio_in.clone(),
|
||||
txt_feat.clone(),
|
||||
txt_feat_len,
|
||||
neg_txt_feat.clone(),
|
||||
neg_txt_feat_len,
|
||||
video_sched=fv_sr_video_sched,
|
||||
**sr_kwargs,
|
||||
)
|
||||
fv_video = fv_video.detach().float().cpu()
|
||||
fv_audio = fv_audio.detach().float().cpu()
|
||||
|
||||
v_diff = (ref_video - fv_video).abs()
|
||||
a_diff = (ref_audio - fv_audio).abs()
|
||||
print(
|
||||
f"sr540p {('ti2v' if use_image else 't2v')} "
|
||||
f"video diff_max={v_diff.max().item():.4f} diff_mean={v_diff.mean().item():.4f}"
|
||||
)
|
||||
print(
|
||||
f"sr540p {('ti2v' if use_image else 't2v')} "
|
||||
f"audio diff_max={a_diff.max().item():.4f} diff_mean={a_diff.mean().item():.4f}"
|
||||
)
|
||||
|
||||
assert ref_video.shape == fv_video.shape
|
||||
assert ref_audio.shape == fv_audio.shape
|
||||
assert_close(fv_audio, ref_audio, atol=0.0, rtol=0.0)
|
||||
assert_close(fv_video, ref_video, atol=0.0, rtol=0.0)
|
||||
if use_image:
|
||||
assert_close(fv_video[:, :, :1], sr_image_latent.detach().cpu(), atol=0.0, rtol=0.0)
|
||||
assert_close(ref_video[:, :, :1], sr_image_latent.detach().cpu(), atol=0.0, rtol=0.0)
|
||||
|
||||
ref_v_abs = ref_video.abs().mean().item()
|
||||
ref_a_abs = ref_audio.abs().mean().item()
|
||||
assert abs(ref_v_abs - fv_video.abs().mean().item()) / max(ref_v_abs, 1e-6) < 0.02
|
||||
assert abs(ref_a_abs - fv_audio.abs().mean().item()) / max(ref_a_abs, 1e-6) < 0.02
|
||||
assert v_diff.mean().item() / max(ref_v_abs, 1e-6) < 0.06
|
||||
assert a_diff.mean().item() / max(ref_a_abs, 1e-6) < 0.04
|
||||
@@ -0,0 +1,186 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""TI2V latent-loop parity for daVinci-MagiHuman.
|
||||
|
||||
This mirrors the base MagiHuman pipeline parity test but enables the upstream
|
||||
`latent_image is not None` branch: the clean image latent is copied into
|
||||
`latent_video[:, :, :1]` before every DiT call and once more after denoising.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
|
||||
from tests.local_tests.magi_human.test_magi_human_pipeline_parity import (
|
||||
_build_fastvideo_schedulers,
|
||||
_build_upstream_schedulers,
|
||||
_cleanup_gpu,
|
||||
_dit_forward_fv,
|
||||
_dit_forward_upstream,
|
||||
_encode_magi_human_prompt_pair,
|
||||
_find_base_shard_dir,
|
||||
_run_denoise_loop,
|
||||
)
|
||||
|
||||
|
||||
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
|
||||
os.environ.setdefault("MASTER_ADDR", "localhost")
|
||||
os.environ.setdefault("MASTER_PORT", "29520")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason="MagiHuman TI2V pipeline parity requires CUDA.",
|
||||
)
|
||||
def test_magi_human_ti2v_pipeline_latent_parity():
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
upstream_src = repo_root / "daVinci-MagiHuman"
|
||||
if not upstream_src.exists():
|
||||
pytest.skip("Upstream daVinci-MagiHuman/ clone missing.")
|
||||
|
||||
base_shard_dir = _find_base_shard_dir()
|
||||
if base_shard_dir is None or not base_shard_dir.is_dir():
|
||||
pytest.skip("GAIR/daVinci-MagiHuman base/ shards not available locally.")
|
||||
|
||||
converted_dir = Path(os.getenv(
|
||||
"MAGI_HUMAN_DIFFUSERS_PATH",
|
||||
repo_root / "converted_weights" / "magi_human_base",
|
||||
))
|
||||
transformer_dir = converted_dir / "transformer"
|
||||
if not transformer_dir.is_dir():
|
||||
pytest.skip(f"Converted transformer dir missing at {transformer_dir}")
|
||||
|
||||
from tests.local_tests.helpers.magi_human_upstream import (
|
||||
install_stubs,
|
||||
load_upstream_dit,
|
||||
)
|
||||
install_stubs()
|
||||
|
||||
device = torch.device("cuda:0")
|
||||
torch.manual_seed(123)
|
||||
|
||||
z_dim = 48
|
||||
patch_size = (1, 2, 2)
|
||||
lat_T, lat_H, lat_W = 2, 6, 6
|
||||
video_latent = torch.randn(
|
||||
(1, z_dim, lat_T, lat_H, lat_W),
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
audio_latent = torch.randn((1, 4, 64), dtype=torch.float32, device=device)
|
||||
image_latent = torch.randn(
|
||||
(1, z_dim, 1, lat_H, lat_W),
|
||||
dtype=torch.float32,
|
||||
device=device,
|
||||
)
|
||||
txt_feat, txt_feat_len, neg_txt_feat, neg_txt_feat_len = (
|
||||
_encode_magi_human_prompt_pair(device)
|
||||
)
|
||||
|
||||
num_inference_steps = 4
|
||||
shift = 5.0
|
||||
common_kwargs = dict(
|
||||
cfg_number=2,
|
||||
video_txt_guidance_scale=5.0,
|
||||
audio_txt_guidance_scale=5.0,
|
||||
patch_size=patch_size,
|
||||
coords_style="v2",
|
||||
video_in_channels=192,
|
||||
audio_in_channels=64,
|
||||
image_latent=image_latent,
|
||||
)
|
||||
|
||||
up_video_sched, up_audio_sched = _build_upstream_schedulers(
|
||||
shift=shift,
|
||||
num_inference_steps=num_inference_steps,
|
||||
device=device,
|
||||
)
|
||||
print("Loading upstream DiTModel from base shards...")
|
||||
upstream_dit = load_upstream_dit(base_shard_dir, device=device, dtype=None)
|
||||
print("Running upstream TI2V denoise loop...")
|
||||
ref_video, ref_audio = _run_denoise_loop(
|
||||
upstream_dit,
|
||||
_dit_forward_upstream,
|
||||
video_latent.clone(),
|
||||
audio_latent.clone(),
|
||||
txt_feat.clone(),
|
||||
txt_feat_len,
|
||||
neg_txt_feat.clone(),
|
||||
neg_txt_feat_len,
|
||||
video_sched=up_video_sched,
|
||||
audio_sched=up_audio_sched,
|
||||
**common_kwargs,
|
||||
)
|
||||
ref_video = ref_video.detach().float().cpu()
|
||||
ref_audio = ref_audio.detach().float().cpu()
|
||||
del upstream_dit
|
||||
_cleanup_gpu()
|
||||
|
||||
fv_video_sched, fv_audio_sched = _build_fastvideo_schedulers(
|
||||
shift=shift,
|
||||
num_inference_steps=num_inference_steps,
|
||||
device=device,
|
||||
)
|
||||
from fastvideo.configs.models.dits.magi_human import MagiHumanVideoConfig
|
||||
from fastvideo.models.dits.magi_human import MagiHumanDiT
|
||||
from safetensors.torch import load_file
|
||||
|
||||
print("Loading FastVideo MagiHumanDiT from converted transformer/...")
|
||||
fv_cfg = MagiHumanVideoConfig()
|
||||
fv_dit = MagiHumanDiT(fv_cfg)
|
||||
fv_state = {}
|
||||
for shard in sorted(glob.glob(str(transformer_dir / "*.safetensors"))):
|
||||
fv_state.update(load_file(shard))
|
||||
missing, unexpected = fv_dit.load_state_dict(fv_state, strict=False)
|
||||
assert not missing, f"FastVideo DiT missing {len(missing)} keys: {missing[:5]}"
|
||||
assert not unexpected, f"FastVideo DiT unexpected {len(unexpected)} keys: {unexpected[:5]}"
|
||||
fv_dit = fv_dit.to(device=device)
|
||||
fv_dit.eval()
|
||||
|
||||
print("Running FastVideo TI2V denoise loop...")
|
||||
fv_video, fv_audio = _run_denoise_loop(
|
||||
fv_dit,
|
||||
_dit_forward_fv,
|
||||
video_latent.clone(),
|
||||
audio_latent.clone(),
|
||||
txt_feat.clone(),
|
||||
txt_feat_len,
|
||||
neg_txt_feat.clone(),
|
||||
neg_txt_feat_len,
|
||||
video_sched=fv_video_sched,
|
||||
audio_sched=fv_audio_sched,
|
||||
**common_kwargs,
|
||||
)
|
||||
fv_video = fv_video.detach().float().cpu()
|
||||
fv_audio = fv_audio.detach().float().cpu()
|
||||
|
||||
v_diff = (ref_video - fv_video).abs()
|
||||
a_diff = (ref_audio - fv_audio).abs()
|
||||
print(
|
||||
f"ti2v video diff_max={v_diff.max().item():.4f} "
|
||||
f"diff_mean={v_diff.mean().item():.4f}"
|
||||
)
|
||||
print(
|
||||
f"ti2v audio diff_max={a_diff.max().item():.4f} "
|
||||
f"diff_mean={a_diff.mean().item():.4f}"
|
||||
)
|
||||
|
||||
assert ref_video.shape == fv_video.shape
|
||||
assert ref_audio.shape == fv_audio.shape
|
||||
assert_close(fv_video, ref_video, atol=0.40, rtol=0.05)
|
||||
assert_close(fv_audio, ref_audio, atol=0.40, rtol=0.05)
|
||||
assert_close(fv_video[:, :, :1], image_latent.detach().cpu(), atol=0.0, rtol=0.0)
|
||||
assert_close(ref_video[:, :, :1], image_latent.detach().cpu(), atol=0.0, rtol=0.0)
|
||||
|
||||
ref_v_abs = ref_video.abs().mean().item()
|
||||
ref_a_abs = ref_audio.abs().mean().item()
|
||||
rel_v = abs(ref_v_abs - fv_video.abs().mean().item()) / max(ref_v_abs, 1e-6)
|
||||
rel_a = abs(ref_a_abs - fv_audio.abs().mean().item()) / max(ref_a_abs, 1e-6)
|
||||
assert rel_v < 0.01, f"video abs_mean drift {rel_v:.2%} > 1%"
|
||||
assert rel_a < 0.01, f"audio abs_mean drift {rel_a:.2%} > 1%"
|
||||
assert v_diff.mean().item() / max(ref_v_abs, 1e-6) < 0.04
|
||||
assert a_diff.mean().item() / max(ref_a_abs, 1e-6) < 0.04
|
||||
@@ -0,0 +1,180 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Parity test: FastVideo `AutoencoderKLWan` vs upstream `Wan2_2_VAE`.
|
||||
|
||||
MagiHuman uses the Wan 2.2 TI2V-5B VAE. The two implementations
|
||||
compared here are:
|
||||
|
||||
* Upstream (SandAI port) — `inference/model/vae2_2/vae2_2_module.py::Wan2_2_VAE`
|
||||
loaded from `Wan-AI/Wan2.2-TI2V-5B/Wan2.2_VAE.pth` (the official .pth
|
||||
inside the daVinci-MagiHuman repo). This is the reference.
|
||||
* FastVideo — `fastvideo.models.vaes.wanvae.AutoencoderKLWan` (the
|
||||
class registered as `EntryClass` and resolved by the VAE component
|
||||
loader at runtime; this is what `MagiHumanBaseConfig.vae_config`
|
||||
materializes when the magi pipeline runs). Weights are loaded from
|
||||
a Diffusers-format `vae/` subdir (`config.json` +
|
||||
`diffusion_pytorch_model.safetensors`).
|
||||
|
||||
This test decodes the same random latent through both and asserts the
|
||||
decoded videos are close. Catches regressions in:
|
||||
- FastVideo's `AutoencoderKLWan` weight load / scale / shift handling.
|
||||
- Any deviation in `latents_mean` / `latents_std` baked into the
|
||||
Diffusers-format config vs the upstream constants.
|
||||
|
||||
Skips when:
|
||||
- CUDA is unavailable.
|
||||
- The .pth is not locally available (requires ~2.8 GB download).
|
||||
- The converted MagiHuman Diffusers repo (or any `Wan-AI/*-Diffusers`
|
||||
repo with a `vae/` subdir) is not available locally.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.testing import assert_close
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not torch.cuda.is_available(),
|
||||
reason="VAE parity test requires CUDA.",
|
||||
)
|
||||
def test_magi_human_vae_decode_parity():
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
upstream_src = repo_root / "daVinci-MagiHuman"
|
||||
if not upstream_src.exists():
|
||||
pytest.skip(
|
||||
"Upstream daVinci-MagiHuman/ clone missing — no Wan2_2_VAE source."
|
||||
)
|
||||
|
||||
fv_vae_dir = Path(os.getenv(
|
||||
"MAGI_HUMAN_VAE_DIR",
|
||||
repo_root / "converted_weights" / "magi_human_base" / "vae",
|
||||
))
|
||||
if not (fv_vae_dir / "config.json").is_file():
|
||||
pytest.skip(f"FastVideo VAE dir missing at {fv_vae_dir}")
|
||||
|
||||
# Upstream Wan2_2_VAE needs the raw .pth shipped by Wan-AI/Wan2.2-TI2V-5B
|
||||
# (NOT the -Diffusers variant; that one has safetensors, not .pth).
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
pth_path = hf_hub_download(
|
||||
repo_id="Wan-AI/Wan2.2-TI2V-5B", filename="Wan2.2_VAE.pth",
|
||||
)
|
||||
except Exception as exc:
|
||||
pytest.skip(f"Wan2.2_VAE.pth not available: {exc}")
|
||||
|
||||
# Push upstream + install compiler stubs (the VAE module itself doesn't
|
||||
# need magi_compiler, but `inference.*` imports pull in siblings that do).
|
||||
from tests.local_tests.helpers.magi_human_upstream import install_stubs
|
||||
install_stubs()
|
||||
|
||||
device = torch.device("cuda:0")
|
||||
torch.manual_seed(0)
|
||||
|
||||
# Tiny latent so the test stays well inside GPU memory budget.
|
||||
# z_dim=48, T=1, H=4, W=4 -> VAE decodes to [1, 3, 1 (or 1+4*0), 64, 64]
|
||||
z = torch.randn((1, 48, 1, 4, 4), dtype=torch.float32, device=device)
|
||||
|
||||
# --- Upstream decode ---
|
||||
from inference.model.vae2_2 import Wan2_2_VAE
|
||||
up_vae = Wan2_2_VAE(
|
||||
vae_pth=pth_path,
|
||||
device=device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
with torch.inference_mode():
|
||||
# Wan2_2_VAE.decode expects a (C, T, H, W) latent (no batch dim);
|
||||
# see inference/pipeline/video_generate.py:494 — `self.vae.decode(latent.squeeze(0).to(self.dtype), ...)`.
|
||||
up_out = up_vae.decode(z[0]).detach().float().cpu()
|
||||
|
||||
del up_vae
|
||||
import gc; gc.collect(); torch.cuda.empty_cache()
|
||||
|
||||
# --- FastVideo decode ---
|
||||
# Upstream `Wan2_2_VAE.decode(z)` internally normalizes via
|
||||
# `(z - latents_mean) / latents_std` before feeding the decoder
|
||||
# (see `scale = [mean, 1.0/std]` and the _video_vae.decode call).
|
||||
# FastVideo's `AutoencoderKLWan.decode(z)` expects the input to
|
||||
# ALREADY be in "decoder-input space" (the normalization is the
|
||||
# caller's job — `DecodingStage` applies it). So we mirror the
|
||||
# upstream transform here before calling decode.
|
||||
import glob
|
||||
|
||||
from safetensors.torch import load_file as safetensors_load_file
|
||||
|
||||
from fastvideo.configs.models.vaes import WanVAEConfig
|
||||
from fastvideo.models.loader.component_loader import get_diffusers_config
|
||||
from fastvideo.models.vaes.wanvae import AutoencoderKLWan
|
||||
|
||||
diffusers_cfg = get_diffusers_config(model=str(fv_vae_dir))
|
||||
diffusers_cfg.pop("_class_name", None)
|
||||
diffusers_cfg.pop("_name_or_path", None)
|
||||
fv_config = WanVAEConfig()
|
||||
fv_config.load_encoder = False
|
||||
fv_config.load_decoder = True
|
||||
fv_config.update_model_arch(diffusers_cfg)
|
||||
fv_vae = AutoencoderKLWan(fv_config).to(device=device, dtype=torch.float32)
|
||||
|
||||
# Mirror the VAE component loader: glob `*.safetensors`, merge, load
|
||||
# non-strictly so any unused buffers (per_channel_statistics, etc.)
|
||||
# don't fail the load.
|
||||
sf_files = glob.glob(os.path.join(str(fv_vae_dir), "*.safetensors"))
|
||||
assert sf_files, f"No safetensors files in {fv_vae_dir}"
|
||||
state = {}
|
||||
for sf in sf_files:
|
||||
state.update(safetensors_load_file(sf))
|
||||
fv_vae.load_state_dict(state, strict=False)
|
||||
fv_vae.eval()
|
||||
|
||||
# Upstream's inner `_video_vae.decode(z, scale)` (line 874-877 of
|
||||
# inference/model/vae2_2/vae2_2_module.py) does:
|
||||
# z = z / scale[1] + scale[0] # where scale = [mean, 1/std]
|
||||
# = z * std + mean
|
||||
# FastVideo's `AutoencoderKLWan.decode` expects the pre-denormalized
|
||||
# latent — apply the same transform externally to feed both paths
|
||||
# equivalently.
|
||||
latents_mean = torch.tensor(
|
||||
fv_config.arch_config.latents_mean, dtype=torch.float32, device=device,
|
||||
)
|
||||
latents_std = torch.tensor(
|
||||
fv_config.arch_config.latents_std, dtype=torch.float32, device=device,
|
||||
)
|
||||
z_denormalized = z * latents_std.view(1, -1, 1, 1, 1) + latents_mean.view(1, -1, 1, 1, 1)
|
||||
with torch.inference_mode():
|
||||
fv_out_tensor = fv_vae.decode(z_denormalized)
|
||||
fv_out = fv_out_tensor.detach().float().cpu()
|
||||
|
||||
# Both sides should return a video tensor of shape [..., C, T_dec, H_dec, W_dec].
|
||||
# Normalize shapes for comparison — upstream returns a list per-video or a
|
||||
# single tensor depending on CP group; we just squeeze batch dims.
|
||||
def _squeeze(t):
|
||||
while t.ndim > 4 and t.shape[0] == 1:
|
||||
t = t[0]
|
||||
return t
|
||||
|
||||
up_s = _squeeze(up_out)
|
||||
fv_s = _squeeze(fv_out)
|
||||
print(
|
||||
f"up shape={tuple(up_s.shape)} abs_mean={up_s.abs().mean().item():.4f} "
|
||||
f"range=[{up_s.min().item():.4f}, {up_s.max().item():.4f}]"
|
||||
)
|
||||
print(
|
||||
f"fv shape={tuple(fv_s.shape)} abs_mean={fv_s.abs().mean().item():.4f} "
|
||||
f"range=[{fv_s.min().item():.4f}, {fv_s.max().item():.4f}]"
|
||||
)
|
||||
|
||||
# Wan VAE has a known fp32 op-ordering drift of ~8e-4 caused by
|
||||
# `z * std + mean` (FV) vs `z / (1/std) + mean` (upstream) at decode
|
||||
# normalization. This is a SHARED Wan-family bug, not magi-specific.
|
||||
# Tracked as OQ-7 in tests/local_tests/magi-human.md; tighten to
|
||||
# atol=1e-4 once the Wan VAE op-order fix lands.
|
||||
assert up_s.shape == fv_s.shape, (
|
||||
f"shape mismatch: up={up_s.shape} fv={fv_s.shape}"
|
||||
)
|
||||
diff = (up_s - fv_s).abs()
|
||||
print(
|
||||
f"diff max={diff.max().item():.6f} mean={diff.mean().item():.6f}"
|
||||
)
|
||||
assert_close(fv_s, up_s, atol=1e-3, rtol=1e-3)
|
||||
Reference in New Issue
Block a user