Files
hao-ai-lab-FastVideo/v2/video_generator.py
SolitaryThinker db866dfb04 [misc] v2: simplify docstrings/comments + drop deleted-design-doc citations
Sweep all 296 v2 modules: simplify verbose docstrings/comments and remove 376 dangling
"(design_vN §X)" citations to the now-deleted design docs (v2/README.md is the source of
truth). Comment/docstring-only — AST-verified code-identical; the CPU suite holds at 237
passed / 5 pre-existing. Also applies yapf + ruff --fix auto-fixes (import ordering,
forward-ref annotation de-quoting under `from __future__ import annotations`; behavior-
neutral, suite-confirmed) and adds the legitimate domain terms mot/clen/te to the codespell
ignore-list. Remaining ruff (24) + mypy (68 no-untyped-call) findings are pre-existing v2
debt, untouched here.
2026-06-19 20:54:59 +00:00

275 lines
16 KiB
Python

"""v2 ``VideoGenerator`` — a typed entrypoint mirroring ``fastvideo.entrypoints.VideoGenerator``,
wrapping the v2 ``Engine`` so an upstream-style script runs against the v2 (recipe, runtime) substrate
and the real torch backend on a GPU box.
It consumes the official ``fastvideo.api`` typed configs (``GeneratorConfig`` / ``GenerationRequest`` /
``EngineConfig`` / ``OutputConfig`` / ...), so the only delta from the upstream example is importing
``VideoGenerator`` from ``v2`` instead of ``fastvideo``. Flow: ``from_config(GeneratorConfig)`` →
``generate(GenerationRequest)`` → ``GenerationResult`` with ``.video_path``.
All torch / fastvideo / huggingface imports are lazy (inside methods) so ``import v2`` stays CPU-clean.
Model dispatch lives in the shared ``v2/registry.py`` (HF-id registry primary + architecture-inference
fallback) so a CLI or server resolve identically. 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
# 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.
def _resolve_default(key: str, generic: Any, kwargs: dict, sp: Any, sd: Any, card_attr: str | None = None) -> Any:
"""Resolve one sampling param with precedence kwargs > SamplingParam override > card
``SamplingDefaults`` > generic fallback. ``card_attr`` aliases the card field name when it differs
from the request key (e.g. ``num_inference_steps`` -> ``SamplingDefaults.num_steps``). Pure and
torch-free, so it's unit-testable without a GPU."""
if key in kwargs and kwargs[key] is not None:
return kwargs[key]
if sp is not None and getattr(sp, key, None) is not None:
return getattr(sp, key)
if sd is not None:
cv = getattr(sd, card_attr or key, None)
if cv is not None:
return cv
return generic
class VideoGenerator:
"""Typed t2v entrypoint over a single resident v2 ``ModelInstance`` (mirrors fastvideo's)."""
def __init__(self,
engine: Any,
model_id: str,
*,
instance: Any = None,
supports_av: bool = False,
card: Any = None) -> None:
self._engine = engine
self._model_id = model_id
self._inst = instance # the resident ModelInstance (to read e.g. the audio sample rate)
self._supports_av = supports_av # model emits joint video+sound (LTX-2.3 T2VS) -> auto-request audio
self._card = card # the v2 ModelCard (for per-model sampling_defaults)
# --------------------------------------------------------------------- #
@classmethod
def from_pretrained(cls, model_path: str, **kwargs: Any) -> VideoGenerator:
"""Convenience constructor (mirrors fastvideo's): accepts the legacy ``from_pretrained`` kwargs
(``num_gpus`` / ``use_fsdp_inference`` / ``*_cpu_offload`` / ``pin_cpu_memory`` / ``VSA_sparsity``
/ ...). The v2 bring-up runs single-GPU, resident, on SDPA, so these are accepted for parity but
not all applied."""
from v2.api import EngineConfig, GeneratorConfig, OffloadConfig
engine = EngineConfig(num_gpus=int(kwargs.get("num_gpus", 1)),
use_fsdp_inference=bool(kwargs.get("use_fsdp_inference", False)),
offload=OffloadConfig(text_encoder=bool(kwargs.get("text_encoder_cpu_offload", False)),
dit=bool(kwargs.get("dit_cpu_offload", False)),
vae=bool(kwargs.get("vae_cpu_offload", False)),
pin_cpu_memory=bool(kwargs.get("pin_cpu_memory", False))))
return cls.from_config(GeneratorConfig(model_path=model_path, engine=engine))
@classmethod
def from_config(cls, config: Any) -> VideoGenerator:
"""``config``: ``fastvideo.api.GeneratorConfig``. Resolves the v2 card from ``model_path``,
stamps the (downloaded) checkpoint onto it, and registers it on a fresh v2 ``Engine`` whose
``Platform.detect()`` returns cuda on a GPU box."""
# The v2 bring-up runs dense attention via SDPA (fastvideo-kernel / VSA not built). Honor an
# explicit override but default to SDPA so PipelineSelection.experimental VSA knobs don't break.
os.environ.setdefault("FASTVIDEO_ATTENTION_BACKEND", "TORCH_SDPA")
model_path = config.model_path
if getattr(config.engine, "num_gpus", 1) != 1:
import warnings
warnings.warn("v2 VideoGenerator bring-up is single-GPU; ignoring num_gpus>1.", stacklevel=2)
from huggingface_hub import snapshot_download
from v2.registry import resolve
rev = getattr(config, "revision", None)
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 v2.recipes.wan21 import stamp_wan21_checkpoints # transformer/vae/text_encoder subfolder layout
stamp_wan21_checkpoints(card, root)
from v2._enums import Capability
from v2.card import load_card
from v2.cache import CacheManager
from v2.runtime import Engine
inst = load_card(card, cache_manager=CacheManager.from_card(card)) # platform auto-detect -> cuda
eng = Engine()
eng.register(card.model_id, inst, program)
# A model that advertises TEXT_TO_VIDEO_SOUND (LTX-2.3) generates joint video+audio; the
# entrypoint then issues a T2VS request by default so a plain generate() yields both modalities.
supports_av = card.capabilities.has(Capability.TEXT_TO_VIDEO_SOUND)
return cls(eng, card.model_id, instance=inst, supports_av=supports_av, card=card)
def shutdown(self) -> None:
"""API parity with ``fastvideo.VideoGenerator.shutdown()``. The v2 bring-up holds a single
resident instance that is freed at process exit; there is nothing to tear down explicitly."""
# --------------------------------------------------------------------- #
def generate(self, request: Any, *, want_audio: bool | None = None) -> Any:
"""``request``: ``fastvideo.api.GenerationRequest``. Returns a ``GenerationResult`` (or a list
for multi-prompt) with ``.video_path`` (saved mp4) and optionally ``.frames``.
``want_audio``: ``None`` (default) auto-enables sound for an A/V model (LTX-2.3 → T2VS, both
video+audio in one joint pass); ``True``/``False`` force it on/off. For a video-only model the
auto resolves to video-only (T2V), unchanged."""
from v2.request import DiffusionParams, OutputSpec, TaskType, make_request
s = request.sampling
prompts = request.prompt if isinstance(request.prompt, list) else [request.prompt]
audio = self._supports_av if want_audio is None else bool(want_audio)
results = []
for idx, prompt in enumerate(prompts):
diff = DiffusionParams(num_steps=s.num_inference_steps,
seed=s.seed,
num_frames=s.num_frames,
height=s.height,
width=s.width,
guidance_scale=s.guidance_scale,
negative_prompt=request.negative_prompt or "",
sigmas=tuple(s.sigmas) if getattr(s, "sigmas", None) else None)
if audio: # joint text->video+sound: ask for both modalities so the audio branch runs
req = make_request(TaskType.T2VS,
self._model_id,
prompt,
diffusion=diff,
outputs=OutputSpec(modalities=frozenset({"video", "audio"})))
else:
req = make_request(TaskType.T2V, self._model_id, prompt, diffusion=diff)
out = self._engine.run(req)
results.append(self._result(out, request.output, s.fps, idx))
return results[0] if len(results) == 1 else results
# --------------------------------------------------------------------- #
def generate_video(self, prompt: Any = None, sampling_param: Any = None, **kwargs: Any) -> Any:
"""Convenience API mirroring ``fastvideo.VideoGenerator.generate_video``. Builds a
``GenerationRequest`` from loose kwargs (+ an optional ``SamplingParam`` override) and calls
``generate()``. kwargs: num_inference_steps / seed / num_frames / height / width /
guidance_scale / fps / sigmas / negative_prompt / output_path / output_video_name /
save_video / return_frames / audio (None=auto for an A/V model, True/False to force; the audio
is saved as a sibling ``.wav`` next to the mp4)."""
from v2.api import GenerationRequest, OutputConfig, SamplingConfig
audio = kwargs.pop("audio", None) # None -> auto (by model capability), True/False -> force
sp = sampling_param
# Per-model defaults (LTX-2 wants 8/30 steps + its own res, Wan2.2-TI2V 704x1280@24fps, etc.) come
# from the resolved card; precedence is kwargs > SamplingParam > card defaults > generic fallback.
sd = getattr(self._card, "sampling_defaults", None) if self._card is not None else None
def pick(key: str, default: Any, card_attr: str | None = None) -> Any:
return _resolve_default(key, default, kwargs, sp, sd, card_attr)
sampling = SamplingConfig(num_inference_steps=int(pick("num_inference_steps", 30, "num_steps")),
seed=int(pick("seed", 1024)),
num_frames=int(pick("num_frames", 25)),
height=int(pick("height", 480)),
width=int(pick("width", 832)),
guidance_scale=float(pick("guidance_scale", 5.0)),
fps=int(pick("fps", 16)),
sigmas=pick("sigmas", None))
output = OutputConfig(output_path=kwargs.get("output_path", "outputs/"),
output_video_name=kwargs.get("output_video_name"),
save_video=bool(kwargs.get("save_video", True)),
return_frames=bool(kwargs.get("return_frames", False)))
req = GenerationRequest(prompt=prompt,
negative_prompt=pick("negative_prompt", None),
sampling=sampling,
output=output)
return self.generate(req, want_audio=audio)
# --------------------------------------------------------------------- #
def _result(self, out: Any, output: Any, fps: int, idx: int) -> Any:
import numpy as np
from v2.api import GenerationResult
arts = out.artifacts
save = bool(getattr(output, "save_video", True))
out_dir = getattr(output, "output_path", "outputs/")
stem = (output.output_video_name or f"v2_{self._model_id}_{idx}")
for _ext in (".mp4", ".png", ".wav"):
stem = stem[:-len(_ext)] if stem.endswith(_ext) else stem
def _to_uint8(a: Any) -> Any: # [-1,1] float -> [0,255] uint8
return ((np.clip(np.asarray(a, dtype="float32"), -1.0, 1.0) + 1.0) / 2.0 * 255.0).astype("uint8")
# Modality-aware: a model emits exactly one of video / image / (and/or) audio. T2V/I2V/T2VS carry
# a "video" artifact; T2I (SD3.5 / FLUX.2) an "image" TensorArtifact; audio (Stable Audio) only
# the AudioArtifact. Guard each so an audio/image-only result does not KeyError on "video".
vid = video_path = image_path = None
if "video" in arts:
vid = _to_uint8(arts["video"].frames) # [C,T,H,W] -> [T,H,W,C]
vid = vid.transpose(1, 2, 3, 0) if vid.ndim == 4 else vid
if save:
os.makedirs(out_dir, exist_ok=True)
video_path = os.path.join(out_dir, f"{stem}.mp4")
import imageio.v2 as imageio
imageio.mimsave(video_path, list(vid), fps=int(fps), format="mp4")
elif "image" in arts: # single decoded image (T2I)
art = arts["image"]
img = _to_uint8(getattr(art, "tensor", getattr(art, "frames", art)))
img = np.squeeze(img)
if img.ndim == 4:
img = img[0]
if img.ndim == 3 and img.shape[0] in (1, 3, 4) and img.shape[-1] not in (1, 3, 4):
img = img.transpose(1, 2, 0) # [C,H,W] -> [H,W,C]
vid = img
if save:
os.makedirs(out_dir, exist_ok=True)
image_path = os.path.join(out_dir, f"{stem}.png")
import imageio.v2 as imageio
imageio.imwrite(image_path, img)
# Audio (T2VS / Stable Audio): the AudioArtifact carries the raw stereo waveform; save a sibling
# .wav at the vocoder's real rate (read from the built audio_vae adapter — the artifact's default
# rate is a placeholder). A plain T2V request produces no audio artifact, so this is skipped.
audio = audio_sr = audio_path = None
art = out.artifacts.get("audio") if hasattr(out.artifacts, "get") else None
wav = getattr(art, "samples", None) if art is not None else None
if wav is not None:
audio = np.asarray(wav, dtype="float32")
audio_sr = self._audio_sample_rate(default=int(getattr(art, "sample_rate", 24000) or 24000))
if save:
audio_path = os.path.join(out_dir, f"{stem}.wav")
self._write_wav(audio_path, audio, audio_sr)
res = GenerationResult(frames=(vid if getattr(output, "return_frames", True) else None),
video_path=video_path,
audio=audio,
audio_sample_rate=audio_sr)
if audio_path:
res.extra["audio_path"] = audio_path
if image_path:
res.extra["image_path"] = image_path
return res
def _audio_sample_rate(self, default: int = 24000) -> int:
"""Read the true output rate off the built audio_vae adapter (LTX-2 vocoder = 24000); fall back
to ``default`` if the model has no audio component or the adapter doesn't expose it."""
try:
return int(getattr(self._inst.component("audio_vae"), "sample_rate", default))
except Exception:
return int(default)
@staticmethod
def _write_wav(path: str, wav: Any, sample_rate: int) -> None:
"""Write ``wav`` ([samples] or [channels, samples] / [samples, channels], float in [-1,1]) to a
WAV at ``sample_rate`` (scipy: IEEE-float WAV, no extra deps beyond the installed stack)."""
import numpy as np
from scipy.io import wavfile
a = np.asarray(wav, dtype="float32")
if a.ndim == 2 and a.shape[0] in (1, 2) and a.shape[0] < a.shape[1]:
a = a.T # [channels, samples] -> [samples, channels] (scipy convention)
wavfile.write(path, int(sample_rate), np.clip(a, -1.0, 1.0))