[refactor] v2: shared model registry (HF-id primary + arch fallback) for all entrypoints

Per review: dispatch should be a directly-mapped HF-string -> card registry (like fastvideo), shared
by every entrypoint (VideoGenerator + a future CLI / server), not buried in the generator.

- New v2/registry.py mirrors fastvideo's fastvideo/registry.py hybrid resolution: (1) exact HF repo id
  in an explicit ModelEntry registry [PRIMARY — correct per-model card/capabilities, and the only way to
  split same-architecture capability variants like Wan2.1 T2V vs the i2v 'InP' 1.3B], (2) short repo-name
  match, (3) architecture inference [FALLBACK — local paths / unregistered repos]. resolve(model_path[,
  root]) is the single shared entry point.
- video_generator.py: moved _read_arch_signature/_select_builders into the registry; from_config now
  calls resolve() — registered ids resolve with no config read, else arch inference on a cheap *.json
  snapshot. Reconciles the earlier 'no brittle table' refactor with the 'map hf string -> card' ask: one
  clean registry + a fallback, not three coupled structures.
- Verified: registry resolves exact-id / short-name / arch-fallback / unregistered correctly; CPU suite
  green (202+2); wan21 GPU smoke generates via the new resolve path.
This commit is contained in:
SolitaryThinker
2026-07-04 17:15:34 +00:00
committed by Peiyuan Zhang
parent fa9c58b419
commit 7ac2ff0d1c
3 changed files with 179 additions and 85 deletions
@@ -65,8 +65,10 @@ Each would need a new card + adapters (DiT/VAE/encoder forwards) + maybe a sampl
## How to add a model to the v2 VideoGenerator
1. Ensure a v2 card exists (`v2/models/<family>/`) whose `load_id`s resolve to real `fastvideo.models.*`.
2. Dispatch is automatic: add a branch in `v2/video_generator.py:_select_builders` keyed on the new
architecture (transformer/pipeline/VAE class names) — no HF-id table to edit.
2. Register it in the shared `v2/registry.py` (used by every entrypoint — VideoGenerator, CLI, server):
add a `ModelEntry` mapping its HF id(s) → builders (the explicit PRIMARY path — the only way to split
same-architecture capability variants, e.g. T2V vs the i2v "InP"), and/or a branch in
`select_by_architecture` for the arch-inference FALLBACK (local paths / unregistered repos).
3. If a DiT/VAE/encoder/upsampler forward differs, add an adapter + class-detection in `torch_adapters.py`
and register the component kind in `torch_cuda.py`.
4. Add `examples/inference/basic/v2_<name>.py` mirroring the official example; generate to confirm.
+154
View File
@@ -0,0 +1,154 @@
"""v2 model registry — the SHARED resolution of a model (HF repo id or local checkpoint dir) to its
``(build_card, build_program)`` builders, so every v2 entrypoint (the typed ``VideoGenerator``, a CLI,
the server) uses one source of truth instead of each re-deriving the mapping.
Mirrors fastvideo's ``fastvideo/registry.py`` hybrid resolution:
1. exact HF repo id in the explicit registry — PRIMARY: correct per-model card + capabilities, and
the only way to split same-architecture *capability*
variants (e.g. a Wan2.1 T2V base vs the i2v "InP"
1.3B) that pure architecture inference cannot tell apart.
2. short repo-name match — a renamed / forked copy of a known repo.
3. architecture inference from the checkpoint — FALLBACK: local paths / unregistered repos, keyed on
the pipeline / transformer / VAE class names.
Adding a model = one ``ModelEntry`` below (its HF ids -> builders). New architectures (for the fallback)
are added in ``select_by_architecture``. All builders are CPU-clean v2 submodules imported lazily, so
``import v2.registry`` stays cheap and torch-free.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any, Callable
@dataclass(frozen=True)
class ModelEntry:
"""A registered model family: the exact HF repo ids it serves + its card/program builders."""
hf_ids: tuple[str, ...]
build_card: Callable[[], Any]
build_program: Callable[[], Any]
def _entries() -> list[ModelEntry]:
"""The explicit registry (PRIMARY). Builders imported lazily to avoid import cycles with the model
packages and keep importing this module cheap."""
from .models.ltx2 import (
build_ltx2_base_card,
build_ltx2_base_program,
build_ltx2_card,
build_ltx2_program,
)
from .models.wan_causal import build_wan_causal_card, build_wan_causal_program
from .models.wan21 import (
build_wan21_card,
build_wan22_a14b_card,
build_wan22_ti2v_card,
build_wan_t2v_program,
)
return [
ModelEntry(("Wan-AI/Wan2.1-T2V-1.3B-Diffusers",), build_wan21_card, build_wan_t2v_program),
ModelEntry(("Wan-AI/Wan2.2-TI2V-5B-Diffusers",), build_wan22_ti2v_card, build_wan_t2v_program),
ModelEntry(("Wan-AI/Wan2.2-T2V-A14B-Diffusers",), build_wan22_a14b_card, build_wan_t2v_program),
ModelEntry(("wlsaidhi/SFWan2.1-T2V-1.3B-Diffusers",), build_wan_causal_card, build_wan_causal_program),
# LTX-2 two-stage distilled (ships a spatial_upsampler: base -> upsample -> refine):
ModelEntry(("FastVideo/LTX2-Distilled-Diffusers",), build_ltx2_card, build_ltx2_program),
# LTX-2 single-stage (no upsampler) — the non-distilled base (many-step) AND the 2.3-distilled
# (few-step), which is also single-stage; pass a small num_inference_steps for the distilled one:
ModelEntry(("Davids048/LTX2-Base-Diffusers", "FastVideo/LTX-2.3-Distilled-Diffusers"),
build_ltx2_base_card, build_ltx2_base_program),
]
def _short(p: str) -> str:
return p.rstrip("/").split("/")[-1].lower()
def read_arch_signature(root: str) -> dict:
"""Read a diffusers checkpoint's configs into an architecture signature (the FALLBACK dispatch
keys): pipeline / transformer / VAE class names + the fields that split same-class variants
(VAE ``z_dim``, a second transformer / ``boundary_ratio`` for MoE, a ``spatial_upsampler``).
``root`` is a local diffusers dir or a snapshot of just the ``*.json`` configs."""
def _load(*parts: str) -> dict:
p = os.path.join(root, *parts)
if os.path.exists(p):
with open(p) as f:
return json.load(f)
return {}
mi = _load("model_index.json")
tcfg = _load("transformer", "config.json")
vcfg = _load("vae", "config.json")
return {
"pipeline": mi.get("_class_name"),
"boundary_ratio": mi.get("boundary_ratio"),
"has_transformer_2": os.path.isdir(os.path.join(root, "transformer_2")),
"has_spatial_upsampler": os.path.isdir(os.path.join(root, "spatial_upsampler")) or "spatial_upsampler" in mi,
"transformer_cls": tcfg.get("_class_name"),
"in_channels": tcfg.get("in_channels"),
"vae_z_dim": vcfg.get("z_dim", vcfg.get("latent_channels")),
}
def select_by_architecture(sig: dict):
"""FALLBACK: map an architecture signature -> (build_card, build_program) by class names, so a local
path / renamed repo / new distilled variant of a known arch still resolves with no registry entry."""
tr, pipe = sig.get("transformer_cls"), sig.get("pipeline")
if tr == "LTX2Transformer3DModel":
from .models.ltx2 import (
build_ltx2_base_card,
build_ltx2_base_program,
build_ltx2_card,
build_ltx2_program,
)
if sig.get("has_spatial_upsampler"):
return build_ltx2_card, build_ltx2_program # two-stage (base -> upsample -> refine)
return build_ltx2_base_card, build_ltx2_base_program # single-stage
if tr == "CausalWanTransformer3DModel":
from .models.wan_causal import build_wan_causal_card, build_wan_causal_program
return build_wan_causal_card, build_wan_causal_program
if tr == "WanTransformer3DModel":
from .models.wan21 import (
build_wan21_card,
build_wan22_a14b_card,
build_wan22_ti2v_card,
build_wan_t2v_program,
)
if pipe == "WanDMDPipeline": # FastWan: detected by pipeline class -> precise, not a load crash
raise ValueError(
"v2 registry: WanDMD/FastWan is not supported via the generic Wan path — its checkpoint's "
"to_gate_compress param mapping differs from the generic WanTransformer3DModel load. See "
"examples/inference/basic/V2_PORTING_STATUS.md.")
if sig.get("has_transformer_2") or sig.get("boundary_ratio"):
return build_wan22_a14b_card, build_wan_t2v_program # Wan2.2 MoE (two experts)
if sig.get("vae_z_dim") == 48:
return build_wan22_ti2v_card, build_wan_t2v_program # Wan2.2-TI2V-5B (z_dim=48 VAE)
return build_wan21_card, build_wan_t2v_program # Wan2.1
raise ValueError(
f"v2 registry: unsupported architecture (transformer={tr!r}, pipeline={pipe!r}). Supported "
f"transformers: WanTransformer3DModel / CausalWanTransformer3DModel / LTX2Transformer3DModel. "
f"See examples/inference/basic/V2_PORTING_STATUS.md.")
def resolve(model_path: str, root: str | None = None):
"""Resolve ``model_path`` (an HF repo id or a local checkpoint dir) -> ``(build_card, build_program)``.
PRIMARY: an exact HF-id, then a short repo-name match, in the explicit registry. FALLBACK: when
``model_path`` isn't registered, architecture inference from ``root`` (a local dir or a downloaded
``*.json`` config snapshot). Raises ``ValueError`` if unregistered with ``root=None`` (so the caller
knows to fetch the configs), or if the architecture is unsupported. Shared by every v2 entrypoint."""
entries = _entries()
for e in entries: # 1. exact HF id
if model_path in e.hf_ids:
return e.build_card, e.build_program
short = _short(model_path) # 2. short repo name (renamed / forked copy)
for e in entries:
if any(_short(h) == short for h in e.hf_ids):
return e.build_card, e.build_program
if root is None: # 3. architecture inference (needs the configs)
raise ValueError(
f"v2 registry: {model_path!r} is not registered; pass the checkpoint root for architecture "
f"inference, or add a ModelEntry in v2/registry.py.")
return select_by_architecture(read_arch_signature(root))
+21 -83
View File
@@ -9,85 +9,19 @@ It consumes the OFFICIAL ``fastvideo.api`` typed configs (``GeneratorConfig`` /
All torch / fastvideo / huggingface imports are lazy (inside methods) so ``import v2`` stays CPU-clean.
Model dispatch is registry-style: ``from_config`` reads the checkpoint's architecture (the pipeline /
transformer / VAE class names) and picks the v2 card — no hardcoded repo-id table (mirrors fastvideo's
``get_pipeline_config_cls_from_name``). Wired families: Wan2.1, Wan2.2-TI2V-5B, Wan2.2-A14B (MoE),
SF-causal Wan, LTX-2. Multi-GPU / CPU-FSDP offload / VSA are accepted for API parity but the bring-up
runs single-GPU, resident, on the TORCH_SDPA backend (no fastvideo-kernel) — see GPU_BRINGUP.md.
Model dispatch lives in the SHARED ``v2/registry.py`` (so a CLI / server resolve identically): an
explicit HF-id registry PRIMARY + architecture inference FALLBACK, mirroring fastvideo's
``fastvideo/registry.py``. Wired: Wan2.1, Wan2.2-TI2V-5B, Wan2.2-A14B (MoE), SF-causal Wan, LTX-2
(two-stage distilled + single-stage base/2.3). Multi-GPU / CPU-FSDP offload / VSA are accepted for API
parity but the bring-up runs single-GPU, resident, on TORCH_SDPA (no fastvideo-kernel) — see GPU_BRINGUP.md.
"""
from __future__ import annotations
import os
from typing import Any
def _read_arch_signature(root: str) -> dict:
"""Read a diffusers checkpoint's configs into an architecture signature. Dispatch keys are the
*class names* the model declares (pipeline / transformer / VAE) plus the few config fields that
distinguish same-class variants (VAE ``z_dim``, a second transformer / ``boundary_ratio`` for MoE)
— the same information fastvideo's registry (``get_pipeline_config_cls_from_name``) keys on, not
the repo id. ``root`` is a local diffusers dir (or a snapshot of just the ``*.json`` configs)."""
import json
import os
def _load(*parts: str) -> dict:
p = os.path.join(root, *parts)
if os.path.exists(p):
with open(p) as f:
return json.load(f)
return {}
mi, tcfg, vcfg = _load("model_index.json"), _load("transformer", "config.json"), _load("vae", "config.json")
return {
"pipeline": mi.get("_class_name"),
"boundary_ratio": mi.get("boundary_ratio"),
"has_transformer_2": os.path.isdir(os.path.join(root, "transformer_2")),
"has_spatial_upsampler": os.path.isdir(os.path.join(root, "spatial_upsampler")) or "spatial_upsampler" in mi,
"transformer_cls": tcfg.get("_class_name"),
"in_channels": tcfg.get("in_channels"),
"vae_z_dim": vcfg.get("z_dim", vcfg.get("latent_channels")),
}
def _select_builders(sig: dict):
"""Map an architecture signature -> (build_card, build_program). Which v2 card a checkpoint uses is
determined by its transformer/pipeline/VAE classes — exactly like fastvideo's registry — so a local
path, a renamed repo, or a new distilled variant of a known arch all resolve correctly with no
hardcoded HF-id table. New families are added here by class, next to the card that handles them."""
tr, pipe = sig.get("transformer_cls"), sig.get("pipeline")
if tr == "LTX2Transformer3DModel":
from .models.ltx2 import (
build_ltx2_base_card,
build_ltx2_base_program,
build_ltx2_card,
build_ltx2_program,
)
if sig.get("has_spatial_upsampler"):
return build_ltx2_card, build_ltx2_program # distilled two-stage (base→upsample→refine)
return build_ltx2_base_card, build_ltx2_base_program # single-stage base model
if tr == "CausalWanTransformer3DModel":
from .models.wan_causal import build_wan_causal_card, build_wan_causal_program
return build_wan_causal_card, build_wan_causal_program
if tr == "WanTransformer3DModel":
from .models.wan21 import (
build_wan21_card,
build_wan22_a14b_card,
build_wan22_ti2v_card,
build_wan_t2v_program,
)
if pipe == "WanDMDPipeline": # FastWan: detected by pipeline class -> precise, not a load crash
raise ValueError(
"v2 VideoGenerator: WanDMD/FastWan is not supported via the generic Wan path — its "
"checkpoint's to_gate_compress param mapping differs from the generic WanTransformer3DModel "
"load. See examples/inference/basic/V2_PORTING_STATUS.md.")
if sig.get("has_transformer_2") or sig.get("boundary_ratio"):
return build_wan22_a14b_card, build_wan_t2v_program # Wan2.2 MoE (two experts)
if sig.get("vae_z_dim") == 48:
return build_wan22_ti2v_card, build_wan_t2v_program # Wan2.2-TI2V-5B (z_dim=48 VAE)
return build_wan21_card, build_wan_t2v_program # Wan2.1
raise ValueError(
f"v2 VideoGenerator: unsupported architecture (transformer={tr!r}, pipeline={pipe!r}). Supported "
f"transformers: WanTransformer3DModel / CausalWanTransformer3DModel / LTX2Transformer3DModel. "
f"See examples/inference/basic/V2_PORTING_STATUS.md.")
# Model dispatch (HF-id registry primary + architecture-inference fallback) lives in the shared
# ``v2/registry.py`` so every entrypoint (this VideoGenerator, a CLI, the server) resolves identically.
class VideoGenerator:
@@ -130,17 +64,21 @@ class VideoGenerator:
warnings.warn("v2 VideoGenerator bring-up is single-GPU; ignoring num_gpus>1.", stacklevel=2)
from huggingface_hub import snapshot_download
from .registry import resolve
rev = getattr(config, "revision", None)
# Registry-style dispatch on the checkpoint's architecture (class names), not the repo id. Read
# just the configs first (a local dir, or a cheap ``*.json`` snapshot) so an unsupported arch is
# rejected BEFORE the full (possibly 100GB+) weight download; then fetch the weights (cached).
if os.path.isdir(model_path):
root = model_path
else:
root = snapshot_download(model_path, revision=rev, allow_patterns=["*.json", "**/*.json"])
build_card, build_program = _select_builders(_read_arch_signature(root))
if not os.path.isdir(model_path):
root = snapshot_download(model_path, revision=rev) # full weights (idempotent, reuses cache)
local = os.path.isdir(model_path)
# Resolve card+program via the shared registry (v2/registry.py): a registered HF-id resolves with
# no download; an unregistered id / local path falls back to architecture inference, which needs
# the configs (the local dir, or a cheap ``*.json`` snapshot fetched BEFORE the full — possibly
# 100GB+ — weight download, so an unsupported arch is rejected early).
try:
build_card, build_program = resolve(model_path)
except ValueError:
cfg_root = model_path if local else snapshot_download(
model_path, revision=rev, allow_patterns=["*.json", "**/*.json"])
build_card, build_program = resolve(model_path, cfg_root)
root = model_path if local else snapshot_download(model_path, revision=rev) # full weights (cached)
card, program = build_card(), build_program()
from .models.wan21 import stamp_wan21_checkpoints # transformer/vae/text_encoder subfolder layout