Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c2612097b | ||
|
|
63ef3435d4 | ||
|
|
4519f8c488 | ||
|
|
c168d3f31f | ||
|
|
29372dc5e8 |
@@ -0,0 +1,196 @@
|
||||
# Open-H3-Regenerate-2K
|
||||
|
||||
`OpenH3Regenerate2KPipeline` regenerates a 768P [MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) draft video
|
||||
(1344x768, 121 frames, 24 fps) at **2560x1440** with three refine steps. It is video to video: the draft is the input,
|
||||
and the output is a silent 2K MP4. FastVideo ports NVlabs'
|
||||
[Open-H3-Regenerate-2K](https://huggingface.co/Efficient-Large-Model/Open-H3-Regenerate-2K)
|
||||
([project page](https://nvlabs.github.io/Sana/Open-H3-Regenerate-2K/),
|
||||
[reference code](https://github.com/NVlabs/Sana/tree/sol-engine/models/Open-H3-Regenerate-2K)); the dense BF16
|
||||
`standard_fwd` path of that repo is the parity reference. The accelerated `sol_engine_fwd` path (Sol-Attn, MXFP8, CUDA
|
||||
graphs) and the fast 24-layer VAE decoder are not ported.
|
||||
|
||||
```text
|
||||
draft video -> H3 VAE encode -> latent upscaler (2x, 3D conv) -> 3 Euler refine steps -> H3 VAE decode -> 2560x1440
|
||||
H3 DiT + merged LoRA + Sharp curve AdaLN branches
|
||||
```
|
||||
|
||||
## What runs
|
||||
|
||||
| Step | Details |
|
||||
|---|---|
|
||||
| Draft encode | `ffmpeg` resamples to 24 fps, cover-scales (bilinear) and centre-crops to 1344x768 (`draft_size: 768p`, or 1024x576 for `576p`), holds the last frame to 121 frames plus 3 copies. The FP32 H3 VAE posterior is sampled under seed 42. |
|
||||
| Latent upscale | The author's 3D-conv upscaler (`fastvideo/models/upsamplers/minimax_h3_latent_upscaler.py`), BF16, to the 160x90 latent grid. |
|
||||
| Refine | Euler on sigmas `[0.909375, 0.857143, 0.666667, 0]`, CFG 1, model time `1 - sigma`, initial state `(1 - 0.909375) * upscaled + 0.909375 * noise`. `num_inference_steps: 2` uses `[0.7, 0.494118, 0]` (softer, about 30% faster). |
|
||||
| Transformer | The base MiniMax-H3 DiT (the same module as the other H3 pipelines) with the released merged LoRA (FP32, rank 576 on 312 linears, scale 1) and 50 LMS Sharp curve AdaLN branches. |
|
||||
| Decode | The released H3 VAE, or an NVFP4 decoder on Blackwell (see below); the output is trimmed to 121 frames. |
|
||||
|
||||
The merged LoRA loads through the standard H3 community-LoRA path as a constructor-time adapter: the pipeline sets
|
||||
`lora_path` to `<model>/lora/open_h3_regenerate_2k_lora.safetensors` when you do not pass one. The Sharp branches take an
|
||||
8-D timestep curve (`lora/adaln_t_table.bin`, 1025x8) instead of the layer input, so they cannot be folded into weights;
|
||||
`sharp_curve.py` adds them to the AdaLN projection outputs with forward hooks.
|
||||
|
||||
**Audio.** The reference feeds an encoded-silence audio stream to the DiT as conditioning and writes the video with no
|
||||
audio track (`-an`); it neither regenerates nor passes through the draft's audio. FastVideo does the same: the draft's
|
||||
soundtrack is ignored and the output MP4 has no audio.
|
||||
|
||||
## Weights
|
||||
|
||||
The release repo keeps the unmodified MiniMax-H3 components (a copy of `MiniMaxAI/MiniMax-H3` at `6818f6c`) under
|
||||
`base_model/` next to `lora/` and `latent_upsampler/`. Point `model_path` at the Hub id or at a local copy of that layout.
|
||||
`adaln_t_table.bin` is base-derived and is not redistributed in the release repo: put the 32,800-byte file (SHA-256
|
||||
`c02a6c11888297688c1e6278185ea1f947023acfc69f9003bbcdcec9a229a8e7`) at `lora/adaln_t_table.bin`. The reference
|
||||
`download.py` fetches it by byte range from `Comfy-Org/MiniMax-H3` at revision
|
||||
`e5eb578a89295337b8ff433a035929ce0279e0b6` (tensor `adaln_t_table` of
|
||||
`diffusion_models/minimax_h3_ref2va_pruned_int8_convrot.safetensors`) and verifies the hash.
|
||||
|
||||
A GB200/B200-class GPU is needed (about 150 GB with the DiT, the 2K activations and the VAE).
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
python examples/inference/basic/basic_open_h3_regenerate_2k.py \
|
||||
--model-path Efficient-Large-Model/Open-H3-Regenerate-2K \
|
||||
--video draft_768p.mp4 --prompt "A red fox trots through fresh snow" --output outputs/regen2k
|
||||
```
|
||||
|
||||
The prompt conditions the refine steps (use the draft's own prompt). Drafts from other video models are better encoded at
|
||||
576P (`--draft-size 576p`). `--steps 2` runs the two-step variant. The output is always 2560x1440; the first 121 frames of
|
||||
the draft are used.
|
||||
|
||||
## Serving
|
||||
|
||||
```bash
|
||||
fastvideo serve --config examples/serving/openai_open_h3_regenerate_2k.yaml
|
||||
```
|
||||
|
||||
`examples/serving/openai_open_h3_regenerate_2k.yaml` loads the release on one GPU, sets `media_roots`, and pins the 2K
|
||||
defaults. The draft arrives as the typed `video_reference` of `POST /v1/videos/sync` (the response body is the MP4) or of
|
||||
`POST /v1/videos` (an async job; poll `GET /v1/videos/{id}` and download `GET /v1/videos/{id}/content`):
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://localhost:8000/v1/videos/sync \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"prompt": "A red fox trots through fresh snow",
|
||||
"video_reference": {"video_url": "https://example.com/draft_768p.mp4"},
|
||||
"seed": 1766580932
|
||||
}' \
|
||||
-o regen_2k.mp4 -D -
|
||||
```
|
||||
|
||||
The response headers carry `X-Request-Id`, `X-Model`, `X-Inference-Time-S` and `X-Stage-Durations`. Rules:
|
||||
|
||||
- Exactly one `video_reference` (or the legacy `video_path` / `video_url`) and no image or audio references.
|
||||
- `http(s)` URLs must resolve to public addresses unless `server.allow_private_media_urls` is set. A local path is accepted
|
||||
only inside a directory listed in `server.media_roots`. `data:` URLs are refused for video references; this policy is
|
||||
unchanged.
|
||||
- `size`, `width` and `height` may be omitted or `2560x1440`; `num_frames` may be omitted or 124 (the frame count of the
|
||||
VAE grid; the video has 121 frames); `fps` must be 24. `seconds` is not accepted.
|
||||
- `num_inference_steps` is 3 (default) or 2; `seed` sets the refine noise.
|
||||
|
||||
## NVFP4 video decoder
|
||||
|
||||
On Blackwell GPUs the released 36-block video decoder can be replaced by a pre-packed NVFP4 one; the latent space is
|
||||
unchanged, so the draft encode, the upscale and the refine steps are the same and only the decode changes. Both tiers of
|
||||
[FastVideo/FastH3-NVFP4-VAE-Decoder](https://huggingface.co/FastVideo/FastH3-NVFP4-VAE-Decoder) apply here, because the
|
||||
release carries MiniMax-H3's full 36-block VAE (`base_model/vae`):
|
||||
|
||||
| Tier | Decoder blocks | File | Pick it when |
|
||||
|---|---|---|---|
|
||||
| `quality-36L` | 36 | `quality-36L/decoder-nvfp4.safetensors` | you want the released detail back; the default choice |
|
||||
| `fast-8L` | 8 (evenly spaced; the load drops 28 blocks) | `fast-8L/decoder-nvfp4.safetensors` | decode time or memory dominate and some fine detail can go |
|
||||
|
||||
```bash
|
||||
hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' \
|
||||
'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae
|
||||
```
|
||||
|
||||
Select it through the surface you drive the pipeline with; the field, the CLI flag and the environment variable all
|
||||
set `fastvideo_args.h3_vae_nvfp4_decoder`, and the serve-config field wins over the environment.
|
||||
|
||||
```bash
|
||||
# example script
|
||||
python examples/inference/basic/basic_open_h3_regenerate_2k.py --video draft.mp4 --prompt "..." \
|
||||
--vae-decoder fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
|
||||
# `fastvideo generate` and the serve config use the same field
|
||||
fastvideo generate --model-path Efficient-Large-Model/Open-H3-Regenerate-2K --video draft.mp4 --prompt "..." \
|
||||
--h3-vae-nvfp4-decoder fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
|
||||
```
|
||||
|
||||
```yaml
|
||||
generator:
|
||||
pipeline:
|
||||
experimental:
|
||||
h3_vae_nvfp4_decoder: ./fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
|
||||
```
|
||||
|
||||
`FASTVIDEO_H3_VAE_NVFP4_DECODER` stays as the fallback when neither the serve config nor the CLI sets the field. The
|
||||
swap happens in the VAE loader, so the dense decoder never reaches the GPU: it is replaced on the CPU by the exported
|
||||
checkpoint (2.0 GiB of packed weights instead of 9.7 GiB of FP32), moved to the device, and decoded under bf16 autocast.
|
||||
The decoder keeps the pipeline's tiling (at 2560x1440: 8x13 tiles of 256 px with 64 px overlap) and the fused Triton
|
||||
block kernels, which are on by default (`FASTVIDEO_H3_VAE_NVFP4_FUSED`); `FASTVIDEO_H3_VAE_TILE_BATCH` decodes several
|
||||
tiles per decoder call (4 is bit-identical and faster here), and `FASTVIDEO_H3_VAE_NVFP4_CUDA_GRAPH` replays the small
|
||||
fused calls. The MP4 is unaffected: the same 121 frames are trimmed and written.
|
||||
|
||||
Warm decode of one refined latent at 2560x1440 (124 frames) on one GB200, median of 3, with the released decode as the
|
||||
reference:
|
||||
|
||||
| Decoder | Decode (s) | Peak GPU memory (GiB) | PSNR (dB) | SSIM |
|
||||
|---|---:|---:|---:|---:|
|
||||
| released H3 VAE (dense, fp16 autocast) | 23.3 | 18.4 | - | - |
|
||||
| `quality-36L` (fused) | 6.6 | 6.2 | 41.3 | 0.979 |
|
||||
| `quality-36L` (fused, tile batch 4) | 5.0 | 6.3 | 41.3 | 0.979 |
|
||||
| `quality-36L` (eager NVFP4 GEMM) | 67.0 | 6.2 | 41.3 | 0.979 |
|
||||
| `fast-8L` (fused) | 1.7 | 5.3 | 32.8 | 0.892 |
|
||||
| `fast-8L` (eager NVFP4 GEMM) | 16.1 | 5.2 | 32.8 | 0.892 |
|
||||
|
||||
The fused kernels are an exact speedup: the eager and fused rows decode bit-identical frames, and so does batching
|
||||
tiles, so the PSNR/SSIM columns measure the tier and nothing else. The eager NVFP4 GEMM path loses to the dense fp16
|
||||
decoder at these tile sizes (67 s against 23 s), so keep `FASTVIDEO_H3_VAE_NVFP4_FUSED` on. PSNR against the released
|
||||
decode is bounded by the released decode itself: the refined latent already sits 45.2 dB from the NVlabs reference (see
|
||||
below), so 41.3 dB is a decoder-only difference of the same order. `fast-8L` softens fine texture (fur, foliage) while
|
||||
keeping structure; use it only when the decode budget is tight.
|
||||
|
||||
## Parity with the reference
|
||||
|
||||
Measured on one GB200 (tray hpc-rack-1-5, BF16, dense attention) against the reference `standard_fwd` with the released
|
||||
VAE, same draft, seed (1766580932), prompt and VAE posterior seed (42). The reference's noise is a CUDA generator draw
|
||||
that FastVideo reproduces (the same `torch.Generator(device="cuda")` seed), so no injected noise was needed.
|
||||
|
||||
| Stage / quantity | Fox draft (FastVideo H3 t2v, 768P) | Dance draft (576P, project page) |
|
||||
|---|---|---|
|
||||
| Draft latent, upscaled latent, DiT input | bit-identical | bit-identical |
|
||||
| One DiT forward (LoRA + Sharp), relative L2 | 1.1e-2 | 2.6e-2 |
|
||||
| Refined latent, relative L2 | 1.3e-2 | 3.7e-2 |
|
||||
| Decoded video PSNR / SSIM (21 frames) | 45.2 dB / 0.993 | 42.1 dB / 0.992 |
|
||||
|
||||
The differences come from text-encoder rounding (relative L2 1e-3 on the prompt embeddings) and BF16 kernel choices;
|
||||
the reference reports the same order (1.4% relative L2 on the refined latent between its own runs).
|
||||
|
||||
Warm timings, one GB200 (seconds):
|
||||
|
||||
| | Upscale | Refine (3 steps) | VAE decode | Total |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Reference `standard_fwd` | 0.87 | 71.6 | 15.0 | 87.5 |
|
||||
| FastVideo (end to end, incl. draft encode 5-10 s and decode to host) | 5.9-10.7 (encode + upscale + silence) | 69.5 | 25.0 | 107.5 |
|
||||
|
||||
The first request builds nothing extra but loads weights (about 70 s once). The decode stage is slower than the
|
||||
reference because it also copies 121 frames to host memory and the MP4 encode is outside both columns.
|
||||
|
||||
## Licences
|
||||
|
||||
The release is not covered by one licence; its `README.md` says so, and the runtime code's licence does not replace the
|
||||
model licences. FastVideo only loads these weights and does not redistribute them.
|
||||
|
||||
- The refiner LoRA, the fast H3 VAE decoder and `base_model/` (a copy of `MiniMaxAI/MiniMax-H3` at `6818f6c`: DiT, VAEs,
|
||||
text encoder) follow the
|
||||
[MiniMax H3 Community License](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/6818f6c32d12b210915e44ad56a4228c2608f160/LICENSE),
|
||||
including its territory, use and redistribution terms (`base_model/LICENSE`, `NOTICE`). The Qwen3-VL text encoder's
|
||||
Apache-2.0 text ships as `LICENSE-Qwen.txt`.
|
||||
- The latent upscaler follows its own licence:
|
||||
[LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler](https://github.com/LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler)
|
||||
(MIT, per the reference code) and its [weights](https://huggingface.co/LBH-123-AI/Minimax_h3_latent_Upscaler)
|
||||
(`latent_upsampler/LICENSE`, Apache-2.0). `minimax_h3_latent_upscaler.py` is a port of that node's network.
|
||||
- The merged LoRA includes [LightX2V FL2VA Turbo](https://huggingface.co/lightx2v/Minimax-h3-Turbo) and
|
||||
[LMS Sharp](https://huggingface.co/Alissonerdx/Minimax-H3-ComfyUI), which keep their own licences; the Sharp
|
||||
`adaln_t_table` is read from [Comfy-Org/MiniMax-H3](https://huggingface.co/Comfy-Org/MiniMax-H3).
|
||||
@@ -68,6 +68,7 @@ column links a runnable script in `examples/inference/basic/` where one exists.
|
||||
| matrixgame | `FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-GTA-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-TempleRun-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-Base-Diffusers`<br />`FastVideo/Matrix-Game-2.0-GTA-Diffusers`<br />`FastVideo/Matrix-Game-2.0-TempleRun-Diffusers`<br />`mignonjia/mg_longtuning_distilled_zelda`<br />`mignonjia/mg_sf_distilled_zelda_1k_steps`<br />`mignonjia/mg_sf_distilled_zelda`<br />`mignonjia/mg_causal_zelda`<br />`mignonjia/mg_bidirectional_zelda` | I2V | [basic_matrixgame2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame2.py) |
|
||||
| matrixgame | `FastVideo/Matrix-Game-3.0-Base-Distilled-Diffusers` | I2V | [basic_matrixgame3.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame3.py) |
|
||||
| minimax_h3 | `MiniMaxAI/MiniMax-H3` | T2V, I2V | [T2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_t2v.py)<br />[FL2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_fl2va.py)<br />[Ref2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_ref2va.py) |
|
||||
| open_h3_regenerate_2k | `Efficient-Large-Model/Open-H3-Regenerate-2K` | — | [basic_open_h3_regenerate_2k.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_open_h3_regenerate_2k.py) |
|
||||
| sd35 | `stabilityai/stable-diffusion-3.5-medium` | T2I | [basic_sd35_t2i.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_sd35_t2i.py) |
|
||||
| qwen_image21 | `Qwen/Qwen-Image-2.1` | T2I, I2I | [basic_qwen_image21.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_qwen_image21.py) |
|
||||
| stable_audio | `FastVideo/stable-audio-open-1.0-Diffusers` | T2V | [basic_stable_audio.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio.py) |
|
||||
@@ -121,6 +122,9 @@ and distilled) generate video with audio from text, optionally plus an image (se
|
||||
`kandinsky6_sr` IDs upscale an existing video (see the
|
||||
[Video SR guide](kandinsky6_sr.md)).
|
||||
|
||||
**Note (Open-H3-Regenerate-2K)**: `open_h3_regenerate_2k` regenerates an existing 768P MiniMax-H3 draft video at
|
||||
2560x1440 (video to video, silent output); see the [Open-H3-Regenerate-2K guide](open_h3_regenerate_2k.md).
|
||||
|
||||
**Note (LTX-2.5)**: T2V and I2V produce synchronized video and audio. The
|
||||
official `Lightricks/LTX-2.5` repository uses gated, split component files, so
|
||||
it must first be converted to FastVideo's component layout. See the
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Regenerate a 768P MiniMax-H3 draft video at 2560x1440 with Open-H3-Regenerate-2K.
|
||||
|
||||
The draft is VAE-encoded (the first 121 frames at 24 fps, 1344x768), its latent is upscaled, and three refine steps
|
||||
of the H3 transformer with the released LoRA run at 2560x1440. The output video is silent. See
|
||||
docs/inference/open_h3_regenerate_2k.md.
|
||||
|
||||
python examples/inference/basic/basic_open_h3_regenerate_2k.py --video draft.mp4 --prompt "..." --output out/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastvideo import VideoGenerator
|
||||
from fastvideo.api import (
|
||||
ComponentConfig,
|
||||
EngineConfig,
|
||||
GenerationRequest,
|
||||
GeneratorConfig,
|
||||
InputConfig,
|
||||
OffloadConfig,
|
||||
OutputConfig,
|
||||
PipelineSelection,
|
||||
SamplingConfig,
|
||||
)
|
||||
|
||||
DEFAULT_MODEL = "Efficient-Large-Model/Open-H3-Regenerate-2K"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--model-path", default=DEFAULT_MODEL, help="release repo id or a local copy of it")
|
||||
parser.add_argument("--video", required=True, help="768P draft video (URL-free local path)")
|
||||
parser.add_argument("--prompt", required=True, help="text the refine steps are conditioned on")
|
||||
parser.add_argument("--output", type=Path, default=Path("outputs/open_h3_regenerate_2k"))
|
||||
parser.add_argument("--steps", type=int, choices=(2, 3), default=3, help="refine steps (3 is the released setting)")
|
||||
parser.add_argument("--seed", type=int, default=1766580932)
|
||||
parser.add_argument("--attention-backend", default="FLASH_ATTN")
|
||||
parser.add_argument("--draft-size", choices=("768p", "576p"), default="768p",
|
||||
help="size the draft is encoded at; 576p for drafts from other video models")
|
||||
parser.add_argument("--vae-decoder", default=None,
|
||||
help="NVFP4 decoder-nvfp4.safetensors that replaces the VAE decoder at load "
|
||||
"(Blackwell only, see docs/inference/open_h3_regenerate_2k.md)")
|
||||
parser.add_argument("--runs", type=int, default=1, help="repeat the request to measure warm timings")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
experimental = {
|
||||
"attention_backend": args.attention_backend,
|
||||
"draft_size": args.draft_size,
|
||||
}
|
||||
if args.vae_decoder:
|
||||
experimental["h3_vae_nvfp4_decoder"] = args.vae_decoder
|
||||
generator = VideoGenerator.from_config(
|
||||
GeneratorConfig(
|
||||
model_path=args.model_path,
|
||||
engine=EngineConfig(num_gpus=1, offload=OffloadConfig(dit=False, dit_layerwise=False, text_encoder=True, vae=False)),
|
||||
pipeline=PipelineSelection(
|
||||
components=ComponentConfig(),
|
||||
experimental=experimental,
|
||||
),
|
||||
))
|
||||
try:
|
||||
for run in range(args.runs):
|
||||
started = time.perf_counter()
|
||||
result = generator.generate(
|
||||
GenerationRequest(
|
||||
prompt=args.prompt,
|
||||
inputs=InputConfig(video_path=args.video),
|
||||
sampling=SamplingConfig(height=1440,
|
||||
width=2560,
|
||||
num_frames=124,
|
||||
fps=24,
|
||||
num_inference_steps=args.steps,
|
||||
guidance_scale=1.0,
|
||||
seed=args.seed),
|
||||
output=OutputConfig(output_path=str(args.output / f"regen2k_{run}.mp4"),
|
||||
save_video=True,
|
||||
return_frames=False),
|
||||
))
|
||||
print(f"run {run}: {time.perf_counter() - started:.1f}s -> {result.video_path}")
|
||||
finally:
|
||||
generator.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
# OpenAI-compatible Open-H3-Regenerate-2K server (one GPU with about 150 GB free, e.g. B200 / GB200).
|
||||
# POST /v1/videos/sync (or /v1/videos) with a 768P H3 draft as video_reference; the response is the 2560x1440 MP4.
|
||||
# Local draft paths are accepted only inside server.media_roots; http(s) URLs must resolve to public addresses unless
|
||||
# server.allow_private_media_urls is set. data: URLs are always refused. See docs/inference/open_h3_regenerate_2k.md.
|
||||
generator:
|
||||
model_path: Efficient-Large-Model/Open-H3-Regenerate-2K
|
||||
engine:
|
||||
num_gpus: 1
|
||||
offload:
|
||||
dit: false
|
||||
dit_layerwise: false
|
||||
text_encoder: true
|
||||
vae: false
|
||||
pin_cpu_memory: true
|
||||
compile:
|
||||
enabled: false
|
||||
vae_enabled: false
|
||||
pipeline:
|
||||
experimental:
|
||||
attention_backend: FLASH_ATTN
|
||||
# 768p for MiniMax-H3 drafts; 576p encodes drafts from other video models at 1024x576.
|
||||
draft_size: 768p
|
||||
# NVFP4 video decoder (Blackwell only). Download one tier from FastVideo/FastH3-NVFP4-VAE-Decoder and point
|
||||
# this at it; the release VAE has the full 36 blocks, so both tiers apply. `fast-8L` is the cheap option.
|
||||
# h3_vae_nvfp4_decoder: ./fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 8000
|
||||
output_dir: outputs/openai_open_h3_regenerate_2k
|
||||
served_model_name: open-h3-regenerate-2k
|
||||
media_roots:
|
||||
- /data/drafts
|
||||
media_reference_max_bytes: 536870912
|
||||
|
||||
default_request:
|
||||
sampling:
|
||||
height: 1440
|
||||
width: 2560
|
||||
num_frames: 124
|
||||
fps: 24
|
||||
num_inference_steps: 3
|
||||
guidance_scale: 1.0
|
||||
seed: 1766580932
|
||||
output:
|
||||
return_frames: false
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Pipeline configuration for Open-H3-Regenerate-2K (768P H3 draft -> 2560x1440)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
|
||||
|
||||
DRAFT_SIZE_CHOICES = ("768p", "576p")
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpenH3Regenerate2KPipelineConfig(MiniMaxH3PipelineConfig):
|
||||
"""MiniMax-H3 policy plus the refine-stage knobs.
|
||||
|
||||
``draft_size`` is the size the draft video is VAE-encoded at: 768p (1344x768) for MiniMax-H3 drafts, 576p
|
||||
(1024x576) for drafts from other video models. ``refine_start_sigma`` re-noises the upscaled draft to another
|
||||
level; the later steps then follow the shift-6 rule (None keeps the released grid).
|
||||
"""
|
||||
|
||||
draft_size: str = "768p"
|
||||
refine_start_sigma: float | None = None
|
||||
|
||||
def check_pipeline_config(self) -> None:
|
||||
super().check_pipeline_config()
|
||||
if self.draft_size not in DRAFT_SIZE_CHOICES:
|
||||
raise ValueError(f"draft_size must be one of {DRAFT_SIZE_CHOICES}, got {self.draft_size!r}.")
|
||||
if self.refine_start_sigma is not None and not 0.0 < self.refine_start_sigma < 1.0:
|
||||
raise ValueError(f"refine_start_sigma must be in (0, 1), got {self.refine_start_sigma}.")
|
||||
@@ -41,6 +41,10 @@ class RequestAdaptationError(ValueError):
|
||||
"""The transport request cannot be represented by the loaded pipeline."""
|
||||
|
||||
|
||||
OPEN_H3_REGENERATE_2K_FAMILY = "open_h3_regenerate_2k"
|
||||
OPEN_H3_REGENERATE_2K_SIZE = (2560, 1440)
|
||||
OPEN_H3_REGENERATE_2K_NUM_FRAMES = 124
|
||||
|
||||
_VIDEO_SUFFIXES = frozenset({".avi", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm"})
|
||||
|
||||
|
||||
@@ -381,6 +385,13 @@ def _apply_reference_inputs(
|
||||
if request.task is not None and model_family != "minimax_h3":
|
||||
raise RequestAdaptationError("The task selector is only defined for MiniMax-H3 servers.")
|
||||
|
||||
if model_family == OPEN_H3_REGENERATE_2K_FAMILY:
|
||||
if len(videos) != 1 or images or audios:
|
||||
raise RequestAdaptationError("Open-H3-Regenerate-2K takes exactly one video_reference (the 768P draft) "
|
||||
"and no image or audio references.")
|
||||
kwargs["video_path"] = videos[0]
|
||||
return
|
||||
|
||||
if model_family == "minimax_h3" and (videos or audios):
|
||||
raise RequestAdaptationError("MiniMax-H3 video/audio references require a server configured with "
|
||||
"override_pipeline_cls_name=MiniMaxH3Ref2VAModularPipeline.")
|
||||
@@ -522,6 +533,16 @@ def build_generation_request(
|
||||
if height is not None and (not isinstance(height, int) or height <= 0):
|
||||
raise RequestAdaptationError(f"height must be a positive integer, got {height!r}")
|
||||
|
||||
if model_family == OPEN_H3_REGENERATE_2K_FAMILY:
|
||||
if (width, height) not in ((None, None), OPEN_H3_REGENERATE_2K_SIZE):
|
||||
raise RequestAdaptationError("Open-H3-Regenerate-2K always renders 2560x1440; omit size/width/height "
|
||||
f"or pass 2560x1440, got {width}x{height}.")
|
||||
if kwargs.get("num_frames", OPEN_H3_REGENERATE_2K_NUM_FRAMES) != OPEN_H3_REGENERATE_2K_NUM_FRAMES:
|
||||
raise RequestAdaptationError("Open-H3-Regenerate-2K regenerates the draft's first 121 frames (24 fps); "
|
||||
"omit seconds and num_frames.")
|
||||
if kwargs["fps"] != 24:
|
||||
raise RequestAdaptationError(f"Open-H3-Regenerate-2K requires fps=24, got {kwargs['fps']}.")
|
||||
|
||||
if model_family == "minimax_h3":
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import (
|
||||
MINIMAX_H3_CANVAS_MULTIPLE,
|
||||
|
||||
@@ -881,6 +881,13 @@ class FastVideoArgs:
|
||||
default=FastVideoArgs.taeh3_chunk_size,
|
||||
help="TAEH3 latent frames per execution chunk.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--h3-vae-nvfp4-decoder",
|
||||
type=str,
|
||||
default=FastVideoArgs.h3_vae_nvfp4_decoder,
|
||||
help="Exported NVFP4 MiniMax-H3 decoder (decoder-nvfp4.safetensors) that replaces the VAE decoder at "
|
||||
"load. Blackwell only. Overrides FASTVIDEO_H3_VAE_NVFP4_DECODER.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vae-parallel-decode",
|
||||
action=StoreBoolean,
|
||||
|
||||
@@ -277,12 +277,14 @@ class BaseLayerWithLoRA(nn.Module):
|
||||
current_device = self.base_layer.weight.data.device
|
||||
data = self.base_layer.weight.data.to(get_local_torch_device())
|
||||
|
||||
# Apply LoRA with alpha scaling
|
||||
lora_delta = (
|
||||
self.slice_lora_b_weights(self.lora_B.to(data)) @ self.slice_lora_a_weights(self.lora_A.to(data)))
|
||||
# Apply LoRA with alpha scaling. An adapter stored wider than the base (an FP32 adapter on a BF16 DiT)
|
||||
# is multiplied and added at its own precision and rounded once, as a merge-then-cast reference does.
|
||||
accumulate_dtype = torch.promote_types(data.dtype, self.lora_A.dtype)
|
||||
lora_delta = (self.slice_lora_b_weights(self.lora_B.to(accumulate_dtype)) @ self.slice_lora_a_weights(
|
||||
self.lora_A.to(accumulate_dtype)))
|
||||
scale = ((self.lora_alpha / self.lora_rank) if (self.lora_alpha and self.lora_rank) else 1.0)
|
||||
lora_delta *= scale * self.lora_strength
|
||||
data += lora_delta
|
||||
data = (data.to(accumulate_dtype) + lora_delta).to(data.dtype)
|
||||
self.base_layer.weight.data = data.to(current_device, non_blocking=True)
|
||||
|
||||
self.merged = True
|
||||
|
||||
@@ -1655,6 +1655,8 @@ class PipelineComponentLoader:
|
||||
component_model_path,
|
||||
)
|
||||
|
||||
# A component under a subfolder (e.g. "base_model/vae") dispatches on its own name.
|
||||
module_name = module_name.rsplit("/", 1)[-1]
|
||||
# Get the appropriate loader for this module type
|
||||
loader = ComponentLoader.for_module_type(module_name, transformers_or_diffusers)
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""MiniMax-H3 3D-convolution latent upscaler (LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler, MIT).
|
||||
|
||||
Port of the upstream node's ``LatentResizer3D`` and its ``target dimensions`` size logic, without the ComfyUI shims,
|
||||
attention blocks (the node forces them off) and temporal chunking (Open-H3-Regenerate-2K runs without it). Module names
|
||||
match the upstream ``latent_upsampler/*.safetensors`` checkpoint, so it loads without remapping.
|
||||
|
||||
The network works on a latent that was normalized with the statistics below, not the VAE-normalized latent the DiT
|
||||
sees: ``upscale_latent`` therefore re-normalizes its input and de-normalizes the result with the same statistics.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from safetensors.torch import load_file
|
||||
from torch import nn
|
||||
|
||||
VAE_DOWNSAMPLE = 16
|
||||
PIXEL_ALIGNMENT = 32
|
||||
|
||||
LATENTS_MEAN = (
|
||||
0.858090341091156, -0.9606591463088989, 1.0661640167236328, -0.5090325474739075, -0.2727581858634949,
|
||||
-1.3675414323806763, -0.2553254961967468, -0.26907554268836975, -0.5376840829849243, -0.0464097298681736,
|
||||
0.6657370328903198, 0.19690127670764923, -0.5460608005523682, -0.4035342037677765, -0.23683024942874908,
|
||||
0.25928452610969543, -0.30133944749832153, 0.211341992020607, -1.1206848621368408, 0.3581933379173279,
|
||||
-0.04225143790245056, 0.2604829967021942, 0.22864092886447906, 0.7056031823158264)
|
||||
LATENTS_STD = (
|
||||
1.2223774194717407, 1.2767263650894165, 1.6831774711608887, 1.7549455165863037, 1.5636216402053833,
|
||||
2.194143533706665, 0.9653137922286987, 1.0569885969161987, 0.841948926448822, 0.7729952931404114,
|
||||
1.8955937623977661, 0.946841835975647, 0.7996809482574463, 0.44988900423049927, 0.7197399735450745,
|
||||
0.6936293244361877, 2.961095094680786, 2.7694199085235596, 3.0496184825897217, 2.1088054180145264,
|
||||
3.276226282119751, 3.1627357006073, 2.2816812992095947, 2.6127843856811523)
|
||||
|
||||
|
||||
def _norm(channels: int) -> nn.GroupNorm:
|
||||
return nn.GroupNorm(32, channels)
|
||||
|
||||
|
||||
class ResBlockEmb3D(nn.Module):
|
||||
|
||||
def __init__(self, channels: int, emb_channels: int) -> None:
|
||||
super().__init__()
|
||||
self.in_layers = nn.Sequential(_norm(channels), nn.SiLU(), nn.Conv3d(channels, channels, 3, padding=1))
|
||||
self.emb_layers = nn.Sequential(nn.SiLU(), nn.Linear(emb_channels, 2 * channels))
|
||||
self.out_norm = _norm(channels)
|
||||
self.out_layers = nn.Sequential(nn.SiLU(), nn.Dropout(p=0.0), nn.Conv3d(channels, channels, 3, padding=1))
|
||||
|
||||
def forward(self, x: torch.Tensor, emb: torch.Tensor) -> torch.Tensor:
|
||||
h = self.in_layers(x)
|
||||
scale, shift = self.emb_layers(emb).type(h.dtype)[..., None, None, None].chunk(2, dim=1)
|
||||
h = self.out_norm(h) * (1 + scale) + shift
|
||||
return x + self.out_layers(h)
|
||||
|
||||
|
||||
class TemporalConv(nn.Module):
|
||||
|
||||
def __init__(self, channels: int, kernel_size: int = 5) -> None:
|
||||
super().__init__()
|
||||
self.norm = _norm(channels)
|
||||
self.dwconv = nn.Conv3d(channels, channels, (kernel_size, 1, 1), padding=(kernel_size // 2, 0, 0),
|
||||
groups=channels)
|
||||
self.pwconv = nn.Conv3d(channels, channels, 1)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
return x + self.pwconv(self.dwconv(F.silu(self.norm(x))))
|
||||
|
||||
|
||||
def _blocks(count: int, channels: int, emb_dim: int, temporal_every: int, kernel: int) -> nn.ModuleList:
|
||||
blocks = nn.ModuleList()
|
||||
for index in range(count):
|
||||
blocks.append(ResBlockEmb3D(channels, emb_dim))
|
||||
if temporal_every > 0 and index % temporal_every == 0:
|
||||
blocks.append(TemporalConv(channels, kernel))
|
||||
return blocks
|
||||
|
||||
|
||||
class MiniMaxH3LatentUpscaler(nn.Module):
|
||||
|
||||
def __init__(self,
|
||||
in_channels: int = 24,
|
||||
in_blocks: int = 12,
|
||||
out_blocks: int = 12,
|
||||
channels: int = 512,
|
||||
temporal_every: int = 2,
|
||||
temporal_kernel: int = 5) -> None:
|
||||
super().__init__()
|
||||
emb_dim = 64
|
||||
self.conv_in = nn.Conv3d(in_channels, channels, 3, padding=1)
|
||||
self.embed = nn.Sequential(nn.Linear(1, emb_dim), nn.SiLU(), nn.Linear(emb_dim, emb_dim))
|
||||
self.in_blocks = _blocks(in_blocks, channels, emb_dim, temporal_every, temporal_kernel)
|
||||
self.out_blocks = _blocks(out_blocks, channels, emb_dim, temporal_every, temporal_kernel)
|
||||
self.norm_out = _norm(channels)
|
||||
self.conv_out = nn.Conv3d(channels, in_channels, 3, padding=1)
|
||||
|
||||
def _run(self, blocks: nn.ModuleList, x: torch.Tensor, emb: torch.Tensor) -> torch.Tensor:
|
||||
for block in blocks:
|
||||
x = block(x, emb.expand(x.shape[0], -1)) if isinstance(block, ResBlockEmb3D) else block(x)
|
||||
return x
|
||||
|
||||
def forward(self, x: torch.Tensor, scale: float, target_size: tuple[int, int, int]) -> torch.Tensor:
|
||||
"""``x``: [B, C, T, h, w] normalized latent; returns [B, C, T, *target_size[1:]]."""
|
||||
emb = self.embed(torch.tensor([[scale - 1.0]], dtype=x.dtype, device=x.device))
|
||||
x = self._run(self.in_blocks, self.conv_in(x), emb)
|
||||
x = F.interpolate(x, size=target_size, mode="trilinear", align_corners=False)
|
||||
x = self._run(self.out_blocks, x, emb)
|
||||
return self.conv_out(F.silu(self.norm_out(x)))
|
||||
|
||||
@classmethod
|
||||
def from_checkpoint(cls, path: str) -> MiniMaxH3LatentUpscaler:
|
||||
state = load_file(path, device="cpu")
|
||||
model = cls(in_channels=state["conv_in.weight"].shape[1],
|
||||
channels=state["conv_in.weight"].shape[0],
|
||||
in_blocks=_count_blocks(state, "in_blocks"),
|
||||
out_blocks=_count_blocks(state, "out_blocks"),
|
||||
temporal_kernel=_temporal_kernel(state))
|
||||
model.load_state_dict(state, strict=True)
|
||||
return model.eval().requires_grad_(False)
|
||||
|
||||
|
||||
def _count_blocks(state: dict[str, torch.Tensor], prefix: str) -> int:
|
||||
pattern = re.compile(rf"{prefix}\.(\d+)\.in_layers\.0\.weight")
|
||||
return len({int(m.group(1)) for key in state if (m := pattern.match(key))})
|
||||
|
||||
|
||||
def _temporal_kernel(state: dict[str, torch.Tensor]) -> int:
|
||||
return next(tensor.shape[2] for key, tensor in state.items() if key.endswith("dwconv.weight"))
|
||||
|
||||
|
||||
def resolve_upscale_geometry(input_size: tuple[int, int], target_pixels: tuple[int, int]) -> tuple[tuple[int, int], float]:
|
||||
"""Latent (h, w) after aligning the target pixel size to 32, and the scale the network is conditioned on.
|
||||
|
||||
``input_size`` is the latent (h, w); ``target_pixels`` the requested (height, width).
|
||||
"""
|
||||
h_in, w_in = input_size
|
||||
h_target, w_target = target_pixels
|
||||
scale = (w_target / (w_in * VAE_DOWNSAMPLE) + h_target / (h_in * VAE_DOWNSAMPLE)) / 2.0
|
||||
aligned = (round(round(h_target / PIXEL_ALIGNMENT) * PIXEL_ALIGNMENT / VAE_DOWNSAMPLE),
|
||||
round(round(w_target / PIXEL_ALIGNMENT) * PIXEL_ALIGNMENT / VAE_DOWNSAMPLE))
|
||||
return (max(1, aligned[0]), max(1, aligned[1])), scale
|
||||
|
||||
|
||||
@torch.inference_mode()
|
||||
def upscale_latent(model: MiniMaxH3LatentUpscaler, latent: torch.Tensor, target_pixels: tuple[int, int],
|
||||
dtype: torch.dtype = torch.bfloat16) -> torch.Tensor:
|
||||
"""Upscale a [C, T, h, w] latent to ``target_pixels`` (height, width); FP32 [C, T, H, W] out.
|
||||
|
||||
The input is returned unchanged when it already has the target latent size.
|
||||
"""
|
||||
channels, frames, h_in, w_in = latent.shape
|
||||
(h_out, w_out), scale = resolve_upscale_geometry((h_in, w_in), target_pixels)
|
||||
if (h_out, w_out) == (h_in, w_in):
|
||||
return latent.float()
|
||||
if scale < 1.0:
|
||||
raise ValueError("The MiniMax-H3 latent upscaler only upscales (effective scale >= 1.0).")
|
||||
mean = torch.tensor(LATENTS_MEAN, dtype=dtype, device=latent.device).view(1, -1, 1, 1, 1)
|
||||
std = torch.tensor(LATENTS_STD, dtype=dtype, device=latent.device).view(1, -1, 1, 1, 1)
|
||||
source = (latent[None].to(dtype) - mean) / std
|
||||
result = model(source, scale, (frames, h_out, w_out))
|
||||
return (result * std + mean).float()[0]
|
||||
@@ -0,0 +1,8 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.open_h3_regenerate_2k_pipeline import (
|
||||
EntryClass,
|
||||
OpenH3Regenerate2KPipeline,
|
||||
)
|
||||
|
||||
__all__ = ["EntryClass", "OpenH3Regenerate2KPipeline"]
|
||||
@@ -0,0 +1,54 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Decode a draft video into the 124-frame pixel block the H3 VAE turns into 37 latent frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
import fastvideo.envs as envs
|
||||
|
||||
DRAFT_SIZES: dict[str, tuple[int, int]] = {"768p": (1344, 768), "576p": (1024, 576)}
|
||||
DRAFT_FRAMES = 121
|
||||
# The causal VAE maps 124 frames to 37 latent frames; the last frame is held for the three extra ones.
|
||||
VAE_PADDING_FRAMES = 3
|
||||
# Seconds of held last frame ffmpeg appends so a short draft still yields DRAFT_FRAMES.
|
||||
_TAIL_PAD_SECONDS = 6
|
||||
_DECODE_TIMEOUT_S = 600
|
||||
|
||||
|
||||
def ffmpeg_decode_command(path: str, width: int, height: int) -> list[str]:
|
||||
"""fps=24, cover-scale (bilinear) and centre-crop, last frame held, exactly DRAFT_FRAMES frames, no audio."""
|
||||
video_filter = (f"fps=24,scale={width}:{height}:force_original_aspect_ratio=increase:flags=bilinear,"
|
||||
f"crop={width}:{height},tpad=stop_mode=clone:stop_duration={_TAIL_PAD_SECONDS}")
|
||||
return [
|
||||
envs.FASTVIDEO_FFMPEG_BIN.get(), "-v", "error", "-i", path, "-vf", video_filter, "-frames:v",
|
||||
str(DRAFT_FRAMES), "-an", "-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1"
|
||||
]
|
||||
|
||||
|
||||
def load_draft_pixels(path: str, size: str = "768p") -> torch.Tensor:
|
||||
"""uint8 pixels [1, 3, 124, H, W] on the CPU for the draft video at ``path``."""
|
||||
if size not in DRAFT_SIZES:
|
||||
raise ValueError(f"draft size must be one of {sorted(DRAFT_SIZES)}, got {size!r}.")
|
||||
width, height = DRAFT_SIZES[size]
|
||||
try:
|
||||
raw = subprocess.run(ffmpeg_decode_command(path, width, height),
|
||||
capture_output=True,
|
||||
check=True,
|
||||
timeout=_DECODE_TIMEOUT_S).stdout
|
||||
except FileNotFoundError as error:
|
||||
raise RuntimeError(f"ffmpeg ({envs.FASTVIDEO_FFMPEG_BIN.get()!r}) is required to read the draft video; "
|
||||
"set FASTVIDEO_FFMPEG_BIN.") from error
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise ValueError(
|
||||
f"Could not decode the draft video {path!r}: {error.stderr.decode(errors='replace')}") from error
|
||||
expected = DRAFT_FRAMES * height * width * 3
|
||||
if len(raw) != expected:
|
||||
raise ValueError(f"The draft video {path!r} decoded to {len(raw)} bytes, expected {expected} "
|
||||
f"({DRAFT_FRAMES} frames at {width}x{height}).")
|
||||
frames = torch.from_numpy(np.frombuffer(raw, np.uint8).reshape(DRAFT_FRAMES, height, width, 3).copy())
|
||||
pixels = frames.permute(3, 0, 1, 2)[None]
|
||||
return torch.cat([pixels, pixels[:, :, -1:].expand(-1, -1, VAE_PADDING_FRAMES, -1, -1)], dim=2).contiguous()
|
||||
@@ -0,0 +1,102 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Open-H3-Regenerate-2K: regenerate a 768P MiniMax-H3 draft video at 2560x1440.
|
||||
|
||||
``draft video -> H3 VAE encode -> latent upscaler -> 3 fixed-sigma Euler steps (H3 DiT + merged LoRA + Sharp curve
|
||||
AdaLN branches) -> H3 VAE decode``. The release repo holds the unmodified MiniMax-H3 components under ``base_model/``
|
||||
next to ``lora/`` and ``latent_upsampler/``; see ``docs/inference/open_h3_regenerate_2k.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.logger import init_logger
|
||||
from fastvideo.models.upsamplers.minimax_h3_latent_upscaler import MiniMaxH3LatentUpscaler
|
||||
from fastvideo.pipelines.basic.minimax_h3.minimax_h3_pipeline import MiniMaxH3Pipeline
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages import MiniMaxH3VideoDecodingStage
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sharp_curve import SharpCurveBranches
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.stages import (
|
||||
Regenerate2KDenoisingStage,
|
||||
Regenerate2KFinalizeStage,
|
||||
Regenerate2KLatentPreparationStage,
|
||||
)
|
||||
from fastvideo.utils import maybe_download_model, verify_model_config_and_directory
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
BASE_MODEL_DIR = "base_model"
|
||||
LORA_FILE = "lora/open_h3_regenerate_2k_lora.safetensors"
|
||||
UPSCALER_FILE = "latent_upsampler/minimax_h3_latent_upscaler_3d_conv_v1_bf16.safetensors"
|
||||
_BASE_COMPONENTS = ("text_encoder", "tokenizer", "processor", "vae", "audio_vae", "transformer", "scheduler",
|
||||
"audio_scheduler")
|
||||
|
||||
|
||||
class OpenH3Regenerate2KPipeline(MiniMaxH3Pipeline):
|
||||
"""MiniMax-H3 T2VA layout with a draft-latent start, fixed sigmas and a silent output."""
|
||||
|
||||
_extra_config_module_map = {name: f"{BASE_MODEL_DIR}/{name}" for name in _BASE_COMPONENTS}
|
||||
# The merged LoRA reaches only these linears; the AdaLN projections carry the Sharp curve branches instead.
|
||||
lora_target_modules = ["attn.to_q", "attn.to_k", "attn.to_v", "attn.to_out", "ff.fc_in", "ff.fc_out"]
|
||||
|
||||
@classmethod
|
||||
def get_hf_download_allow_patterns(cls) -> list[str]:
|
||||
components = [f"{BASE_MODEL_DIR}/{name}/**" for name in _BASE_COMPONENTS]
|
||||
return [f"{BASE_MODEL_DIR}/model_index.json", *components, "lora/**", "latent_upsampler/*.safetensors"]
|
||||
|
||||
def _load_config(self, model_path: str) -> dict[str, Any]:
|
||||
revision = getattr(self.fastvideo_args, "revision", None)
|
||||
self.model_path = maybe_download_model(model_path,
|
||||
revision=revision,
|
||||
allow_patterns=self.get_hf_download_allow_patterns())
|
||||
if self.fastvideo_args.lora_path is None:
|
||||
# The merged refine LoRA is part of the release; it is a constructor-time adapter like any H3 LoRA.
|
||||
self.fastvideo_args.lora_path = os.path.join(self.model_path, LORA_FILE)
|
||||
return verify_model_config_and_directory(os.path.join(self.model_path, BASE_MODEL_DIR),
|
||||
required_component_dirs=())
|
||||
|
||||
def initialize_pipeline(self, fastvideo_args: FastVideoArgs) -> None:
|
||||
super().initialize_pipeline(fastvideo_args)
|
||||
self._upscaler: MiniMaxH3LatentUpscaler | None = None
|
||||
self._curve: SharpCurveBranches | None = None
|
||||
|
||||
def _load_upscaler(self) -> MiniMaxH3LatentUpscaler:
|
||||
if self._upscaler is None:
|
||||
model = MiniMaxH3LatentUpscaler.from_checkpoint(os.path.join(self.model_path, UPSCALER_FILE))
|
||||
self._upscaler = model.to(device=self.device, dtype=torch.bfloat16)
|
||||
return self._upscaler
|
||||
|
||||
def _load_curve(self) -> SharpCurveBranches:
|
||||
if self._curve is None:
|
||||
self._curve = SharpCurveBranches(Path(self.model_path))
|
||||
return self._curve
|
||||
|
||||
def _add_denoise_stages(self, *, ref2va: bool) -> None:
|
||||
config = self.fastvideo_args.pipeline_config
|
||||
vae = self.get_module("vae")
|
||||
audio_vae = self.get_module("audio_vae")
|
||||
transformer = self.get_module("transformer")
|
||||
if vae is None or audio_vae is None or transformer is None:
|
||||
raise RuntimeError("Open-H3-Regenerate-2K needs the video VAE, audio VAE and transformer loaded.")
|
||||
self.add_stage(
|
||||
"latent_preparation_stage",
|
||||
Regenerate2KLatentPreparationStage(vae,
|
||||
audio_vae,
|
||||
self.get_module("scheduler"),
|
||||
load_upscaler=self._load_upscaler,
|
||||
draft_size=config.draft_size,
|
||||
start_sigma=config.refine_start_sigma))
|
||||
self.add_stage("denoising_stage",
|
||||
Regenerate2KDenoisingStage(transformer, self._load_curve(), config.refine_start_sigma))
|
||||
self.add_stage("video_decoding_stage", MiniMaxH3VideoDecodingStage(vae=vae))
|
||||
self.add_stage("audio_decoding_stage", Regenerate2KFinalizeStage())
|
||||
self._denoise_stages_ready = True
|
||||
|
||||
|
||||
EntryClass = OpenH3Regenerate2KPipeline
|
||||
|
||||
__all__ = ["EntryClass", "OpenH3Regenerate2KPipeline"]
|
||||
@@ -0,0 +1,35 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Inference preset for Open-H3-Regenerate-2K."""
|
||||
|
||||
from fastvideo.api.presets import InferencePreset, PresetStageSpec
|
||||
|
||||
_REFINE_STAGE = PresetStageSpec(
|
||||
name="refine",
|
||||
kind="denoising",
|
||||
description="Latent upscale, then three fixed-sigma Euler refine steps",
|
||||
allowed_overrides=frozenset({"num_inference_steps"}),
|
||||
)
|
||||
|
||||
OPEN_H3_REGENERATE_2K = InferencePreset(
|
||||
name="open_h3_regenerate_2k",
|
||||
version=1,
|
||||
model_family="open_h3_regenerate_2k",
|
||||
description="Open-H3-Regenerate-2K: regenerate a 768P MiniMax-H3 draft video at 2560x1440 (silent output)",
|
||||
workload_type=None,
|
||||
stage_schemas=(_REFINE_STAGE, ),
|
||||
defaults={
|
||||
"height": 1440,
|
||||
"width": 2560,
|
||||
"num_frames": 124,
|
||||
"fps": 24,
|
||||
"guidance_scale": 1.0,
|
||||
"batch_cfg": False,
|
||||
"negative_prompt": "",
|
||||
"num_inference_steps": 3,
|
||||
"seed": 1766580932,
|
||||
},
|
||||
)
|
||||
|
||||
ALL_PRESETS = (OPEN_H3_REGENERATE_2K, )
|
||||
|
||||
__all__ = ["ALL_PRESETS", "OPEN_H3_REGENERATE_2K"]
|
||||
@@ -0,0 +1,42 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Fixed Open-H3-Regenerate-2K refine schedule: Euler, CFG 1, model time ``1 - sigma``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
REFINE_SHIFT = 6.0
|
||||
DEFAULT_REFINE_STEPS = 3
|
||||
# Start noise of the two-step variant; the three-step grid starts at 0.909375.
|
||||
TWO_STEP_START_SIGMA = 0.7
|
||||
|
||||
|
||||
def shifted_grid(start: float, steps: int, shift: float = REFINE_SHIFT) -> list[float]:
|
||||
"""Euler grid from ``start`` to 0: even spacing in unshifted sigma, then ``s * x / (1 + (s - 1) * x)``."""
|
||||
if not 0.0 < start < 1.0:
|
||||
raise ValueError(f"start sigma must be in (0, 1), got {start}.")
|
||||
if steps < 1:
|
||||
raise ValueError(f"steps must be positive, got {steps}.")
|
||||
raw = start / (shift - (shift - 1) * start)
|
||||
mapped = [shift * x / (1 + (shift - 1) * x) for x in (raw * (1 - i / steps) for i in range(1, steps))]
|
||||
return [start, *mapped, 0.0]
|
||||
|
||||
|
||||
REFINE_SCHEDULES: dict[int, tuple[float, ...]] = {
|
||||
3: (0.909375, 6 / 7, 2 / 3, 0.0),
|
||||
2: tuple(shifted_grid(TWO_STEP_START_SIGMA, 2)),
|
||||
}
|
||||
|
||||
|
||||
def refine_sigmas(steps: int = DEFAULT_REFINE_STEPS, start_sigma: float | None = None) -> list[float]:
|
||||
"""Sigma grid of ``steps`` Euler steps, ending at 0."""
|
||||
if start_sigma is not None:
|
||||
return shifted_grid(start_sigma, steps)
|
||||
if steps not in REFINE_SCHEDULES:
|
||||
raise ValueError(f"Open-H3-Regenerate-2K supports {sorted(REFINE_SCHEDULES)} refine steps, got {steps}.")
|
||||
return list(REFINE_SCHEDULES[steps])
|
||||
|
||||
|
||||
def mix_initial_state(upscaled: torch.Tensor, noise: torch.Tensor, sigma: float) -> torch.Tensor:
|
||||
"""Re-noise the upscaled draft latent to the first sigma: ``(1 - sigma) * upscaled + sigma * noise``."""
|
||||
return (1.0 - sigma) * upscaled + sigma * noise
|
||||
@@ -0,0 +1,86 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""LMS Sharp curve AdaLN branches.
|
||||
|
||||
Fifty AdaLN projections get an extra low-rank term whose input is an 8-D curve interpolated from the model timestep
|
||||
(``adaln_t_table``, 1025 x 8) instead of the layer input, so it cannot be folded into the LoRA. The term is added to the
|
||||
projection output through a forward hook; ``set_timesteps`` must be called with the per-forward unique timesteps first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from safetensors.torch import load_file
|
||||
|
||||
CURVE_BRANCHES_FILE = "lora/sharp_curve_branches.safetensors"
|
||||
CURVE_TABLE_FILE = "lora/adaln_t_table.bin"
|
||||
CURVE_TABLE_SHAPE = (1025, 8)
|
||||
NUM_CURVE_BRANCHES = 50
|
||||
_KEY_PREFIX = "transformer."
|
||||
_A_SUFFIX = ".curve_A.weight"
|
||||
_B_SUFFIX = ".curve_B.weight"
|
||||
|
||||
|
||||
def interpolate_curve(table: torch.Tensor, timesteps: torch.Tensor) -> torch.Tensor:
|
||||
"""Linear interpolation of ``table`` rows at model times in [0, 1]; ``timesteps`` [U] -> [U, 8]."""
|
||||
t = timesteps.float()
|
||||
if t.numel() and (t.min() < 0 or t.max() > 1):
|
||||
raise ValueError("Sharp curve timesteps must lie in [0, 1].")
|
||||
table = table.to(t.device)
|
||||
position = t * (len(table) - 1)
|
||||
low = position.floor().long().clamp(max=len(table) - 2)
|
||||
return torch.lerp(table[low], table[low + 1], (position - low).unsqueeze(-1))
|
||||
|
||||
|
||||
class SharpCurveBranches:
|
||||
|
||||
def __init__(self, weights_dir: str | Path) -> None:
|
||||
root = Path(weights_dir)
|
||||
raw = torch.frombuffer(bytearray((root / CURVE_TABLE_FILE).read_bytes()), dtype=torch.float32)
|
||||
if raw.numel() != CURVE_TABLE_SHAPE[0] * CURVE_TABLE_SHAPE[1]:
|
||||
raise ValueError(f"{root / CURVE_TABLE_FILE} must hold a {CURVE_TABLE_SHAPE} float32 table.")
|
||||
self.table = raw.reshape(CURVE_TABLE_SHAPE).clone()
|
||||
tensors = load_file(str(root / CURVE_BRANCHES_FILE), device="cpu")
|
||||
self.factors: dict[str, tuple[torch.Tensor, torch.Tensor]] = {
|
||||
key[len(_KEY_PREFIX):-len(_A_SUFFIX)]: (value.float(), tensors[key[:-len(_A_SUFFIX)] + _B_SUFFIX].float())
|
||||
for key, value in tensors.items() if key.endswith(_A_SUFFIX)
|
||||
}
|
||||
if len(self.factors) != NUM_CURVE_BRANCHES:
|
||||
raise ValueError(f"Expected {NUM_CURVE_BRANCHES} Sharp curve branches, found {len(self.factors)}.")
|
||||
self.coords: torch.Tensor | None = None
|
||||
self._attached: set[int] = set()
|
||||
self._device_factors: dict[str, tuple[torch.Tensor, torch.Tensor]] = {}
|
||||
|
||||
def set_timesteps(self, unique_timesteps: torch.Tensor) -> None:
|
||||
self.coords = interpolate_curve(self.table, unique_timesteps)
|
||||
|
||||
def attach(self, transformer: torch.nn.Module) -> None:
|
||||
"""Register the branch hooks on ``transformer`` once."""
|
||||
if id(transformer) in self._attached:
|
||||
return
|
||||
modules = dict(transformer.named_modules())
|
||||
missing = sorted(set(self.factors) - set(modules))
|
||||
if missing:
|
||||
raise ValueError(f"Sharp curve branches match no transformer module: {missing[:5]}")
|
||||
for name in self.factors:
|
||||
modules[name].register_forward_hook(self._hook(name))
|
||||
self._attached.add(id(transformer))
|
||||
|
||||
def _hook(self, name: str):
|
||||
|
||||
def add_branch(module, inputs, output):
|
||||
projected = output[0] if isinstance(output, tuple) else output
|
||||
coords = self.coords
|
||||
if coords is None or coords.shape[0] != projected.shape[0]:
|
||||
raise RuntimeError("Sharp curve timesteps were not set for this forward.")
|
||||
factors = self._device_factors.get(name)
|
||||
if factors is None or factors[0].device != projected.device:
|
||||
factors = tuple(f.to(projected.device) for f in self.factors[name])
|
||||
self._device_factors[name] = factors
|
||||
delta = F.linear(F.linear(coords, factors[0]), factors[1]).to(projected.dtype)
|
||||
projected = projected + delta
|
||||
return (projected, *output[1:]) if isinstance(output, tuple) else projected
|
||||
|
||||
return add_branch
|
||||
@@ -0,0 +1,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.stages.denoising import Regenerate2KDenoisingStage
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.stages.finalize import Regenerate2KFinalizeStage
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.stages.latent_preparation import (
|
||||
Regenerate2KLatentPreparationStage, )
|
||||
|
||||
__all__ = [
|
||||
"Regenerate2KDenoisingStage",
|
||||
"Regenerate2KFinalizeStage",
|
||||
"Regenerate2KLatentPreparationStage",
|
||||
]
|
||||
@@ -0,0 +1,109 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Fixed-sigma Euler refine loop of Open-H3-Regenerate-2K."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch.nn.attention import SDPBackend, sdpa_kernel
|
||||
|
||||
from fastvideo.distributed import get_local_torch_device
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.forward_context import set_forward_context
|
||||
from fastvideo.logger import init_logger
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import MiniMaxH3PackedLayout, build_row_timesteps
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_latent_preparation import MINIMAX_H3_LAYOUT_KEY
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sampling import refine_sigmas
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sharp_curve import SharpCurveBranches
|
||||
from fastvideo.pipelines.lazy_module import is_lazy_module
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
from fastvideo.pipelines.stages.base import PipelineStage
|
||||
from fastvideo.pipelines.stages.validators import StageValidators as V
|
||||
from fastvideo.pipelines.stages.validators import VerificationResult
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
class Regenerate2KDenoisingStage(PipelineStage):
|
||||
"""Euler steps over the refine sigmas with the video clock at ``1 - sigma`` and the silent audio held clean."""
|
||||
|
||||
performance_component_metric = "dit_time_s"
|
||||
|
||||
def __init__(self, transformer: Any, curve: SharpCurveBranches, start_sigma: float | None) -> None:
|
||||
super().__init__()
|
||||
self.transformer = transformer
|
||||
self.curve = curve
|
||||
self.start_sigma = start_sigma
|
||||
|
||||
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("layout", batch.extra.get(MINIMAX_H3_LAYOUT_KEY), V.not_none)
|
||||
result.add_check("prompt_embeds", batch.prompt_embeds, V.list_of_tensors_dims(3))
|
||||
result.add_check("latents", batch.latents, V.with_dims(2))
|
||||
result.add_check("audio_latents", batch.audio_latents, V.with_dims(2))
|
||||
return result
|
||||
|
||||
def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("latents", batch.latents, V.with_dims(2))
|
||||
return result
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
transformer = self.transformer
|
||||
if is_lazy_module(transformer):
|
||||
transformer.materialize()
|
||||
self.curve.attach(transformer)
|
||||
layout = batch.extra.get(MINIMAX_H3_LAYOUT_KEY)
|
||||
if not isinstance(layout, MiniMaxH3PackedLayout):
|
||||
raise ValueError("The MiniMax-H3 packed layout is missing before denoising.")
|
||||
device = get_local_torch_device()
|
||||
sigmas = refine_sigmas(batch.num_inference_steps, self.start_sigma)
|
||||
state = batch.latents.to(device)
|
||||
with self._attention_kernel(device):
|
||||
state = self._euler_loop(batch, layout, state, sigmas, device, transformer)
|
||||
batch.latents = state
|
||||
return batch
|
||||
|
||||
@staticmethod
|
||||
def _attention_kernel(device: torch.device) -> contextlib.AbstractContextManager[None]:
|
||||
"""cuDNN attention: at 133k tokens PyTorch's default SDPA dispatch picks its flash kernel, 3x slower on B200."""
|
||||
return sdpa_kernel(SDPBackend.CUDNN_ATTENTION) if device.type == "cuda" else contextlib.nullcontext()
|
||||
|
||||
def _euler_loop(self, batch: ForwardBatch, layout: MiniMaxH3PackedLayout, state: torch.Tensor, sigmas: list[float],
|
||||
device: torch.device, transformer: Any) -> torch.Tensor:
|
||||
position_ids = layout.position_ids.to(device)
|
||||
token_tags = layout.token_tags.to(device)
|
||||
video_indices = layout.video_indices.to(device)
|
||||
audio_indices = layout.audio_indices.to(device)
|
||||
text_indices = layout.text_indices.to(device)
|
||||
prompt_embeds = batch.prompt_embeds[0].to(device)
|
||||
audio = batch.audio_latents.to(device=device, dtype=torch.bfloat16)[None]
|
||||
for index, (sigma, following) in enumerate(zip(sigmas[:-1], sigmas[1:], strict=True)):
|
||||
unique, inverse = build_row_timesteps(layout,
|
||||
video_timestep=1.0 - sigma,
|
||||
audio_timestep=1.0,
|
||||
condition_video_timestep=1.0,
|
||||
condition_audio_timestep=1.0)
|
||||
unique, inverse = unique.to(device), inverse.to(device)
|
||||
self.curve.set_timesteps(unique)
|
||||
torch.compiler.cudagraph_mark_step_begin()
|
||||
with set_forward_context(current_timestep=index, attn_metadata=None, forward_batch=batch):
|
||||
velocity, _ = transformer(hidden_states=state.to(torch.bfloat16)[None],
|
||||
audio_hidden_states=audio,
|
||||
encoder_hidden_states=prompt_embeds,
|
||||
timestep=unique,
|
||||
timestep_indices=inverse,
|
||||
token_tags=token_tags,
|
||||
position_ids=position_ids,
|
||||
video_indices=video_indices,
|
||||
audio_indices=audio_indices,
|
||||
text_indices=text_indices)
|
||||
state = state + (sigma - following) * velocity[0].float()
|
||||
batch.step_index = index
|
||||
return state
|
||||
|
||||
|
||||
__all__ = ["Regenerate2KDenoisingStage"]
|
||||
@@ -0,0 +1,39 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Trim the decoded video to the draft length and drop the per-request H3 state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.draft_video import DRAFT_FRAMES
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
from fastvideo.pipelines.stages.base import PipelineStage
|
||||
from fastvideo.pipelines.stages.validators import StageValidators as V
|
||||
from fastvideo.pipelines.stages.validators import VerificationResult
|
||||
|
||||
|
||||
class Regenerate2KFinalizeStage(PipelineStage):
|
||||
"""The released pipeline writes a silent video: the audio stream only conditions the DiT and is never decoded."""
|
||||
|
||||
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("output", batch.output, V.with_dims(5))
|
||||
return result
|
||||
|
||||
def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = VerificationResult()
|
||||
result.add_check("output", batch.output, V.with_dims(5))
|
||||
return result
|
||||
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
# The VAE decodes 124 frames (37 latent frames); the draft has 121.
|
||||
batch.output = batch.output[:, :, :DRAFT_FRAMES]
|
||||
batch.prompt_embeds = []
|
||||
batch.latents = None
|
||||
batch.audio_latents = None
|
||||
for key in tuple(batch.extra):
|
||||
if key.startswith("minimax_h3_"):
|
||||
batch.extra.pop(key)
|
||||
return batch
|
||||
|
||||
|
||||
__all__ = ["Regenerate2KFinalizeStage"]
|
||||
@@ -0,0 +1,129 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Draft video -> upscaled, re-noised H3 latent plus the fixed silent-audio rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from fastvideo.distributed import get_local_torch_device
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.logger import init_logger
|
||||
from fastvideo.models import pinned_offload
|
||||
from fastvideo.models.upsamplers.minimax_h3_latent_upscaler import MiniMaxH3LatentUpscaler, upscale_latent
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import (
|
||||
MINIMAX_H3_AUDIO_CHANNELS,
|
||||
MINIMAX_H3_FPS,
|
||||
MINIMAX_H3_KEYFRAME_ENCODE_SEED,
|
||||
h3_dit_patch_size,
|
||||
patchify_video_latents,
|
||||
)
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_latent_preparation import (
|
||||
MINIMAX_H3_LAYOUT_KEY,
|
||||
MiniMaxH3LatentPreparationStage,
|
||||
)
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.draft_video import load_draft_pixels
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sampling import mix_initial_state, refine_sigmas
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
from fastvideo.pipelines.stages.validators import VerificationResult
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
DRAFT_FRAME_COUNT = 124
|
||||
|
||||
|
||||
class Regenerate2KLatentPreparationStage(MiniMaxH3LatentPreparationStage):
|
||||
"""Encode the draft with the H3 VAE, upscale its latent to the target grid and re-noise it to the first sigma."""
|
||||
|
||||
def __init__(self, vae: Any, audio_vae: Any, scheduler: Any, *, load_upscaler: Callable[[],
|
||||
MiniMaxH3LatentUpscaler],
|
||||
draft_size: str, start_sigma: float | None) -> None:
|
||||
super().__init__(vae, audio_vae, scheduler, ref2va=False)
|
||||
self.load_upscaler = load_upscaler
|
||||
self.draft_size = draft_size
|
||||
self.start_sigma = start_sigma
|
||||
self._silence: tuple[tuple[str, int], torch.Tensor] | None = None
|
||||
|
||||
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
|
||||
result = super().verify_input(batch, fastvideo_args)
|
||||
result.add_check("video_path", batch.video_path, lambda value: isinstance(value, str) and bool(value))
|
||||
return result
|
||||
|
||||
def _encode_draft(self, video_path: str, device: torch.device, fastvideo_args: FastVideoArgs) -> torch.Tensor:
|
||||
"""Normalized native latent [C, T, h, w] of the draft, with the VAE posterior sampled under a fixed seed."""
|
||||
pixels = load_draft_pixels(video_path, self.draft_size)
|
||||
pinned_offload.load(self.vae, device, pin=fastvideo_args.pin_cpu_memory)
|
||||
try:
|
||||
posterior = self.vae.encode_pixels(pixels).latent_dist
|
||||
sample = posterior.sample(generator=torch.Generator("cpu").manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED))
|
||||
return self.vae.normalize_latents(sample.float())[0].to(device)
|
||||
finally:
|
||||
if fastvideo_args.vae_cpu_offload:
|
||||
pinned_offload.unload(self.vae)
|
||||
|
||||
def _silent_audio_rows(self, num_frames: int, device: torch.device, fastvideo_args: FastVideoArgs) -> torch.Tensor:
|
||||
"""The encoded-silence audio stream, cached: it is the same for every request of this length."""
|
||||
key = (str(device), num_frames)
|
||||
if self._silence is not None and self._silence[0] == key:
|
||||
return self._silence[1]
|
||||
pinned_offload.load(self.audio_vae, device, pin=fastvideo_args.pin_cpu_memory)
|
||||
try:
|
||||
samples = round(num_frames / MINIMAX_H3_FPS * int(self.audio_vae.sampling_rate))
|
||||
silence = torch.zeros(MINIMAX_H3_AUDIO_CHANNELS, 1, samples, device=device)
|
||||
posterior = self.audio_vae.encode(silence).latent_dist
|
||||
latents = self.audio_vae.normalize_latents(posterior.mode().float()).transpose(1, 2)
|
||||
rows = latents.reshape(-1, self.audio_vae.latent_channels).to(torch.bfloat16)
|
||||
finally:
|
||||
if fastvideo_args.vae_cpu_offload:
|
||||
pinned_offload.unload(self.audio_vae)
|
||||
self._silence = (key, rows)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _noise_rows(shape: tuple[int, int], device: torch.device, seed: int) -> torch.Tensor:
|
||||
generator = torch.Generator(device=device).manual_seed(seed)
|
||||
return torch.randn((1, *shape), device=device, dtype=torch.float32, generator=generator)[0]
|
||||
|
||||
@torch.no_grad()
|
||||
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
|
||||
if batch.num_frames != DRAFT_FRAME_COUNT:
|
||||
raise ValueError(f"Open-H3-Regenerate-2K refines {DRAFT_FRAME_COUNT} frames, got {batch.num_frames}.")
|
||||
device = get_local_torch_device()
|
||||
patch_size = h3_dit_patch_size(fastvideo_args)
|
||||
_, channels, num_latent_frames, latent_height, latent_width = batch.raw_latent_shape
|
||||
|
||||
draft = self._encode_draft(batch.video_path, device, fastvideo_args)
|
||||
upscaler = self.load_upscaler()
|
||||
upscaled = upscale_latent(upscaler, draft, (batch.height, batch.width))
|
||||
if tuple(upscaled.shape) != (channels, num_latent_frames, latent_height, latent_width):
|
||||
raise ValueError(f"The upscaled latent has shape {tuple(upscaled.shape)}, expected "
|
||||
f"{(channels, num_latent_frames, latent_height, latent_width)}.")
|
||||
upscaled_rows = patchify_video_latents(upscaled[None], patch_size)
|
||||
|
||||
if batch.latents is None:
|
||||
noise_rows = self._noise_rows(tuple(upscaled_rows.shape), device, 0 if batch.seed is None else batch.seed)
|
||||
else:
|
||||
expected = (1, channels, num_latent_frames, latent_height, latent_width)
|
||||
if tuple(batch.latents.shape) != expected:
|
||||
raise ValueError(f"Injected noise must have shape {expected}, got {tuple(batch.latents.shape)}.")
|
||||
noise_rows = patchify_video_latents(batch.latents.to(device=device, dtype=torch.float32), patch_size)
|
||||
|
||||
layout = self._build_layout(batch, fastvideo_args)
|
||||
audio_rows = self._silent_audio_rows(batch.num_frames, device, fastvideo_args)
|
||||
if audio_rows.shape[0] != layout.audio_indices.numel():
|
||||
raise ValueError(f"The silent audio has {audio_rows.shape[0]} rows, the packed layout expects "
|
||||
f"{layout.audio_indices.numel()} ({layout.num_audio_latents} latents).")
|
||||
|
||||
batch.latents = mix_initial_state(upscaled_rows, noise_rows,
|
||||
refine_sigmas(batch.num_inference_steps, self.start_sigma)[0])
|
||||
batch.audio_latents = audio_rows
|
||||
batch.extra[MINIMAX_H3_LAYOUT_KEY] = layout
|
||||
for key in tuple(batch.extra):
|
||||
if key.startswith("minimax_h3_") and key != MINIMAX_H3_LAYOUT_KEY:
|
||||
batch.extra.pop(key)
|
||||
return batch
|
||||
|
||||
|
||||
__all__ = ["Regenerate2KLatentPreparationStage"]
|
||||
+36
-7
@@ -42,6 +42,7 @@ from fastvideo.configs.pipelines.flux_2 import (
|
||||
from fastvideo.configs.pipelines.matrixgame2 import MatrixGame2I2V480PConfig
|
||||
from fastvideo.configs.pipelines.matrixgame3 import MatrixGame3I2V720PConfig
|
||||
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
|
||||
from fastvideo.configs.pipelines.open_h3_regenerate_2k import OpenH3Regenerate2KPipelineConfig
|
||||
from fastvideo.configs.pipelines.mmaudio import (
|
||||
MMAudioLarge44kV2PipelineConfig,
|
||||
MMAudioLarge44kV2AConfig,
|
||||
@@ -216,6 +217,13 @@ def _resolve_class_name(config: dict[str, Any]) -> str | None:
|
||||
return class_name
|
||||
|
||||
|
||||
OPEN_H3_REGENERATE_2K_HF_PATH = "Efficient-Large-Model/Open-H3-Regenerate-2K"
|
||||
|
||||
|
||||
def _is_open_h3_regenerate_2k_release(model_path: str) -> bool:
|
||||
return os.path.isfile(os.path.join(model_path, "lora", "open_h3_regenerate_2k_lora.safetensors"))
|
||||
|
||||
|
||||
def _get_config_info(
|
||||
model_path: str,
|
||||
*,
|
||||
@@ -247,6 +255,9 @@ def _get_config_info(
|
||||
return _CONFIG_REGISTRY.get(model_id)
|
||||
|
||||
# 3. Use detectors (path or model_index pipeline name).
|
||||
if os.path.exists(model_path) and _is_open_h3_regenerate_2k_release(model_path):
|
||||
# The release keeps its Diffusers manifest under base_model/, so a renamed local copy has no root manifest.
|
||||
return _CONFIG_REGISTRY.get(_MODEL_HF_PATH_TO_NAME[OPEN_H3_REGENERATE_2K_HF_PATH])
|
||||
if os.path.exists(model_path):
|
||||
config = verify_model_config_and_directory(model_path, required_component_dirs=[])
|
||||
elif allow_remote:
|
||||
@@ -1152,6 +1163,22 @@ def _register_configs() -> None:
|
||||
|
||||
_register_wan_configs(WAN_MODEL_DEFINITIONS)
|
||||
|
||||
# Open-H3-Regenerate-2K (video -> video). Registered before MiniMax H3 so the first matching detector wins. There is
|
||||
# no V2V WorkloadType, so, like the Kandinsky6 SR entries, it declares no workload.
|
||||
register_configs(
|
||||
sampling_param_cls=None,
|
||||
pipeline_config_cls=OpenH3Regenerate2KPipelineConfig,
|
||||
workload_types=(),
|
||||
hf_model_paths=[OPEN_H3_REGENERATE_2K_HF_PATH],
|
||||
model_detectors=[
|
||||
lambda path: any(token in path.lower()
|
||||
for token in ("open-h3-regenerate", "open_h3_regenerate", "openh3regenerate2kpipeline")),
|
||||
],
|
||||
model_family="open_h3_regenerate_2k",
|
||||
default_preset="open_h3_regenerate_2k",
|
||||
pipeline_cls_name="OpenH3Regenerate2KPipeline",
|
||||
)
|
||||
|
||||
# MiniMax H3
|
||||
register_configs(
|
||||
sampling_param_cls=None,
|
||||
@@ -1263,19 +1290,18 @@ def get_model_info(
|
||||
# is triggered just to log the replaced name).
|
||||
pipeline_name: str | None = override_pipeline_cls_name
|
||||
logger.info("Using override pipeline class name %s", pipeline_name)
|
||||
elif config_info.pipeline_cls_name is not None:
|
||||
# The resolved (path/detector-based) config pins the pipeline class, e.g. an I2V checkpoint whose
|
||||
# `_class_name` would otherwise resolve to the T2V pipeline. The manifest is not read: a release such as
|
||||
# Open-H3-Regenerate-2K keeps it in a subfolder.
|
||||
pipeline_name = config_info.pipeline_cls_name
|
||||
logger.info("Pinned pipeline class name %s", pipeline_name)
|
||||
else:
|
||||
if os.path.exists(model_path):
|
||||
config = verify_model_config_and_directory(model_path, required_component_dirs=[])
|
||||
else:
|
||||
config = maybe_download_model_index(model_path, revision=revision)
|
||||
|
||||
pipeline_name = _resolve_class_name(config)
|
||||
if config_info.pipeline_cls_name is not None:
|
||||
# The resolved (path/detector-based) config pins the pipeline class,
|
||||
# e.g. an I2V checkpoint whose `_class_name` would otherwise resolve
|
||||
# to the T2V pipeline.
|
||||
logger.info("Pinning pipeline class name from %s to %s", pipeline_name, config_info.pipeline_cls_name)
|
||||
pipeline_name = config_info.pipeline_cls_name
|
||||
|
||||
if pipeline_name is None:
|
||||
raise ValueError("Model config does not contain a _class_name attribute. "
|
||||
@@ -1350,6 +1376,8 @@ def _register_presets() -> None:
|
||||
ALL_PRESETS as MATRIXGAME3_PRESETS, )
|
||||
from fastvideo.pipelines.basic.minimax_h3.presets import (
|
||||
ALL_PRESETS as MINIMAX_H3_PRESETS, )
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.presets import (
|
||||
ALL_PRESETS as OPEN_H3_REGENERATE_2K_PRESETS, )
|
||||
from fastvideo.pipelines.basic.mmaudio.presets import (
|
||||
ALL_PRESETS as MMAUDIO_PRESETS, )
|
||||
from fastvideo.pipelines.basic.sd35.presets import (
|
||||
@@ -1383,6 +1411,7 @@ def _register_presets() -> None:
|
||||
MATRIXGAME2_PRESETS,
|
||||
MATRIXGAME3_PRESETS,
|
||||
MINIMAX_H3_PRESETS,
|
||||
OPEN_H3_REGENERATE_2K_PRESETS,
|
||||
MMAUDIO_PRESETS,
|
||||
SD35_PRESETS,
|
||||
STABLE_AUDIO_PRESETS,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Open-H3-Regenerate-2K through the OpenAI-compatible video routes, with a fake generator (no GPU)."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from fastvideo.entrypoints.cli.inference_config import build_serve_config
|
||||
from fastvideo.entrypoints.openai.api_server import create_app
|
||||
from fastvideo.entrypoints.openai.media_policy import MediaPolicy
|
||||
from fastvideo.entrypoints.openai.stores import VIDEO_STORE
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
MODEL = "Efficient-Large-Model/Open-H3-Regenerate-2K"
|
||||
ARTIFACT = b"fake-2k-video-for-transport-tests"
|
||||
PROMPT = "a quiet street at dusk"
|
||||
|
||||
|
||||
class FileGenerator:
|
||||
|
||||
def __init__(self):
|
||||
self.requests = []
|
||||
|
||||
def generate(self, request):
|
||||
self.requests.append(request)
|
||||
output = Path(request.output.output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(ARTIFACT)
|
||||
return SimpleNamespace(video_path=str(output), generation_time=0.01)
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def served(monkeypatch, tmp_path):
|
||||
generator = FileGenerator()
|
||||
monkeypatch.setattr("fastvideo.entrypoints.openai.api_server.VideoGenerator.from_fastvideo_args",
|
||||
lambda args: generator)
|
||||
drafts = tmp_path / "drafts"
|
||||
drafts.mkdir()
|
||||
(drafts / "draft.mp4").write_bytes(b"draft")
|
||||
Image.new("RGB", (8, 8)).save(drafts / "still.png")
|
||||
args = SimpleNamespace(model_path=MODEL, lora_path=None, lora_nickname="default", lora_strength=1.0,
|
||||
override_pipeline_cls_name=None)
|
||||
config = build_serve_config(SimpleNamespace(config=str(ROOT / "examples/serving/openai_open_h3_regenerate_2k.yaml")),
|
||||
overrides=[])
|
||||
app = create_app(args,
|
||||
str(tmp_path / "server"),
|
||||
config.default_request,
|
||||
config.server.served_model_name,
|
||||
media_policy=MediaPolicy(roots=(str(drafts), )))
|
||||
with TestClient(app) as client:
|
||||
yield client, generator, drafts
|
||||
asyncio.run(VIDEO_STORE.clear())
|
||||
|
||||
|
||||
def _body(drafts, **extra):
|
||||
return {"prompt": PROMPT, "video_reference": {"video_url": str(drafts / "draft.mp4")}, **extra}
|
||||
|
||||
|
||||
def test_serving_yaml_pins_the_2k_defaults():
|
||||
config = build_serve_config(SimpleNamespace(config=str(ROOT / "examples/serving/openai_open_h3_regenerate_2k.yaml")),
|
||||
overrides=[])
|
||||
sampling = config.default_request.sampling
|
||||
assert (sampling.height, sampling.width, sampling.num_frames, sampling.num_inference_steps) == (1440, 2560, 124, 3)
|
||||
assert config.generator.model_path == MODEL
|
||||
|
||||
|
||||
def test_sync_route_returns_the_mp4_and_maps_the_draft_to_video_path(served):
|
||||
client, generator, drafts = served
|
||||
response = client.post("/v1/videos/sync", json=_body(drafts, seed=7))
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.content == ARTIFACT
|
||||
assert response.headers["content-type"] == "video/mp4"
|
||||
assert response.headers["x-model"] == "open-h3-regenerate-2k"
|
||||
request = generator.requests[0]
|
||||
assert request.prompt == PROMPT
|
||||
assert request.inputs.video_path == str((drafts / "draft.mp4").resolve())
|
||||
assert (request.sampling.height, request.sampling.width, request.sampling.num_frames) == (1440, 2560, 124)
|
||||
assert request.sampling.num_inference_steps == 3 and request.sampling.seed == 7
|
||||
|
||||
|
||||
def test_async_route_completes_and_serves_the_content(served):
|
||||
client, generator, drafts = served
|
||||
created = client.post("/v1/videos", json=_body(drafts))
|
||||
assert created.status_code == 200, created.text
|
||||
video_id = created.json()["id"]
|
||||
deadline = time.monotonic() + 10
|
||||
while time.monotonic() < deadline:
|
||||
status = client.get(f"/v1/videos/{video_id}").json()["status"]
|
||||
if status in ("completed", "failed"):
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert status == "completed"
|
||||
assert client.get(f"/v1/videos/{video_id}/content").content == ARTIFACT
|
||||
assert generator.requests[0].inputs.video_path.endswith("draft.mp4")
|
||||
|
||||
|
||||
def test_two_refine_steps_can_be_requested(served):
|
||||
client, generator, drafts = served
|
||||
assert client.post("/v1/videos/sync", json=_body(drafts, num_inference_steps=2)).status_code == 200
|
||||
assert generator.requests[0].sampling.num_inference_steps == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reference, message", [
|
||||
({"video_url": "data:video/mp4;base64,AAAA"}, "Data video references are not supported"),
|
||||
({"video_url": "/etc/passwd"}, "Local media paths are not accepted"),
|
||||
({"file_id": "file_1"}, "file_id video references are not supported"),
|
||||
])
|
||||
def test_unusable_video_references_are_rejected(served, reference, message):
|
||||
client, generator, _ = served
|
||||
response = client.post("/v1/videos/sync", json={"prompt": PROMPT, "video_reference": reference})
|
||||
assert response.status_code == 400 and message in response.text
|
||||
assert not generator.requests
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra, message", [
|
||||
({"video_reference": None}, "exactly one video_reference"),
|
||||
({"image_reference": {"image_url": "STILL"}}, "exactly one video_reference"),
|
||||
({"size": "1920x1080"}, "always renders 2560x1440"),
|
||||
({"seconds": 5}, "first 121 frames"),
|
||||
({"fps": 30}, "requires fps=24"),
|
||||
])
|
||||
def test_requests_the_pipeline_cannot_honor_are_rejected(served, extra, message):
|
||||
client, generator, drafts = served
|
||||
body = {**_body(drafts), **extra}
|
||||
if "image_reference" in body:
|
||||
body["image_reference"] = {"image_url": str(drafts / "still.png")}
|
||||
body = {key: value for key, value in body.items() if value is not None}
|
||||
response = client.post("/v1/videos/sync", json=body)
|
||||
assert response.status_code == 400 and message in response.text
|
||||
assert not generator.requests
|
||||
|
||||
|
||||
def test_two_drafts_are_rejected(served):
|
||||
client, generator, drafts = served
|
||||
reference = {"video_url": str(drafts / "draft.mp4")}
|
||||
response = client.post("/v1/videos/sync", json={"prompt": PROMPT, "video_reference": [reference, reference]})
|
||||
assert response.status_code == 400 and "exactly one video_reference" in response.text
|
||||
@@ -0,0 +1,44 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""CPU contract: merging an adapter stored wider than the base rounds once, after a wide add."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from fastvideo.layers.linear import ReplicatedLinear
|
||||
from fastvideo.layers.lora import linear as lora_linear
|
||||
from fastvideo.layers.lora.linear import get_lora_layer
|
||||
|
||||
IN, OUT, RANK = 32, 24, 16
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cpu_merge(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(torch._dynamo.config, "disable", True)
|
||||
monkeypatch.setattr(lora_linear, "get_local_torch_device", lambda: torch.device("cpu"))
|
||||
|
||||
|
||||
def _merged(adapter_dtype: torch.dtype) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
torch.manual_seed(0)
|
||||
layer = ReplicatedLinear(IN, OUT, bias=False, params_dtype=torch.bfloat16)
|
||||
with torch.no_grad():
|
||||
layer.weight.copy_(torch.randn(OUT, IN))
|
||||
base = layer.weight.detach().clone()
|
||||
wrapped = get_lora_layer(layer)
|
||||
a, b = torch.randn(RANK, IN, dtype=adapter_dtype), torch.randn(OUT, RANK, dtype=adapter_dtype) * 0.1
|
||||
wrapped.set_lora_weights(a, b, lora_alpha=RANK, strength=1.0)
|
||||
wrapped.merge_lora_weights()
|
||||
return wrapped.base_layer.weight.detach(), base, a, b
|
||||
|
||||
|
||||
def test_fp32_adapter_is_added_in_fp32_and_rounded_once() -> None:
|
||||
merged, base, a, b = _merged(torch.float32)
|
||||
expected = (base.float() + b @ a).to(torch.bfloat16)
|
||||
assert merged.dtype == torch.bfloat16
|
||||
assert torch.equal(merged, expected)
|
||||
|
||||
|
||||
def test_bf16_adapter_keeps_the_bf16_merge() -> None:
|
||||
merged, base, a, b = _merged(torch.bfloat16)
|
||||
assert torch.equal(merged, base + b @ a)
|
||||
@@ -0,0 +1,300 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""CPU unit tests for the Open-H3-Regenerate-2K building blocks."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
|
||||
from fastvideo.models.upsamplers.minimax_h3_latent_upscaler import (
|
||||
MiniMaxH3LatentUpscaler,
|
||||
resolve_upscale_geometry,
|
||||
upscale_latent,
|
||||
)
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k import draft_video
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sampling import (
|
||||
mix_initial_state,
|
||||
refine_sigmas,
|
||||
shifted_grid,
|
||||
)
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sharp_curve import (
|
||||
NUM_CURVE_BRANCHES,
|
||||
SharpCurveBranches,
|
||||
interpolate_curve,
|
||||
)
|
||||
|
||||
|
||||
def test_default_sigmas_match_the_released_grid():
|
||||
assert refine_sigmas(3) == pytest.approx([0.909375, 0.857143, 0.666667, 0.0], abs=1e-6)
|
||||
|
||||
|
||||
def test_two_step_sigmas_start_at_0_7():
|
||||
assert refine_sigmas(2) == pytest.approx([0.7, 0.494118, 0.0], abs=1e-6)
|
||||
|
||||
|
||||
def test_shifted_grid_from_the_released_start_is_the_original_four_step_grid():
|
||||
assert shifted_grid(0.909375, 4) == pytest.approx([0.909375, 0.841446, 0.732075, 0.526697, 0.0], abs=1e-6)
|
||||
|
||||
|
||||
def test_custom_start_sigma_is_decreasing_and_ends_at_zero():
|
||||
grid = refine_sigmas(3, start_sigma=0.8)
|
||||
assert grid[0] == 0.8 and grid[-1] == 0.0
|
||||
assert all(a > b for a, b in zip(grid, grid[1:]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("steps", [0, 4])
|
||||
def test_unsupported_step_count_is_rejected(steps):
|
||||
with pytest.raises(ValueError):
|
||||
refine_sigmas(steps)
|
||||
|
||||
|
||||
def test_initial_state_mix_weights_noise_by_sigma():
|
||||
upscaled, noise = torch.ones(2, 3), torch.zeros(2, 3)
|
||||
assert torch.allclose(mix_initial_state(upscaled, noise, 0.909375), torch.full((2, 3), 0.090625))
|
||||
assert torch.equal(mix_initial_state(upscaled, noise, 0.0), upscaled)
|
||||
|
||||
|
||||
def test_upscale_geometry_targets_2k_from_768p_and_576p():
|
||||
assert resolve_upscale_geometry((48, 84), (1440, 2560))[0] == (90, 160)
|
||||
shape, scale = resolve_upscale_geometry((36, 64), (1440, 2560))
|
||||
assert shape == (90, 160) and scale == pytest.approx(2.5)
|
||||
|
||||
|
||||
def test_tiny_upscaler_forward_shape_and_identity_passthrough():
|
||||
model = MiniMaxH3LatentUpscaler(channels=32, in_blocks=2, out_blocks=2).eval()
|
||||
latent = torch.randn(24, 5, 4, 6)
|
||||
out = upscale_latent(model, latent, (96, 160), dtype=torch.float32)
|
||||
assert out.shape == (24, 5, 6, 10) and out.dtype == torch.float32
|
||||
assert torch.equal(upscale_latent(model, latent, (64, 96)), latent)
|
||||
|
||||
|
||||
def test_upscaler_checkpoint_roundtrip(tmp_path):
|
||||
model = MiniMaxH3LatentUpscaler(channels=32, in_blocks=3, out_blocks=2)
|
||||
path = str(tmp_path / "upscaler.safetensors")
|
||||
save_file(model.state_dict(), path)
|
||||
loaded = MiniMaxH3LatentUpscaler.from_checkpoint(path)
|
||||
assert len(loaded.in_blocks) == len(model.in_blocks) and len(loaded.out_blocks) == len(model.out_blocks)
|
||||
for key, value in model.state_dict().items():
|
||||
assert torch.equal(loaded.state_dict()[key], value)
|
||||
|
||||
|
||||
def test_curve_interpolation_hits_table_rows_and_midpoints():
|
||||
table = torch.arange(1025 * 8, dtype=torch.float32).reshape(1025, 8)
|
||||
coords = interpolate_curve(table, torch.tensor([0.0, 1.0, 0.5, 1 / 2048]))
|
||||
assert torch.equal(coords[0], table[0]) and torch.equal(coords[1], table[1024])
|
||||
assert torch.equal(coords[2], table[512])
|
||||
assert torch.allclose(coords[3], (table[0] + table[1]) / 2)
|
||||
with pytest.raises(ValueError):
|
||||
interpolate_curve(table, torch.tensor([1.5]))
|
||||
|
||||
|
||||
class _Projection(torch.nn.Module):
|
||||
|
||||
def __init__(self, out_features):
|
||||
super().__init__()
|
||||
self.linear = torch.nn.Linear(4, out_features)
|
||||
|
||||
def forward(self, x):
|
||||
return self.linear(x), None
|
||||
|
||||
|
||||
def _write_curve_release(root, out_features, count):
|
||||
(root / "lora").mkdir()
|
||||
(root / "lora" / "adaln_t_table.bin").write_bytes(np.random.rand(1025 * 8).astype(np.float32).tobytes())
|
||||
tensors = {}
|
||||
for index in range(count):
|
||||
tensors[f"transformer.blocks.{index}.proj.curve_A.weight"] = torch.randn(3, 8)
|
||||
tensors[f"transformer.blocks.{index}.proj.curve_B.weight"] = torch.randn(out_features, 3)
|
||||
save_file(tensors, str(root / "lora" / "sharp_curve_branches.safetensors"))
|
||||
|
||||
|
||||
def test_sharp_branches_require_all_fifty(tmp_path):
|
||||
_write_curve_release(tmp_path, 6, 3)
|
||||
with pytest.raises(ValueError, match="50"):
|
||||
SharpCurveBranches(tmp_path)
|
||||
|
||||
|
||||
def test_sharp_branch_adds_curve_term_to_projection_output(tmp_path):
|
||||
_write_curve_release(tmp_path, 6, NUM_CURVE_BRANCHES)
|
||||
curve = SharpCurveBranches(tmp_path)
|
||||
model = torch.nn.Module()
|
||||
model.blocks = torch.nn.ModuleList()
|
||||
for _ in range(NUM_CURVE_BRANCHES):
|
||||
block = torch.nn.Module()
|
||||
block.proj = _Projection(6)
|
||||
model.blocks.append(block)
|
||||
curve.attach(model)
|
||||
curve.attach(model) # idempotent: a second attach must not double the term
|
||||
timesteps = torch.tensor([0.1, 0.9])
|
||||
x = torch.randn(2, 4)
|
||||
base, _ = model.blocks[0].proj.linear(x), None
|
||||
curve.set_timesteps(timesteps)
|
||||
out, extra = model.blocks[0].proj(x)
|
||||
a, b = curve.factors["blocks.0.proj"]
|
||||
expected = base + torch.nn.functional.linear(torch.nn.functional.linear(curve.coords, a), b)
|
||||
assert extra is None and torch.allclose(out, expected, atol=1e-6)
|
||||
|
||||
|
||||
def test_sharp_branch_without_timesteps_fails_loudly(tmp_path):
|
||||
_write_curve_release(tmp_path, 6, NUM_CURVE_BRANCHES)
|
||||
curve = SharpCurveBranches(tmp_path)
|
||||
model = torch.nn.Module()
|
||||
model.blocks = torch.nn.ModuleList()
|
||||
for _ in range(NUM_CURVE_BRANCHES):
|
||||
block = torch.nn.Module()
|
||||
block.proj = _Projection(6)
|
||||
model.blocks.append(block)
|
||||
curve.attach(model)
|
||||
with pytest.raises(RuntimeError, match="timesteps"):
|
||||
model.blocks[0].proj(torch.randn(2, 4))
|
||||
|
||||
|
||||
def test_ffmpeg_command_cover_scales_and_caps_at_121_frames():
|
||||
command = draft_video.ffmpeg_decode_command("clip.mp4", 1344, 768)
|
||||
video_filter = command[command.index("-vf") + 1]
|
||||
assert "fps=24" in video_filter and "scale=1344:768:force_original_aspect_ratio=increase" in video_filter
|
||||
assert "crop=1344:768" in video_filter
|
||||
assert command[command.index("-frames:v") + 1] == "121"
|
||||
|
||||
|
||||
def test_draft_pixels_pad_the_last_frame_to_124(monkeypatch):
|
||||
width, height = draft_video.DRAFT_SIZES["576p"]
|
||||
frames = np.arange(121, dtype=np.uint8).reshape(121, 1, 1, 1) * np.ones((121, height, width, 3), np.uint8)
|
||||
|
||||
class _Done:
|
||||
stdout = frames.tobytes()
|
||||
|
||||
monkeypatch.setattr(draft_video.subprocess, "run", lambda *a, **k: _Done())
|
||||
pixels = draft_video.load_draft_pixels("clip.mp4", "576p")
|
||||
assert pixels.shape == (1, 3, 124, height, width) and pixels.dtype == torch.uint8
|
||||
assert torch.equal(pixels[:, :, 120], pixels[:, :, 123])
|
||||
|
||||
|
||||
def test_draft_pixels_reject_truncated_decode(monkeypatch):
|
||||
|
||||
class _Short:
|
||||
stdout = b"\x00" * 10
|
||||
|
||||
monkeypatch.setattr(draft_video.subprocess, "run", lambda *a, **k: _Short())
|
||||
with pytest.raises(ValueError, match="decoded to 10 bytes"):
|
||||
draft_video.load_draft_pixels("clip.mp4")
|
||||
|
||||
|
||||
class _ConstantVelocityTransformer:
|
||||
"""Predicts a constant video velocity and records what the stage feeds it."""
|
||||
|
||||
def __init__(self, velocity):
|
||||
self.velocity = velocity
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
rows = kwargs["hidden_states"].shape[1]
|
||||
audio_rows = kwargs["audio_hidden_states"].shape[1]
|
||||
return torch.full((1, rows, 8), self.velocity), torch.zeros(1, audio_rows, 4)
|
||||
|
||||
|
||||
class _RecordingCurve:
|
||||
|
||||
def __init__(self):
|
||||
self.timesteps = []
|
||||
self.attached = 0
|
||||
|
||||
def attach(self, transformer):
|
||||
self.attached += 1
|
||||
|
||||
def set_timesteps(self, unique):
|
||||
self.timesteps.append(unique.clone())
|
||||
|
||||
|
||||
def _denoise(monkeypatch, steps, velocity=0.5):
|
||||
from fastvideo.pipelines.basic.minimax_h3.packing import build_packed_sequence
|
||||
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_latent_preparation import MINIMAX_H3_LAYOUT_KEY
|
||||
from fastvideo.pipelines.basic.open_h3_regenerate_2k.stages import denoising
|
||||
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
|
||||
|
||||
monkeypatch.setattr(denoising, "get_local_torch_device", lambda: torch.device("cpu"))
|
||||
layout = build_packed_sequence(torch.ones(3, dtype=torch.long), 2, 2, 2, 2, (1, 2, 2))
|
||||
batch = ForwardBatch(data_type="video")
|
||||
batch.num_inference_steps = steps
|
||||
batch.prompt_embeds = [torch.zeros(1, 3, 4)]
|
||||
batch.latents = torch.zeros(layout.video_indices.numel(), 8)
|
||||
batch.audio_latents = torch.ones(layout.audio_indices.numel(), 4)
|
||||
batch.extra[MINIMAX_H3_LAYOUT_KEY] = layout
|
||||
transformer, curve = _ConstantVelocityTransformer(velocity), _RecordingCurve()
|
||||
stage = denoising.Regenerate2KDenoisingStage(transformer, curve, None)
|
||||
return stage.forward(batch, None), transformer, curve
|
||||
|
||||
|
||||
def test_euler_steps_integrate_the_constant_velocity_over_sigma_zero_to_start(monkeypatch):
|
||||
batch, transformer, curve = _denoise(monkeypatch, steps=3, velocity=0.5)
|
||||
assert len(transformer.calls) == 3 and curve.attached == 1
|
||||
assert torch.allclose(batch.latents, torch.full_like(batch.latents, 0.909375 * 0.5))
|
||||
|
||||
|
||||
def test_video_clock_is_one_minus_sigma_and_audio_is_held_clean(monkeypatch):
|
||||
_, transformer, curve = _denoise(monkeypatch, steps=3)
|
||||
for call, unique, sigma in zip(transformer.calls, curve.timesteps, [0.909375, 6 / 7, 2 / 3]):
|
||||
assert torch.allclose(unique.sort().values, torch.tensor([1.0 - sigma, 1.0]).sort().values.float(), atol=1e-6)
|
||||
assert call["hidden_states"].dtype == torch.bfloat16 and call["audio_hidden_states"].dtype == torch.bfloat16
|
||||
assert torch.equal(call["audio_hidden_states"].float(), torch.ones_like(call["audio_hidden_states"]).float())
|
||||
|
||||
|
||||
def test_two_step_request_runs_two_forwards(monkeypatch):
|
||||
batch, transformer, _ = _denoise(monkeypatch, steps=2, velocity=1.0)
|
||||
assert len(transformer.calls) == 2
|
||||
assert torch.allclose(batch.latents, torch.full_like(batch.latents, 0.7), atol=1e-6)
|
||||
|
||||
|
||||
def test_registry_resolves_the_release_to_the_regenerate_pipeline(tmp_path):
|
||||
from fastvideo.configs.pipelines.open_h3_regenerate_2k import OpenH3Regenerate2KPipelineConfig
|
||||
from fastvideo.registry import get_model_info, get_pipeline_config_cls_from_name, get_preset_selection
|
||||
|
||||
hub_id = "Efficient-Large-Model/Open-H3-Regenerate-2K"
|
||||
(tmp_path / "lora").mkdir()
|
||||
(tmp_path / "lora" / "open_h3_regenerate_2k_lora.safetensors").write_bytes(b"")
|
||||
for path in (hub_id, str(tmp_path)):
|
||||
assert get_pipeline_config_cls_from_name(path) is OpenH3Regenerate2KPipelineConfig
|
||||
assert get_model_info(path).pipeline_cls.__name__ == "OpenH3Regenerate2KPipeline"
|
||||
preset, family = get_preset_selection(hub_id)
|
||||
assert (preset, family) == ("open_h3_regenerate_2k", "open_h3_regenerate_2k")
|
||||
|
||||
|
||||
def test_serve_config_forwards_the_nvfp4_decoder_option(monkeypatch):
|
||||
"""`generator.pipeline.experimental.h3_vae_nvfp4_decoder` reaches FastVideoArgs through the serve path."""
|
||||
from fastvideo import fastvideo_args as fva
|
||||
from fastvideo.api import ComponentConfig, EngineConfig, GeneratorConfig, PipelineSelection
|
||||
from fastvideo.api.compat import generator_config_to_fastvideo_args
|
||||
|
||||
captured = {}
|
||||
|
||||
class _Captured:
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return _Captured(**kwargs)
|
||||
|
||||
monkeypatch.setattr(fva.FastVideoArgs, "from_kwargs", _capture)
|
||||
generator_config_to_fastvideo_args(
|
||||
GeneratorConfig(model_path="Efficient-Large-Model/Open-H3-Regenerate-2K",
|
||||
engine=EngineConfig(num_gpus=1),
|
||||
pipeline=PipelineSelection(components=ComponentConfig(),
|
||||
experimental={
|
||||
"draft_size": "768p",
|
||||
"h3_vae_nvfp4_decoder": "/tmp/decoder-nvfp4.safetensors",
|
||||
})))
|
||||
assert captured["h3_vae_nvfp4_decoder"] == "/tmp/decoder-nvfp4.safetensors"
|
||||
assert captured["draft_size"] == "768p"
|
||||
|
||||
|
||||
def test_cli_flag_sets_the_nvfp4_decoder_option():
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.utils import FlexibleArgumentParser
|
||||
|
||||
args = FastVideoArgs.add_cli_args(FlexibleArgumentParser()).parse_args(
|
||||
["--h3-vae-nvfp4-decoder", "/tmp/decoder-nvfp4.safetensors"])
|
||||
assert args.h3_vae_nvfp4_decoder == "/tmp/decoder-nvfp4.safetensors"
|
||||
assert FastVideoArgs.add_cli_args(FlexibleArgumentParser()).parse_args([]).h3_vae_nvfp4_decoder is None
|
||||
@@ -487,19 +487,34 @@ def _write_deploy_checkpoint(path, student_layers: int, base_layers: int, heads:
|
||||
return decoder
|
||||
|
||||
|
||||
def _load_h3_vae(vae_dir, cpu: bool = True, decoder: str | None = None):
|
||||
def _load_h3_vae(vae_dir, cpu: bool = True, decoder: str | None = None, pipeline_config=None):
|
||||
from fastvideo.configs.models.vaes.minimax_h3_video import MiniMaxH3VideoVAEConfig
|
||||
from fastvideo.configs.pipelines import PipelineConfig
|
||||
from fastvideo.fastvideo_args import FastVideoArgs
|
||||
from fastvideo.models.loader.component_loader import VAELoader
|
||||
|
||||
args = FastVideoArgs(model_path=str(vae_dir),
|
||||
pipeline_config=PipelineConfig(vae_config=MiniMaxH3VideoVAEConfig(), vae_precision="fp32"),
|
||||
h3_vae_nvfp4_decoder=decoder)
|
||||
args = FastVideoArgs(
|
||||
model_path=str(vae_dir),
|
||||
pipeline_config=pipeline_config or PipelineConfig(vae_config=MiniMaxH3VideoVAEConfig(), vae_precision="fp32"),
|
||||
h3_vae_nvfp4_decoder=decoder)
|
||||
args.vae_cpu_offload = cpu # load on CPU
|
||||
return VAELoader().load(str(vae_dir), args)
|
||||
|
||||
|
||||
def test_open_h3_regenerate_2k_vae_load_applies_the_exported_decoder(tmp_path):
|
||||
"""Regenerate-2K loads its ``base_model/vae`` through the same swap, so the option applies there too."""
|
||||
from fastvideo.configs.pipelines.open_h3_regenerate_2k import OpenH3Regenerate2KPipelineConfig
|
||||
|
||||
_write_vae_dir(tmp_path / "vae", decoder_layers=5)
|
||||
_write_deploy_checkpoint(tmp_path / "d3.pt", student_layers=3, base_layers=5)
|
||||
config = OpenH3Regenerate2KPipelineConfig()
|
||||
assert len(_load_h3_vae(tmp_path / "vae", pipeline_config=config).decoder.transformer_blocks) == 5
|
||||
vae = _load_h3_vae(tmp_path / "vae", decoder=str(tmp_path / "d3.pt"), pipeline_config=config)
|
||||
assert len(vae.decoder.transformer_blocks) == 3
|
||||
assert vae.decode_autocast_dtype == torch.bfloat16
|
||||
assert isinstance(vae.decoder.transformer_blocks[1].ff.net[2], NVFP4DecoderLinear)
|
||||
|
||||
|
||||
def test_loader_applies_exported_nvfp4_decoder(tmp_path):
|
||||
import fastvideo.envs as envs
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Opt-in parity of the ported latent upscaler against the author's ComfyUI node.
|
||||
|
||||
Needs the release weights (OPEN_H3_REGEN_WEIGHTS) and a checkout of NVlabs/Sana on the ``sol-engine`` branch
|
||||
(OPEN_H3_REGEN_REF, the models/Open-H3-Regenerate-2K directory). GPU only: the node runs the network in bf16.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from fastvideo.models.upsamplers.minimax_h3_latent_upscaler import MiniMaxH3LatentUpscaler, upscale_latent
|
||||
|
||||
WEIGHTS = os.environ.get("OPEN_H3_REGEN_WEIGHTS")
|
||||
REFERENCE = os.environ.get("OPEN_H3_REGEN_REF")
|
||||
CHECKPOINT = "latent_upsampler/minimax_h3_latent_upscaler_3d_conv_v1_bf16.safetensors"
|
||||
|
||||
pytestmark = pytest.mark.skipif(not (WEIGHTS and REFERENCE and torch.cuda.is_available()),
|
||||
reason="needs OPEN_H3_REGEN_WEIGHTS, OPEN_H3_REGEN_REF and a GPU")
|
||||
|
||||
|
||||
def _load_author_node(checkpoint: str):
|
||||
folder = types.ModuleType("folder_paths")
|
||||
folder.folder_names_and_paths = {"latent_upscale_models": ([], set())}
|
||||
folder.get_full_path_or_raise = lambda category, name: checkpoint
|
||||
sys.modules["folder_paths"] = folder
|
||||
sys.modules["comfy.model_management"] = None
|
||||
sys.modules["comfy_api.latest"] = None
|
||||
spec = importlib.util.spec_from_file_location("author_upscaler",
|
||||
Path(REFERENCE) / "open_h3" / "upscaler_node.py")
|
||||
node = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(node)
|
||||
node.io.NodeOutput = lambda value: (value, )
|
||||
return node
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shape", [(24, 37, 48, 84), (24, 37, 36, 64)])
|
||||
def test_upscaler_matches_the_author_node(shape):
|
||||
checkpoint = str(Path(WEIGHTS) / CHECKPOINT)
|
||||
node = _load_author_node(checkpoint)
|
||||
torch.manual_seed(0)
|
||||
latent = torch.randn(shape)
|
||||
mode = {"mode": node.UpscaleMode.TARGET_DIMENSIONS, "width": 2560, "height": 1440}
|
||||
with torch.inference_mode():
|
||||
(expected, ) = node.MinimaxH3LatentUpscaler3D.execute({"samples": latent[None].float()},
|
||||
"upscaler",
|
||||
mode,
|
||||
align=32,
|
||||
enable_temporal_chunking=False,
|
||||
force_unload=False,
|
||||
device="cuda",
|
||||
precision="bf16")
|
||||
model = MiniMaxH3LatentUpscaler.from_checkpoint(checkpoint).to("cuda", torch.bfloat16)
|
||||
actual = upscale_latent(model, latent.cuda(), (1440, 2560)).cpu()
|
||||
assert torch.equal(actual, expected["samples"].float()[0])
|
||||
Reference in New Issue
Block a user