Merge hao-ai-lab/FastVideo main into alex/openai-data-url-refs

Conflict in request_adapter.py with #2001, which adds continuation_video and
materializes it through _materialize_video. This branch replaced that helper
with materialize_clip, so continuation_video now goes through materialize_clip
as well, with the same "continuation" upload name. As a result it gets the same
policy as the other video fields: media_roots, the private-URL check, the size
cap, and base64 data:video/... URLs.

- docs/design/server_contracts/openai.md: continuation_video is listed in the
  video row of the media-source table.
- docs/inference/fasth3_omniref.md: continuation_video may be a data:video URL.
- test_openai_serving_engine.py: an inline continuation video is decoded,
  tracked and removed, and a data:audio one is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alexzms
2026-11-05 01:33:06 +00:00
co-authored by Claude Opus 5.5
29 changed files with 1488 additions and 86 deletions
+1
View File
@@ -24,6 +24,7 @@ exec pytest \
./fastvideo/tests/stages/ \
./fastvideo/tests/ops/ \
./fastvideo/tests/worker/ \
./fastvideo/tests/distributed/test_sp_warmup_device.py \
./fastvideo/tests/training/test_runner.py \
./fastvideo/tests/training/test_trackers.py \
./fastvideo/tests/training/test_ltx2_rope_fps.py \
@@ -628,6 +628,7 @@ surfaces:
audio_reference: request.inputs.references
video_path: request.inputs.video_path
video_url: request.inputs.video_path
continuation_video: request.inputs.references
video_params: request.sampling.width,height,num_frames,fps
size:
target: request.sampling.width,height
+1 -1
View File
@@ -94,7 +94,7 @@ Reference objects carry one string each through `image_url`, `video_url`, and
| Field | `http(s)` URL | `data:` URL | Local path |
| --- | --- | --- | --- |
| `image_reference[].image_url`, `input_reference`, `reference_url` | yes | `data:image/...;base64,` | under `server.media_roots` |
| `video_reference[].video_url`, `video_path`, `video_url` | yes | `data:video/...;base64,` | under `server.media_roots` |
| `video_reference[].video_url`, `video_path`, `video_url`, `continuation_video` | yes | `data:video/...;base64,` | under `server.media_roots` |
| `audio_reference[].audio_url` | yes | `data:audio/...;base64,` | under `server.media_roots` |
`input_reference` also accepts a multipart upload. `file_id` references are
+36
View File
@@ -26,3 +26,39 @@ to receive the decoded CPU tensor in `GenerationResult.samples` without building
preview grids or writing media. Pixel samples retain the existing float `[0, 1]`
contract. `return_frames=True` continues to return both samples and preview frames.
The legacy sampling/keyword interface accepts the same `return_samples` option.
### Choosing an output filename
Set `request.output.output_video_name` to choose a filename when
`request.output.output_path` is a directory:
```python
result = generator.generate({
"prompt": "A waterfall in the mountains",
"output": {"output_path": "outputs/", "output_video_name": "my_clip"},
})
# Saves outputs/my_clip.mp4 when that file does not already exist.
```
An explicit file path such as `outputs/custom.mp4` takes precedence over
`output_video_name`. Without a name, the filename is derived from the prompt
(or the input video for prompt-less super-resolution pipelines). Names are
sanitized, and existing files are preserved by adding numeric suffixes such as
`my_clip_1.mp4`, including when processing multiple prompts. A matching extension
in the name is not duplicated. Image workloads accept PNG, JPEG, or WebP names
and default to PNG; audio workloads use WAV.
For requests that save media, the sanitized filename is checked against the
destination filesystem's filename byte limit, including its extension and any
collision suffix. If the limit cannot be determined, 255 bytes is used.
Overlong names are rejected before model execution; use a shorter name rather
than relying on automatic truncation. Multibyte characters may use more than
one byte each. Prompt-file batches retain their existing per-prompt error
handling.
Requests with `save_video=False` or `output_type="latent"` do not prepare an
output file: filename validation, directory creation, and collision checks are
skipped. This also applies to latent output when `save_video=True`.
The legacy `SamplingParam` and `generate_video` keyword interfaces accept the
same `output_video_name` option.
+32
View File
@@ -148,6 +148,38 @@ Select the NVFP4 decoder per configuration with
holds base MiniMax-H3's full 36-block VAE, so both the 36-block quality tier and
the 8-block fast tier apply.
## Continuing a video
A Ref2VA request can continue an earlier video. It takes the earlier video's last
5, 22 or 39 frames (and their sound, if any) as a continuation clip, and the
generated video starts by regenerating those frames at the same positions: the
clip's latent rows take the coordinates of the target's first latent frames and
audio latents, the reference context of
[ComfyUI-H3-Continuum](https://github.com/ukr8b3g-cmyk/ComfyUI-H3-Continuum).
Drop the clip's frames, and the matching audio, from the new video, then append
the rest to the earlier one.
- Over HTTP, set `continuation_video` to the clip's URL, a base64
`data:video/...` URL or a local path (under `server.media_roots`) beside the
usual references. In Python, append
`MiniMaxH3Reference(source=clip, media_type="continuation")` to `references`.
- The clip is resampled to 24 fps and must then have 5, 22 or 39 frames on the
target canvas. A request takes at most one clip and still needs at least one
reference; the clip does not count toward the reference limits and is not
shown to the text encoder.
- `num_frames` counts the clip: 22 frames plus five new seconds is 142,
aligned up to 158.
- Write the prompt from the clip's actual end state. When the prompt's opening
contradicts the clip, the model tends to cut to a new shot right after it.
- Each continuation drifts a little toward higher saturation and sharpness, so
fewer, longer segments (two 15-second ones rather than six 5-second ones) hold
up better.
Measured on a FastH3 OmniRef PDD student at 1344×768, 22-frame clips re-encoded
from the earlier output: the jump at a join was about half that of conditioning
the next segment on the earlier last frame as an image reference, and the sound
continued instead of restarting from near silence.
## Environment
The profiles set these; set them yourself when driving the YAML recipes.
@@ -12,6 +12,17 @@ format, which stores already augmented `ref_visual_anchor` tensors. Keep using
`preprocess_minimax_h3_ref2va` for that format. Do not rename or directly combine
shards from these two schemas.
The SFT preprocessor keeps each target clip's own geometry: the canvas follows
the clip's aspect ratio at the inference short edge (`resolve_canvas_size`), and
the frame count is the longest `17 * n + 5` that fits the clip, capped at 362
(about 15 seconds; a longer clip trains on its start). Clips under 124 frames at
24 fps fail. Target audio is cut to the samples covered by that frame count's
whole audio latents, so the record matches what inference generates. To train
on those lengths, leave `training.data.num_latent_t` and `num_frames` unset in
the training config. When they are set, every row is cropped to that length;
for example, the 124-frame overfit values crop a 10-second clip to its first
5.17 seconds while its caption still describes all 10.
## Input
Provide your own local MiniMax-H3 checkpoint with `vae`, `audio_vae`, `tokenizer`,
+6
View File
@@ -303,6 +303,12 @@ endif()
# there, and tk's base_types.cuh brace-initializes signed-char vector members
# from char (narrowing error). Skip TK until upstream is aarch64-clean.
if(ENABLE_TK_KERNELS AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64)$")
if(FASTVIDEO_KERNEL_BUILD_TK STREQUAL "ON")
message(FATAL_ERROR
"fastvideo-kernel: FASTVIDEO_KERNEL_BUILD_TK=ON was requested, but the "
"ThunderKittens headers do not compile on ${CMAKE_SYSTEM_PROCESSOR}. "
"Use FASTVIDEO_KERNEL_BUILD_TK=AUTO or OFF to build with Triton fallbacks.")
endif()
message(STATUS "ThunderKittens kernels: forced OFF on ${CMAKE_SYSTEM_PROCESSOR} (tk headers are not aarch64-clean)")
set(ENABLE_TK_KERNELS OFF)
endif()
+2 -2
View File
@@ -9,7 +9,7 @@ Compiled CUDA extensions (CMake, see the build summary printed at the end of eve
| Extension | Kernels | Sources | GPU arch | Build gate |
|---|---|---|---|---|
| `fastvideo_kernel._C.fastvideo_kernel_ops` | TurboDiffusion INT8 GEMM, quant, RMSNorm, LayerNorm | `csrc/turbodiffusion/` | every arch in `TORCH_CUDA_ARCH_LIST` | always built |
| same extension, optional part | ThunderKittens sliding-tile attention (`sta_fwd`) and VSA block-sparse (`block_sparse_fwd/bwd`) | `csrc/attention/*_h100.cu` | Hopper `sm_90a` only | `FASTVIDEO_KERNEL_BUILD_TK` (AUTO = ON iff `9.0a` is in the arch list; always OFF on aarch64 hosts — TK headers don't compile there) |
| same extension, optional part | ThunderKittens sliding-tile attention (`sta_fwd`) and VSA block-sparse (`block_sparse_fwd/bwd`) | `csrc/attention/*_h100.cu` | Hopper `sm_90a` only | `FASTVIDEO_KERNEL_BUILD_TK` (AUTO enables `9.0a` on supported hosts; ON requires TK and fails on aarch64 compiled-extension builds, where its headers do not compile) |
| same extension, optional part | MiniMax-H3 block-sparse VSA forward (64- and 128-token blocks) | `csrc/attention/block_sparse*.cu` | Data-center Blackwell `sm_100a`/`sm_103a` | ON iff `10.0a` or `10.3a` is in `TORCH_CUDA_ARCH_LIST` |
| same extension, optional part | fused NVLink Ulysses all-to-all | `csrc/comm/ulysses_all_to_all.cu` | CUDA | `FASTVIDEO_KERNEL_BUILD_ULYSSES_A2A` (AUTO = ON with NCCL 2.29+ device headers and library; always OFF on ROCm) |
| `fp4attn_cuda`, `fp4quant_cuda` | FP4 attention + quantization ("attn_qat_infer", modified SageAttention3) | `attn_qat_infer/` | consumer/workstation Blackwell `sm_120a` (CUDA ≥ 12.8) + `sm_121a` (DGX Spark GB10; CUDA ≥ 13) | `FASTVIDEO_KERNEL_BUILD_ATTN_QAT_INFER` (AUTO = ON iff `12.0a` or `12.1a` is in the arch list) |
@@ -46,7 +46,7 @@ Notes:
## Installation
### Standard Installation (Local Development)
This will automatically detect your GPU architecture. If an NVIDIA Hopper (H100/sm_90a) GPU is detected, ThunderKittens kernels will be enabled. Otherwise, they will be skipped, and the package will use Triton fallbacks at runtime.
This detects the GPU architecture and enables ThunderKittens kernels for Hopper (H100/sm_90a) on supported hosts. On aarch64, TK is unavailable and the package uses Triton fallbacks. For a compiled-extension build, set `CMAKE_ARGS="-DFASTVIDEO_KERNEL_BUILD_TK=ON"` to require TK; the build fails on aarch64 instead of silently omitting it.
Before installation, set CUDA toolchain paths:
+4 -6
View File
@@ -80,7 +80,7 @@ extension_mode_from_cmake_args() {
&& printf '%s' "${BASH_REMATCH[2]^^}"
}
if (( PYTHON_ONLY )); then
_explicit_mode="$(extension_mode_from_cmake_args)"
_explicit_mode="$(extension_mode_from_cmake_args || true)"
if [[ -n "${_explicit_mode}" && "${_explicit_mode}" != "OFF" ]]; then
echo "ERROR: --python-only conflicts with CMAKE_ARGS -DFASTVIDEO_KERNEL_BUILD_EXTENSION=${_explicit_mode}" >&2
exit 2
@@ -210,11 +210,9 @@ if [ "${GPU_BACKEND}" = "CUDA" ] && ! (( PYTHON_ONLY )); then
fi
if ! has_cmake_arg "FASTVIDEO_KERNEL_BUILD_TK"; then
if [ "${cc_major}" = "9" ] && [ "${cc_minor}" = "0" ]; then
CMAKE_ARGS="${CMAKE_ARGS:-} -DFASTVIDEO_KERNEL_BUILD_TK=ON"
else
CMAKE_ARGS="${CMAKE_ARGS:-} -DFASTVIDEO_KERNEL_BUILD_TK=OFF"
fi
# Let CMake detect both the target GPU arch and host support. Explicit ON
# remains a request to fail if TK cannot be built on the host.
CMAKE_ARGS="${CMAKE_ARGS:-} -DFASTVIDEO_KERNEL_BUILD_TK=AUTO"
fi
fi
+49 -2
View File
@@ -14,7 +14,13 @@ def _write_executable(path: Path, contents: str) -> None:
path.chmod(0o755)
def _run_build(tmp_path: Path, capability: str, cuda_release: str) -> tuple[subprocess.CompletedProcess[str], Path]:
def _run_build(
tmp_path: Path,
capability: str,
cuda_release: str,
cmake_args: str | None = None,
extra_args: list[str] | None = None,
) -> tuple[subprocess.CompletedProcess[str], Path]:
kernel_dir = tmp_path / "fastvideo-kernel"
kernel_dir.mkdir()
shutil.copy2(BUILD_SCRIPT, kernel_dir / "build.sh")
@@ -46,8 +52,10 @@ def _run_build(tmp_path: Path, capability: str, cuda_release: str) -> tuple[subp
"FV_TEST_RESULT": str(result_dir),
"PATH": f"{fake_bin}{os.pathsep}{env['PATH']}",
})
if cmake_args is not None:
env["CMAKE_ARGS"] = cmake_args
result = subprocess.run(
["bash", "build.sh"],
["bash", "build.sh", *(extra_args or [])],
cwd=kernel_dir,
env=env,
capture_output=True,
@@ -82,3 +90,42 @@ def test_build_script_rejects_sm121_before_cmake_on_cuda_12(tmp_path: Path) -> N
assert result.returncode != 0
assert "sm_121a requires CUDA Toolkit 13.0+" in result.stderr
@pytest.mark.parametrize("capability", ["9.0", "10.3"])
def test_build_script_defers_tk_detection_to_cmake(tmp_path: Path, capability: str) -> None:
result, result_dir = _run_build(tmp_path, capability, "13.0")
assert result.returncode == 0, result.stderr
assert "-DFASTVIDEO_KERNEL_BUILD_TK=AUTO" in (result_dir / "cmake_args").read_text()
@pytest.mark.parametrize("option", ["ON", "OFF"])
def test_build_script_preserves_explicit_tk_option(tmp_path: Path, option: str) -> None:
result, result_dir = _run_build(tmp_path, "9.0", "13.0", f"-DFASTVIDEO_KERNEL_BUILD_TK={option}")
assert result.returncode == 0, result.stderr
assert (result_dir / "cmake_args").read_text().count("-DFASTVIDEO_KERNEL_BUILD_TK=") == 1
assert f"-DFASTVIDEO_KERNEL_BUILD_TK={option}" in (result_dir / "cmake_args").read_text()
@pytest.mark.parametrize("cmake_args", [None, "-DOTHER_OPTION=ON", "-DFASTVIDEO_KERNEL_BUILD_EXTENSION=OFF"])
@pytest.mark.parametrize("build_args", [["--python-only"], ["--rocm", "--python-only"]])
def test_python_only_build_defaults_extension_to_off(
tmp_path: Path, cmake_args: str | None, build_args: list[str]
) -> None:
result, result_dir = _run_build(tmp_path, "9.0", "13.0", cmake_args, build_args)
assert result.returncode == 0, result.stderr
assert (result_dir / "cmake_args").read_text().count("-DFASTVIDEO_KERNEL_BUILD_EXTENSION=OFF") == 1
if cmake_args == "-DOTHER_OPTION=ON":
assert cmake_args in (result_dir / "cmake_args").read_text()
if "--rocm" in build_args:
assert "-DGPU_BACKEND=ROCM" in (result_dir / "cmake_args").read_text()
def test_python_only_build_rejects_extension_on(tmp_path: Path) -> None:
result, _ = _run_build(tmp_path, "9.0", "13.0", "-DFASTVIDEO_KERNEL_BUILD_EXTENSION=ON", ["--python-only"])
assert result.returncode != 0
assert "--python-only conflicts" in result.stderr
+10 -3
View File
@@ -99,7 +99,8 @@ def warmup_sequence_parallel_communication(device: torch.device | None = None) -
trigger the initialization upfront, before the first real forward pass.
Args:
device: Device to use for warmup tensors. If None, uses CUDA device 0.
device: Device to use for warmup tensors. If None, uses the device type of the
accelerator the rank was placed on.
"""
global _sp_warmup_done
@@ -114,8 +115,14 @@ def warmup_sequence_parallel_communication(device: torch.device | None = None) -
_sp_warmup_done = True
return
from fastvideo.platforms import current_platform
device_module = current_platform.get_torch_device()
if device is None:
device = torch.device("cuda")
# The warmup tensors go through the SP collectives, so they have to live on the
# accelerator this rank runs on: a "cuda" default is not a valid device on a build
# without CUDA (Ascend NPU, MPS, ...) and the warmup would fail to allocate.
device = torch.device(current_platform.device_type)
logger.info("Warming up sequence parallel communication (SP=%d)...", sp_world_size)
@@ -149,7 +156,7 @@ def warmup_sequence_parallel_communication(device: torch.device | None = None) -
_ = sequence_model_parallel_all_gather(dummy3, dim=2)
# Synchronize to ensure warmup completes
torch.cuda.synchronize(device)
device_module.synchronize(device)
# Clean up
del dummy, dummy2, dummy3
+30 -12
View File
@@ -22,6 +22,7 @@ import re
import time
import uuid
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
@@ -62,6 +63,7 @@ class RequestFuncOutput:
start_time: float = 0.0
response_body: dict[str, Any] = field(default_factory=dict)
peak_memory_mb: float = 0.0
queue_wait: float = 0.0
class BaseDataset(ABC):
@@ -561,12 +563,31 @@ async def async_request_video_sglang(
return output
async def limited_request(
request_func: Callable[[RequestFuncInput, aiohttp.ClientSession, tqdm | None], Awaitable[RequestFuncOutput]],
req: RequestFuncInput,
session: aiohttp.ClientSession,
pbar: tqdm | None,
semaphore: asyncio.Semaphore | None,
arrival_time: float,
) -> RequestFuncOutput:
"""Send `req` once a concurrency slot is free; `queue_wait` counts from `arrival_time`."""
if semaphore is None:
output = await request_func(req, session, pbar)
else:
async with semaphore:
output = await request_func(req, session, pbar)
output.queue_wait = output.start_time - arrival_time
return output
def calculate_metrics(outputs: list[RequestFuncOutput], total_duration: float):
success_outputs = [o for o in outputs if o.success]
error_outputs = [o for o in outputs if not o.success]
num_success = len(success_outputs)
latencies = [o.latency for o in success_outputs]
e2e_latencies = [o.queue_wait + o.latency for o in success_outputs]
peak_memories = [o.peak_memory_mb for o in success_outputs if o.peak_memory_mb is not None and o.peak_memory_mb > 0]
metrics = {
@@ -578,6 +599,9 @@ def calculate_metrics(outputs: list[RequestFuncOutput], total_duration: float):
"latency_median": np.median(latencies) if latencies else 0,
"latency_p99": np.percentile(latencies, 99) if latencies else 0,
"latency_p50": np.percentile(latencies, 50) if latencies else 0,
"e2e_latency_mean": np.mean(e2e_latencies) if e2e_latencies else 0,
"e2e_latency_median": np.median(e2e_latencies) if e2e_latencies else 0,
"e2e_latency_p99": np.percentile(e2e_latencies, 99) if e2e_latencies else 0,
"peak_memory_mb_max": max(peak_memories) if peak_memories else 0,
"peak_memory_mb_mean": np.mean(peak_memories) if peak_memories else 0,
"peak_memory_mb_median": np.median(peak_memories) if peak_memories else 0,
@@ -671,17 +695,6 @@ async def benchmark(args: argparse.Namespace) -> None:
# Limit concurrency
semaphore = asyncio.Semaphore(args.max_concurrency) if args.max_concurrency is not None else None
async def limited_request_func(
req: RequestFuncInput,
session: aiohttp.ClientSession,
pbar: tqdm | None,
) -> RequestFuncOutput:
if semaphore:
async with semaphore:
return await request_func(req, session, pbar)
else:
return await request_func(req, session, pbar)
# Run benchmark
pbar = tqdm(total=len(requests_list), disable=args.disable_tqdm)
@@ -689,12 +702,14 @@ async def benchmark(args: argparse.Namespace) -> None:
start_time = time.perf_counter()
tasks = []
for req in requests_list:
arrival_time = start_time
if args.request_rate != float("inf"):
# Poisson process: inter-arrival times follow exponential distribution
interval = np.random.exponential(1.0 / args.request_rate)
await asyncio.sleep(interval)
arrival_time = time.perf_counter()
task = asyncio.create_task(limited_request_func(req, session, pbar))
task = asyncio.create_task(limited_request(request_func, req, session, pbar, semaphore, arrival_time))
tasks.append(task)
outputs = await asyncio.gather(*tasks)
@@ -729,6 +744,9 @@ async def benchmark(args: argparse.Namespace) -> None:
print("{:<40} {:<15.4f}".format("Latency Mean (s):", metrics["latency_mean"]))
print("{:<40} {:<15.4f}".format("Latency Median (s):", metrics["latency_median"]))
print("{:<40} {:<15.4f}".format("Latency P99 (s):", metrics["latency_p99"]))
print("{:<40} {:<15.4f}".format("E2E Latency Mean, incl. queue (s):", metrics["e2e_latency_mean"]))
print("{:<40} {:<15.4f}".format("E2E Latency Median, incl. queue (s):", metrics["e2e_latency_median"]))
print("{:<40} {:<15.4f}".format("E2E Latency P99, incl. queue (s):", metrics["e2e_latency_p99"]))
if metrics["peak_memory_mb_max"] > 0:
print(f"{'-' * 50}")
+3
View File
@@ -131,6 +131,9 @@ class VideoGenerationRequest(BaseModel):
# SGLang's legacy direct-video spellings.
video_path: str | None = None
video_url: str | None = None
# MiniMax-H3 Ref2VA: the last 5, 22 or 39 frames of an earlier video (URL or local path) that this request
# continues; see MiniMaxH3Reference(media_type="continuation").
continuation_video: str | None = None
video_params: VideoParams | None = None
user: str | None = None
task: str | None = None
@@ -261,6 +261,8 @@ async def prepare_reference_media(
request.video_path = await materialize_clip(request.video_path, "video_path", "video")
if request.video_url:
request.video_url = await materialize_clip(request.video_url, "video_url", "video")
if request.continuation_video:
request.continuation_video = await materialize_clip(request.continuation_video, "continuation", "video")
for index, reference in enumerate(_as_list(request.audio_reference)):
reference.audio_url = await materialize_clip(reference.audio_url, f"audio_{index}", "audio")
except BaseException:
@@ -420,6 +422,8 @@ def _apply_reference_inputs(
references = [MiniMaxH3Reference(source=source, media_type="image") for source in images]
references.extend(MiniMaxH3Reference(source=source, media_type="video") for source in videos)
references.extend(MiniMaxH3Reference(source=source, media_type="audio") for source in audios)
if request.continuation_video:
references.append(MiniMaxH3Reference(source=request.continuation_video, media_type="continuation"))
if references:
try:
kwargs["references"] = validate_references(references)
@@ -429,6 +433,9 @@ 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 request.continuation_video:
raise RequestAdaptationError("continuation_video requires a MiniMax-H3 server configured with "
"override_pipeline_cls_name=MiniMaxH3Ref2VAModularPipeline.")
if model_family == OPEN_H3_REGENERATE_2K_FAMILY:
if len(videos) != 1 or images or audios:
+44 -26
View File
@@ -637,6 +637,7 @@ class VideoGenerator:
prompt_embeds = kwargs.pop("prompt_embeds", None)
sampling_param.update(kwargs)
kwargs["_extra_overrides"] = extra_overrides
save_to_disk = sampling_param.save_video and fastvideo_args.output_type != "latent"
prompt_txt_path = sampling_param.prompt_path or fastvideo_args.prompt_txt
if self.executor.uses_spmd_execution:
@@ -667,7 +668,11 @@ class VideoGenerator:
try:
# Generate video for this prompt using the same logic below
output_path = self._run_on_output_rank(
partial(self._prepare_output_path, sampling_param.output_path, batch_prompt),
partial(self._prepare_output_path,
sampling_param.output_path,
batch_prompt,
output_video_name=sampling_param.output_video_name,
save_to_disk=save_to_disk),
description="output-path setup",
)
kwargs["output_path"] = output_path
@@ -712,8 +717,10 @@ class VideoGenerator:
else:
raise ValueError("Either prompt or prompt_txt must be provided")
output_path = self._run_on_output_rank(
lambda: self._prepare_output_path(sampling_param.output_path, output_name_hint
if output_name_hint else prompt),
lambda: self._prepare_output_path(sampling_param.output_path,
output_name_hint if output_name_hint else prompt,
output_video_name=sampling_param.output_video_name,
save_to_disk=save_to_disk),
description="output-path setup",
)
kwargs["output_path"] = output_path
@@ -744,26 +751,23 @@ class VideoGenerator:
self,
output_path: str,
prompt: str,
output_video_name: str | None = None,
*,
save_to_disk: bool = True,
) -> str:
"""Build a unique, sanitized output file path.
if not save_to_disk:
return output_path
Image workloads preserve explicit PNG, JPEG, or WebP extensions and
otherwise default to ``.png``. Video workloads use ``.mp4``.
- If ``output_path`` already carries the correct extension, treat it
as a file path.
- Otherwise, treat ``output_path`` as a directory and derive the
filename from the prompt.
- Invalid filename characters are removed; if the name changes, a
warning is logged.
- If the target path already exists, a numeric suffix is appended.
"""
base_path, extension = os.path.splitext(output_path)
extension_lower = extension.lower()
is_directory = os.path.isdir(output_path)
if self._is_image_workload():
target_ext = extension_lower if not is_directory and extension_lower in {".png", ".jpg", ".jpeg", ".webp"
} else ".png"
image_extensions = {".png", ".jpg", ".jpeg", ".webp"}
name_extension = os.path.splitext(output_video_name or "")[1].lower()
if not is_directory and extension_lower in image_extensions:
target_ext = extension_lower
else:
target_ext = name_extension if name_extension in image_extensions else ".png"
elif self._is_audio_workload():
target_ext = ".wav"
else:
@@ -801,20 +805,34 @@ class VideoGenerator:
target_ext,
)
output_dir = output_path
prompt_component = _sanitize_filename_component(prompt[:100])
out_name = f"{prompt_component}{target_ext}"
name_component = output_video_name or prompt[:100]
if output_video_name and name_component.lower().endswith(target_ext):
name_component = name_component[:-len(target_ext)]
sanitized_name = _sanitize_filename_component(name_component)
out_name = f"{sanitized_name}{target_ext}"
if output_dir:
os.makedirs(output_dir, exist_ok=True)
new_output_path = os.path.join(output_dir, out_name)
counter = 1
while os.path.exists(new_output_path):
name_part, ext_part = os.path.splitext(out_name)
new_name = f"{name_part}_{counter}{ext_part}"
new_output_path = os.path.join(output_dir, new_name)
try:
name_max = os.pathconf(output_dir or ".", "PC_NAME_MAX")
except (AttributeError, OSError, ValueError):
name_max = 255
if name_max <= 0:
name_max = 255
name_part, ext_part = os.path.splitext(out_name)
candidate_name = out_name
counter = 0
while True:
if len(os.fsencode(candidate_name)) > name_max:
raise ValueError(f"Output filename exceeds the {name_max}-byte filesystem limit "
"(including extension and collision suffix). Use a shorter output name.")
new_output_path = os.path.join(output_dir, candidate_name)
if not os.path.exists(new_output_path):
return new_output_path
counter += 1
return new_output_path
candidate_name = f"{name_part}_{counter}{ext_part}"
def _generate_single_video(
self,
@@ -93,7 +93,7 @@ class MiniMaxH3PackedLayout:
# Per-reference conditioning spans in packed order (Ref2VA only; empty
# elsewhere): (kind, row_count, latent_shape) covering exactly the rows
# between the text tokens and the target audio. kind is "audio" / "image"
# / "video"; latent_shape is the reference's raw latent (frames, height,
# / "video" / "continuation"; latent_shape is the reference's raw latent (frames, height,
# width) for visual kinds and (0, 0, 0) for audio rows. A video reference
# with sound contributes two entries — its audio rows precede its video
# rows. Retained so VSA-H3 can 3-D-tile (and sparsify) reference-video
@@ -167,6 +167,15 @@ class MiniMaxH3PackedBatchLayout:
raise ValueError("H3 packed-batch token_tags must cover the complete sequence")
@dataclass(frozen=True)
class MiniMaxH3ContinuationLayout:
"""Latent geometry of a continuation clip (``reference.MiniMaxH3PreparedContinuation``)."""
num_latent_frames: int
# 0 for a clip without a soundtrack.
num_audio_latents: int
def _validate_packed_rows(layout: MiniMaxH3PackedLayout | MiniMaxH3PackedBatchLayout) -> None:
if type(layout.pad_tokens) is not int or layout.pad_tokens < 0:
raise ValueError("pad_tokens must be a non-negative integer")
@@ -510,8 +519,15 @@ def build_ref2va_packed_sequence(
latent_width: int,
num_audio_latents: int,
patch_size: tuple[int, int, int],
continuation: MiniMaxH3ContinuationLayout | None = None,
) -> MiniMaxH3PackedLayout:
"""Build `[text | ordered references | target audio | target video]`."""
"""Build `[text | ordered references | continuation clip | target audio | target video]`.
A continuation clip follows the references and advances the rotary clock as a video reference with its
soundtrack would; its video rows then take the coordinates of the target's first latent frames and its audio rows
those of the target's first audio latents, so the clip conditions the start of the target in place (the
reference context of ComfyUI-H3-Continuum). VSA-H3 keeps its rows dense.
"""
if patch_size != (1, 2, 2):
raise ValueError(f"MiniMax-H3 Ref2VA requires patch_size=(1, 2, 2), got {patch_size}.")
if text_token_tags.ndim != 1:
@@ -544,6 +560,13 @@ def build_ref2va_packed_sequence(
num_reference_video_rows = sum(visual_row_counts)
num_reference_audio_rows = sum(reference.num_audio_latents * MINIMAX_H3_AUDIO_CHANNELS for reference in references
if reference.has_audio)
rows_per_target_frame = (latent_height // patch_h) * (latent_width // patch_w)
if continuation is not None:
if not 0 < continuation.num_latent_frames < num_latent_frames or continuation.num_audio_latents < 0:
raise ValueError(f"Continuation clip {continuation} does not fit a target of {num_latent_frames} "
"latent frames.")
num_reference_video_rows += continuation.num_latent_frames * rows_per_target_frame
num_reference_audio_rows += continuation.num_audio_latents * MINIMAX_H3_AUDIO_CHANNELS
sequence_length = (num_text_tokens + num_reference_video_rows + num_reference_audio_rows + num_target_audio_rows +
num_target_video_rows)
@@ -617,6 +640,29 @@ def build_ref2va_packed_sequence(
else:
raise ValueError(f"Unsupported prepared reference type: {reference.media_type!r}.")
if continuation is not None:
audio_count = continuation.num_audio_latents * MINIMAX_H3_AUDIO_CHANNELS
visual_row_count = continuation.num_latent_frames * rows_per_target_frame
if audio_count:
reference_segments.append(("audio", audio_count, (0, 0, 0)))
# Not "video": _h3_vsa_ref2va_segments keeps every other span dense.
reference_segments.append(
("continuation", visual_row_count, (continuation.num_latent_frames, latent_height, latent_width)))
audio_rows = slice(cursor, cursor + audio_count)
video_rows = slice(audio_rows.stop, audio_rows.stop + visual_row_count)
cursor = video_rows.stop
if audio_count:
audio_indices.append(torch.arange(audio_rows.start, audio_rows.stop))
video_indices.append(torch.arange(video_rows.start, video_rows.stop))
rotary_time += max(float(continuation.num_audio_latents),
_reference_temporal_span(continuation.num_latent_frames))
frame_time = temporal_position_grid(continuation.num_latent_frames, rotary_time)
position_ids[video_rows, 0] = frame_time.repeat_interleave(rows_per_target_frame)
position_ids[video_rows, 1:] = target_frame_grid.repeat(continuation.num_latent_frames // patch_t, 1)
if audio_count:
_fill_audio_positions(position_ids, audio_rows, continuation.num_audio_latents, rotary_time,
target_width_grid)
audio_start = cursor
video_start = audio_start + num_target_audio_rows
_fill_audio_positions(position_ids, slice(audio_start, video_start), num_audio_latents, rotary_time,
@@ -34,14 +34,24 @@ MINIMAX_H3_MAX_REFERENCE_IMAGES = 9
MINIMAX_H3_MAX_REFERENCE_VIDEOS = 3
MINIMAX_H3_MAX_REFERENCE_AUDIOS = 3
MINIMAX_H3_MAX_REFERENCES = 12
# A continuation clip ends on a causal-VAE chunk boundary: 2, 7 or 12 latent frames.
MINIMAX_H3_CONTINUATION_FRAMES = (5, 22, 39)
# ForwardBatch.extra key for the decoded continuation clip, set by input preparation.
MINIMAX_H3_PREPARED_CONTINUATION_KEY = "minimax_h3_prepared_continuation"
@dataclass(frozen=True)
class MiniMaxH3Reference:
"""One ordered Ref2VA input; decoding is deferred until request preparation."""
"""One ordered Ref2VA input; decoding is deferred until request preparation.
``media_type="continuation"`` is not a reference: it is the end of an earlier video (5, 22 or 39 frames, on the
target canvas) that the request continues. The generated video starts by regenerating those frames, at the same
positions, so a caller joins the two by dropping them. At most one per request; it is not presented to the text
encoder and does not count toward the reference limits.
"""
source: Any
media_type: Literal["image", "video", "audio"] = "image"
media_type: Literal["image", "video", "audio", "continuation"] = "image"
soundtrack: Any | None = None
fps: float | None = None
sample_rate: int | None = None
@@ -49,13 +59,13 @@ class MiniMaxH3Reference:
def __post_init__(self) -> None:
if self.source is None:
raise ValueError("A MiniMax-H3 reference requires a media source.")
if self.media_type not in ("image", "video", "audio"):
if self.media_type not in ("image", "video", "audio", "continuation"):
raise ValueError(f"Unsupported MiniMax-H3 reference type: {self.media_type!r}.")
if self.soundtrack is not None and self.media_type != "video":
raise ValueError("Only a video reference may carry a separate soundtrack.")
if self.fps is not None and (self.media_type != "video" or isinstance(self.fps, bool)
if self.soundtrack is not None and self.media_type not in ("video", "continuation"):
raise ValueError("Only a video reference or a continuation clip may carry a separate soundtrack.")
if self.fps is not None and (self.media_type not in ("video", "continuation") or isinstance(self.fps, bool)
or not isinstance(self.fps, Real) or self.fps <= 0):
raise ValueError("Reference `fps` must be positive and is only valid for video.")
raise ValueError("Reference `fps` must be positive and is only valid for video and continuation clips.")
if self.sample_rate is not None and (self.media_type == "image" or isinstance(self.sample_rate, bool)
or not isinstance(self.sample_rate, Integral) or self.sample_rate <= 0):
raise ValueError("Reference `sample_rate` must be positive and is only valid for audio-bearing media.")
@@ -99,8 +109,13 @@ def validate_references(references: list[Any]) -> list[MiniMaxH3Reference]:
raise ValueError("MiniMax-H3 Ref2VA requires at least one reference.")
typed = [coerce_reference(reference) for reference in references]
if sum(reference.media_type == "continuation" for reference in typed) > 1:
raise ValueError("MiniMax-H3 accepts at most one continuation clip.")
typed_references = [reference for reference in typed if reference.media_type != "continuation"]
if not typed_references:
raise ValueError("A MiniMax-H3 continuation needs at least one reference besides its clip.")
counts = {
media_type: sum(reference.media_type == media_type for reference in typed)
media_type: sum(reference.media_type == media_type for reference in typed_references)
for media_type in ("image", "video", "audio")
}
limits = {
@@ -111,9 +126,9 @@ def validate_references(references: list[Any]) -> list[MiniMaxH3Reference]:
for media_type, limit in limits.items():
if counts[media_type] > limit:
raise ValueError(f"MiniMax-H3 accepts at most {limit} {media_type} references.")
if len(typed) > MINIMAX_H3_MAX_REFERENCES:
if len(typed_references) > MINIMAX_H3_MAX_REFERENCES:
raise ValueError(f"MiniMax-H3 accepts at most {MINIMAX_H3_MAX_REFERENCES} references.")
if counts["audio"] == len(typed):
if counts["audio"] == len(typed_references):
raise ValueError("Audio references must be paired with at least one image or video reference.")
return typed
@@ -350,6 +365,8 @@ def prepare_reference(
edge and reference-video canvas rule. MiniMax-H3's released values are the
defaults; Pika H3 Flex passes its 480p/768p tier values.
"""
if reference.media_type == "continuation":
raise ValueError("A continuation clip is decoded by prepare_continuation, not as a reference.")
prepared = MiniMaxH3PreparedReference(media_type=reference.media_type)
if reference.media_type == "image":
source = reference.source
@@ -411,19 +428,68 @@ def prepare_reference(
return prepared
@dataclass
class MiniMaxH3PreparedContinuation:
"""A decoded continuation clip: 24-fps RGB frames on the target canvas, and its soundtrack if it has one."""
frames: np.ndarray
waveform: torch.Tensor | None = None
def prepare_continuation(
reference: MiniMaxH3Reference,
height: int,
width: int,
target_sample_rate: int,
) -> MiniMaxH3PreparedContinuation:
"""Decode a continuation clip, resampled to 24 fps; its frames and canvas must match the contract.
Like a video reference, the clip is a path (its own soundtrack, if any, is used) or in-memory frames, and
``soundtrack``/``fps``/``sample_rate`` on the reference override what the file carries.
"""
sound: torch.Tensor | None = None
sound_rate: int | None = None
if isinstance(reference.source, str | os.PathLike):
frames, fps, decoded = decode_reference_video(reference.source)
if decoded is not None:
sound, sound_rate = decoded
else:
frames, fps = reference_media_to_uint8(reference.source), float(MINIMAX_H3_FPS)
if reference.soundtrack is not None:
sound, sound_rate = _resolve_audio_source(reference.soundtrack)
frames = resample_reference_frames(frames, float(reference.fps if reference.fps is not None else fps))
if frames.shape[0] not in MINIMAX_H3_CONTINUATION_FRAMES:
raise ValueError(f"A continuation clip must have {', '.join(map(str, MINIMAX_H3_CONTINUATION_FRAMES))} frames "
f"at {MINIMAX_H3_FPS} fps, got {frames.shape[0]}.")
if frames.shape[1:3] != (height, width):
raise ValueError(f"A continuation clip must be on the target canvas {width}x{height}, "
f"got {frames.shape[2]}x{frames.shape[1]}.")
waveform = None
if sound is not None:
waveform = prepare_reference_waveform(sound,
int(reference.sample_rate or sound_rate or target_sample_rate),
target_sample_rate,
max_duration=frames.shape[0] / MINIMAX_H3_FPS)
return MiniMaxH3PreparedContinuation(frames=frames, waveform=waveform)
__all__ = [
"MINIMAX_H3_MAX_REFERENCES",
"MINIMAX_H3_MAX_REFERENCE_AUDIOS",
"MINIMAX_H3_MAX_REFERENCE_IMAGES",
"MINIMAX_H3_MAX_REFERENCE_VIDEOS",
"MINIMAX_H3_CONTINUATION_FRAMES",
"MINIMAX_H3_PREPARED_CONTINUATION_KEY",
"MINIMAX_H3_QWEN_TEMPORAL_PATCH",
"MINIMAX_H3_QWEN_VIDEO_SAMPLE_FPS",
"MINIMAX_H3_REFERENCE_IMAGE_SHORT_EDGE",
"coerce_reference",
"MiniMaxH3PreparedContinuation",
"MiniMaxH3PreparedReference",
"MiniMaxH3Reference",
"decode_reference_audio",
"decode_reference_video",
"prepare_continuation",
"prepare_reference",
"prepare_reference_frames",
"prepare_reference_image",
@@ -25,6 +25,8 @@ from fastvideo.pipelines.basic.minimax_h3.packing import (
from fastvideo.pipelines.basic.minimax_h3.reference import (
MiniMaxH3PreparedReference,
prepare_reference,
MINIMAX_H3_PREPARED_CONTINUATION_KEY,
prepare_continuation,
validate_references,
)
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
@@ -202,8 +204,19 @@ class MiniMaxH3InputPreparationStage(PipelineStage):
height, width, ratio = resolve_target_canvas(batch, self.vae, (16, 9))
num_frames = resolve_target_num_frames(batch.num_frames)
target_sample_rate = int(self.audio_vae.sampling_rate)
batch.references = [prepare_reference(reference, num_frames, target_sample_rate) for reference in references]
clips = [reference for reference in references if reference.media_type == "continuation"]
batch.references = [
prepare_reference(reference, num_frames, target_sample_rate) for reference in references
if reference.media_type != "continuation"
]
self._write_target_geometry(batch, height, width, ratio, num_frames)
batch.extra.pop(MINIMAX_H3_PREPARED_CONTINUATION_KEY, None)
if clips:
continuation = prepare_continuation(clips[0], height, width, target_sample_rate)
if continuation.frames.shape[0] >= num_frames:
raise ValueError(f"A {continuation.frames.shape[0]}-frame continuation clip leaves no new frames in "
f"{num_frames}.")
batch.extra[MINIMAX_H3_PREPARED_CONTINUATION_KEY] = continuation
batch.extra[MINIMAX_H3_KEYFRAMES_KEY] = []
batch.extra[MINIMAX_H3_KEYFRAME_ANCHORS_KEY] = ()
@@ -19,6 +19,7 @@ from fastvideo.pipelines.basic.minimax_h3.packing import (
MINIMAX_H3_AUDIO_CHANNELS,
MINIMAX_H3_KEYFRAME_ENCODE_SEED,
MINIMAX_H3_KEYFRAME_NOISE_AUG,
MiniMaxH3ContinuationLayout,
MiniMaxH3PackedLayout,
audio_latent_num_frames,
build_packed_sequence,
@@ -29,6 +30,8 @@ from fastvideo.pipelines.basic.minimax_h3.packing import (
patchify_video_latents,
)
from fastvideo.pipelines.basic.minimax_h3.reference import (
MINIMAX_H3_PREPARED_CONTINUATION_KEY,
MiniMaxH3PreparedContinuation,
MiniMaxH3PreparedReference,
trim_reference_num_frames,
)
@@ -45,6 +48,7 @@ from fastvideo.pipelines.stages.validators import VerificationResult
logger = init_logger(__name__)
MINIMAX_H3_LAYOUT_KEY = "minimax_h3_layout"
_CONTINUATION_LAYOUT_KEY = "minimax_h3_continuation_layout"
def _video_geometry(batch: ForwardBatch) -> tuple[int, int, int, int]:
@@ -169,6 +173,19 @@ class MiniMaxH3LatentPreparationStage(PipelineStage):
rows.append(patchify_video_latents(latents, patch_size))
return rows
def _encode_continuation_video(self, continuation: MiniMaxH3PreparedContinuation) -> torch.Tensor:
"""The clip's normalized video latents, sampled as a reference video's are."""
pixels = torch.from_numpy(np.ascontiguousarray(continuation.frames)).permute(3, 0, 1, 2)[None]
posterior = self.vae.encode_pixels(pixels).latent_dist
return self.vae.normalize_latents(_sample_visual_posterior(posterior).to(torch.float16).float()).cpu()
def _encode_continuation_audio(self, continuation: MiniMaxH3PreparedContinuation,
device: torch.device) -> torch.Tensor:
"""The clip soundtrack's normalized latents as channel-major rows, encoded as a reference track is."""
posterior = self.audio_vae.encode(continuation.waveform.to(device)[:, None]).latent_dist
latents = self.audio_vae.normalize_latents(posterior.mode().float()).cpu().transpose(1, 2)
return latents.reshape(-1, self.audio_vae.latent_channels)
def _encode_audio_rows(
self,
references: list[MiniMaxH3PreparedReference],
@@ -243,20 +260,27 @@ class MiniMaxH3LatentPreparationStage(PipelineStage):
if not references or not all(isinstance(item, MiniMaxH3PreparedReference) for item in references):
raise TypeError("MiniMax-H3 Ref2VA latent preparation requires prepared references.")
continuation = batch.extra.pop(MINIMAX_H3_PREPARED_CONTINUATION_KEY, None)
continuation_video = continuation_audio = None
vae_device = get_local_torch_device()
pinned_offload.load(self.vae, vae_device, pin=fastvideo_args.pin_cpu_memory)
try:
video_rows = self._encode_visual_rows(references, vae_device, fastvideo_args)
if continuation is not None:
continuation_video = self._encode_continuation_video(continuation)
finally:
if fastvideo_args.vae_cpu_offload:
pinned_offload.unload(self.vae)
audio_rows: list[torch.Tensor] = []
if any(reference.has_audio for reference in references):
has_continuation_audio = continuation is not None and continuation.waveform is not None
if any(reference.has_audio for reference in references) or has_continuation_audio:
audio_device = get_local_torch_device()
pinned_offload.load(self.audio_vae, audio_device, pin=fastvideo_args.pin_cpu_memory)
try:
audio_rows = self._encode_audio_rows(references, audio_device)
if has_continuation_audio:
continuation_audio = self._encode_continuation_audio(continuation, audio_device)
finally:
if fastvideo_args.vae_cpu_offload:
pinned_offload.unload(self.audio_vae)
@@ -277,6 +301,31 @@ class MiniMaxH3LatentPreparationStage(PipelineStage):
MINIMAX_H3_KEYFRAME_NOISE_AUG,
noise,
)
batch.extra.pop(_CONTINUATION_LAYOUT_KEY, None)
if continuation_video is not None:
# The clip's rows follow the references' and carry the same noise augmentation, drawn from a generator of
# its own so the target noise does not depend on whether a clip is present.
patch_size = h3_dit_patch_size(fastvideo_args)
clip_noise = keyframe_condition_noise(
(tuple(int(size) for size in continuation_video.shape[2:]), ),
patch_size,
_video_latent_channels(fastvideo_args),
generator=torch.Generator("cpu").manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED + 1),
device=device,
)
clip_rows = self.scheduler.scale_noise(
patchify_video_latents(continuation_video, patch_size).to(device),
MINIMAX_H3_KEYFRAME_NOISE_AUG,
clip_noise,
)
video_conditions = torch.cat((video_conditions, clip_rows))
if continuation_audio is not None:
audio_rows.append(continuation_audio)
clip_audio_latents = (0 if continuation_audio is None else continuation_audio.shape[0] //
MINIMAX_H3_AUDIO_CHANNELS)
batch.extra[_CONTINUATION_LAYOUT_KEY] = MiniMaxH3ContinuationLayout(num_latent_frames=int(
continuation_video.shape[2]),
num_audio_latents=clip_audio_latents)
audio_conditions = torch.cat(audio_rows).to(device) if audio_rows else None
for reference in references:
@@ -303,6 +352,7 @@ class MiniMaxH3LatentPreparationStage(PipelineStage):
width,
num_audio_latents,
h3_dit_patch_size(fastvideo_args),
continuation=batch.extra.pop(_CONTINUATION_LAYOUT_KEY, None),
)
anchors = batch.extra.get(MINIMAX_H3_KEYFRAME_ANCHORS_KEY, ())
if not isinstance(anchors, tuple):
@@ -25,7 +25,15 @@ from fastvideo.dataset.dataloader.schema import pyarrow_schema_t2va
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.models.loader.component_loader import PipelineComponentLoader
from fastvideo.pipelines import ForwardBatch
from fastvideo.pipelines.basic.minimax_h3.packing import MINIMAX_H3_FPS
from fastvideo.pipelines.basic.minimax_h3.packing import (
MINIMAX_H3_AUDIO_LATENTS_PER_SECOND,
MINIMAX_H3_FPS,
MINIMAX_H3_FRAMES_PER_CHUNK,
MINIMAX_H3_LATENTS_PER_CHUNK,
MINIMAX_H3_MAX_ALIGNED_FRAMES,
MINIMAX_H3_MIN_ALIGNED_FRAMES,
audio_latent_num_frames,
)
from fastvideo.pipelines.basic.minimax_h3.reference import (
decode_reference_video,
prepare_reference_frames,
@@ -60,20 +68,41 @@ def _init_single_process_distributed() -> None:
maybe_init_distributed_environment_and_model_parallel(1, 1)
def load_training_media(path: Path) -> tuple[np.ndarray, torch.Tensor]:
def native_num_frames(available: int) -> int:
"""Return the longest H3-native ``17 * n + 5`` frame count within ``available``.
Counts are capped at the largest accepted bucket, so a long clip trains on
its first 15 seconds instead of failing.
"""
if available < MINIMAX_H3_MIN_ALIGNED_FRAMES:
raise ValueError(f"Training video has {available} frames at {MINIMAX_H3_FPS} FPS; "
f"at least {MINIMAX_H3_MIN_ALIGNED_FRAMES} are required")
num_frames = min(available, MINIMAX_H3_MAX_ALIGNED_FRAMES)
while num_frames % MINIMAX_H3_FRAMES_PER_CHUNK != MINIMAX_H3_LATENTS_PER_CHUNK:
num_frames -= 1
return num_frames
def load_training_media(path: Path, num_frames: int | None = NUM_FRAMES) -> tuple[np.ndarray, torch.Tensor]:
"""Decode paired targets at the geometry and rates consumed by H3.
The video uses exactly 124 frames, and the audio is capped at the duration
of those frames before variational autoencoder (VAE) encoding.
With the default ``num_frames`` the video must resolve to the fixed
124-frame 768x1344 overfit geometry. ``num_frames=None`` keeps the clip's
own canvas (``resolve_canvas_size``) and its longest native frame count.
The audio is capped at the duration of the selected frames, and at the
samples their ``audio_latent_num_frames`` cover, before VAE encoding.
"""
decoded_frames, source_fps, soundtrack = decode_reference_video(path)
resampled_frames = resample_reference_frames(decoded_frames, source_fps)
if resampled_frames.shape[0] < NUM_FRAMES:
fixed_geometry = num_frames is not None
if num_frames is None:
num_frames = native_num_frames(resampled_frames.shape[0])
if resampled_frames.shape[0] < num_frames:
raise ValueError(f"Training video {path} has {resampled_frames.shape[0]} frames at {MINIMAX_H3_FPS} FPS; "
f"{NUM_FRAMES} are required")
frames = prepare_reference_frames(resampled_frames, NUM_FRAMES)
if frames.shape != (NUM_FRAMES, VIDEO_HEIGHT, VIDEO_WIDTH, 3):
raise ValueError(f"Training video must resolve to {(NUM_FRAMES, VIDEO_HEIGHT, VIDEO_WIDTH, 3)}, "
f"{num_frames} are required")
frames = prepare_reference_frames(resampled_frames, num_frames)
if fixed_geometry and frames.shape != (num_frames, VIDEO_HEIGHT, VIDEO_WIDTH, 3):
raise ValueError(f"Training video must resolve to {(num_frames, VIDEO_HEIGHT, VIDEO_WIDTH, 3)}, "
f"got {tuple(frames.shape)}")
if soundtrack is None:
raise ValueError(f"Training video {path} requires a soundtrack")
@@ -82,8 +111,14 @@ def load_training_media(path: Path) -> tuple[np.ndarray, torch.Tensor]:
waveform,
source_sample_rate,
AUDIO_SAMPLE_RATE,
max_duration=NUM_FRAMES / MINIMAX_H3_FPS,
max_duration=num_frames / MINIMAX_H3_FPS,
)
# The audio VAE pads to whole latents, so audio past the last whole latent
# would add one more than inference generates for these frames (e.g. 264
# instead of 263 at 158 frames). The packed Ref2VA preprocessor applies the
# same cap.
samples_per_latent = AUDIO_SAMPLE_RATE // MINIMAX_H3_AUDIO_LATENTS_PER_SECOND
waveform = waveform[..., :audio_latent_num_frames(num_frames) * samples_per_latent].contiguous()
return frames, waveform
@@ -234,6 +269,9 @@ def build_parquet_record(
video_latents: torch.Tensor,
audio_latents: torch.Tensor,
text_embedding: torch.Tensor,
num_frames: int = NUM_FRAMES,
height: int = VIDEO_HEIGHT,
width: int = VIDEO_WIDTH,
) -> dict[str, Any]:
"""Serialize one synchronized H3 sample for the Parquet schema collator.
@@ -255,10 +293,10 @@ def build_parquet_record(
"file_name": file_name,
"caption": caption,
"media_type": "video_with_audio",
"width": VIDEO_WIDTH,
"height": VIDEO_HEIGHT,
"num_frames": NUM_FRAMES,
"duration_sec": NUM_FRAMES / MINIMAX_H3_FPS,
"width": width,
"height": height,
"num_frames": num_frames,
"duration_sec": num_frames / MINIMAX_H3_FPS,
"fps": float(MINIMAX_H3_FPS),
"audio_sample_rate": AUDIO_SAMPLE_RATE,
})
@@ -305,6 +305,9 @@ def build_ref2va_parquet_record(
ref_visual_anchor: torch.Tensor,
ref_audio_anchor: torch.Tensor,
references: list[MiniMaxH3PreparedReference],
num_frames: int = NUM_FRAMES,
height: int = VIDEO_HEIGHT,
width: int = VIDEO_WIDTH,
) -> dict[str, Any]:
record = build_parquet_record(
file_name=file_name,
@@ -312,6 +315,9 @@ def build_ref2va_parquet_record(
video_latents=video_latents,
audio_latents=audio_latents,
text_embedding=text_embedding,
num_frames=num_frames,
height=height,
width=width,
)
record["schema_version"] = MINIMAX_H3_REF2VA_SCHEMA_VERSION
record["text_token_tags"] = text_token_tags.to(dtype=torch.long, device="cpu").tolist()
@@ -326,8 +332,13 @@ def validate_record_contract(record: dict[str, Any]) -> None:
if missing:
raise ValueError(f"Ref2VA record is missing schema fields: {missing}")
expected_target_shapes = {
"vae_latent_shape": [24, video_latent_num_frames(NUM_FRAMES), VIDEO_HEIGHT // 16, VIDEO_WIDTH // 16],
"audio_latent_shape": [2, 32, audio_latent_num_frames(NUM_FRAMES)],
"vae_latent_shape": [
24,
video_latent_num_frames(record["num_frames"]),
record["height"] // 16,
record["width"] // 16,
],
"audio_latent_shape": [2, 32, audio_latent_num_frames(record["num_frames"])],
}
for name, expected in expected_target_shapes.items():
if record[name] != expected:
@@ -337,9 +348,12 @@ def validate_record_contract(record: dict[str, Any]) -> None:
collate_minimax_h3_ref2va_rows([record])
def _prepare_references(references: tuple[MiniMaxH3RawReference, ...], ) -> list[MiniMaxH3PreparedReference]:
def _prepare_references(
references: tuple[MiniMaxH3RawReference, ...],
num_frames: int = NUM_FRAMES,
) -> list[MiniMaxH3PreparedReference]:
raw_references = build_minimax_h3_references(references)
prepared = [prepare_reference(reference, NUM_FRAMES, AUDIO_SAMPLE_RATE) for reference in raw_references]
prepared = [prepare_reference(reference, num_frames, AUDIO_SAMPLE_RATE) for reference in raw_references]
for index, (raw, resolved) in enumerate(zip(references, prepared, strict=True)):
expected_audio = raw.media_type in {"audio", "video_audio"}
if resolved.has_audio != expected_audio:
@@ -377,8 +391,9 @@ def _build_record(
fastvideo_args: FastVideoArgs,
patch_size: tuple[int, int, int],
) -> dict[str, Any]:
target_frames, target_waveform = load_training_media(sample.target_video_path)
references = _prepare_references(sample.references)
target_frames, target_waveform = load_training_media(sample.target_video_path, num_frames=None)
num_frames, height, width = (int(value) for value in target_frames.shape[:3])
references = _prepare_references(sample.references, num_frames)
# The established single-sample helpers deliberately release each large
# component before the next one is loaded, keeping this multi-sample path
@@ -410,6 +425,9 @@ def _build_record(
ref_visual_anchor=ref_visual_anchor,
ref_audio_anchor=ref_audio_anchor,
references=references,
num_frames=num_frames,
height=height,
width=width,
)
record["id"] = sample.sample_id
validate_record_contract(record)
@@ -0,0 +1,85 @@
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import torch
import fastvideo.platforms as platforms
from fastvideo.distributed import communication_op
class _RecordingSpGroup:
def __init__(self) -> None:
self.tensors: list[torch.Tensor] = []
def all_to_all_4D(self, tensor: torch.Tensor, scatter_dim: int = 2, gather_dim: int = 1) -> torch.Tensor:
self.tensors.append(tensor)
return tensor
def all_gather(self, tensor: torch.Tensor, dim: int = -1) -> torch.Tensor:
self.tensors.append(tensor)
return tensor
class _RecordingDeviceModule:
def __init__(self) -> None:
self.synchronized: list = []
def synchronize(self, device=None) -> None:
self.synchronized.append(device)
class _RecordingPlatform:
def __init__(self, device_type: str) -> None:
self.device_type = device_type
self.device_module = _RecordingDeviceModule()
def get_torch_device(self) -> _RecordingDeviceModule:
return self.device_module
def _patch_warmup_inputs(monkeypatch, platform: _RecordingPlatform, group: _RecordingSpGroup) -> None:
# Set the lazy source, not the public attribute: a pinned current_platform
# would leak into later tests that replace _current_platform.
monkeypatch.setattr(platforms, "_current_platform", platform)
if "current_platform" in vars(platforms):
monkeypatch.delitem(vars(platforms), "current_platform")
monkeypatch.setattr(communication_op, "model_parallel_is_initialized", lambda: True)
monkeypatch.setattr(communication_op, "get_sp_world_size", lambda: 2)
monkeypatch.setattr(communication_op, "get_sp_group", lambda: group)
monkeypatch.setattr(communication_op, "_sp_warmup_done", False)
def test_warmup_uses_the_active_platform_device(monkeypatch) -> None:
platform = _RecordingPlatform(device_type="cpu")
group = _RecordingSpGroup()
_patch_warmup_inputs(monkeypatch, platform, group)
communication_op.warmup_sequence_parallel_communication()
assert group.tensors, "the warmup must exercise the SP collectives"
assert {tensor.device.type for tensor in group.tensors} == {"cpu"}
assert platform.device_module.synchronized == [torch.device("cpu")]
def test_warmup_keeps_an_explicit_device(monkeypatch) -> None:
platform = _RecordingPlatform(device_type="cuda")
group = _RecordingSpGroup()
_patch_warmup_inputs(monkeypatch, platform, group)
communication_op.warmup_sequence_parallel_communication(torch.device("cpu"))
assert {tensor.device.type for tensor in group.tensors} == {"cpu"}
assert platform.device_module.synchronized == [torch.device("cpu")]
def test_warmup_is_skipped_without_sequence_parallelism(monkeypatch) -> None:
platform = _RecordingPlatform(device_type="cpu")
group = _RecordingSpGroup()
_patch_warmup_inputs(monkeypatch, platform, group)
monkeypatch.setattr(communication_op, "get_sp_world_size", lambda: 1)
communication_op.warmup_sequence_parallel_communication()
assert group.tensors == []
assert platform.device_module.synchronized == []
@@ -0,0 +1,75 @@
# SPDX-License-Identifier: Apache-2.0
"""CPU-only contracts for how `fastvideo bench` measures request latency."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import numpy as np
import pytest
from fastvideo.entrypoints.cli.bench_serving import (
RequestFuncInput,
RequestFuncOutput,
calculate_metrics,
limited_request,
)
def _output(latency: float, queue_wait: float, success: bool = True) -> RequestFuncOutput:
return RequestFuncOutput(success=success, latency=latency, queue_wait=queue_wait)
def test_e2e_latency_includes_the_wait_for_a_concurrency_slot():
outputs = [_output(latency=1.0, queue_wait=float(k)) for k in range(5)]
outputs.append(_output(latency=0.1, queue_wait=9.0, success=False))
metrics = calculate_metrics(outputs, total_duration=5.0)
assert metrics["latency_p99"] == pytest.approx(1.0)
assert metrics["e2e_latency_mean"] == pytest.approx(3.0)
assert metrics["e2e_latency_median"] == pytest.approx(3.0)
assert metrics["e2e_latency_p99"] == pytest.approx(np.percentile([1, 2, 3, 4, 5], 99))
assert metrics["failed_requests"] == 1
def test_e2e_latency_equals_latency_without_a_wait():
outputs = [_output(latency=x, queue_wait=0.0) for x in (0.5, 1.5, 2.5)]
metrics = calculate_metrics(outputs, total_duration=3.0)
for stat in ("mean", "median", "p99"):
assert metrics[f"e2e_latency_{stat}"] == pytest.approx(metrics[f"latency_{stat}"])
def test_limited_request_counts_the_wait_from_arrival():
clock = SimpleNamespace(now=0.0)
started = {"a": asyncio.Event(), "b": asyncio.Event()}
done = {"a": asyncio.Event(), "b": asyncio.Event()}
async def fake_request(req, session, pbar):
output = RequestFuncOutput(start_time=clock.now)
started[req.prompt].set()
await done[req.prompt].wait()
output.latency = clock.now - output.start_time
output.success = True
return output
async def run() -> list:
semaphore = asyncio.Semaphore(1)
reqs = [RequestFuncInput(prompt=p, api_url="http://unused", model="m") for p in ("a", "b")]
tasks = [asyncio.create_task(limited_request(fake_request, r, None, None, semaphore, 0.0)) for r in reqs]
clock.now = 0.5
await started["a"].wait()
clock.now = 3.0
done["a"].set()
await started["b"].wait()
clock.now = 5.0
done["b"].set()
return await asyncio.gather(*tasks)
a, b = asyncio.run(run())
assert (a.queue_wait, a.latency) == (0.5, 2.5)
assert (b.queue_wait, b.latency) == (3.0, 2.0)
@@ -294,6 +294,21 @@ def test_inline_video_and_audio_references_are_decoded_in_order(tmp_path: Path)
assert not _uploads(tmp_path)
def test_inline_continuation_video_is_decoded_like_a_video_reference(tmp_path: Path) -> None:
request = VideoGenerationRequest(prompt="waves", continuation_video=_data_url("video/x-matroska", b"last-frames"))
created = asyncio.run(prepare_reference_media("request", request, str(tmp_path), policy=MediaPolicy()))
assert created == [request.continuation_video]
assert Path(request.continuation_video).read_bytes() == b"last-frames"
assert Path(request.continuation_video).suffix == ".mkv"
discard_materialized_media(created)
assert not _uploads(tmp_path)
mismatched = VideoGenerationRequest(prompt="waves", continuation_video=_data_url("audio/mpeg", b"track"))
with pytest.raises(RequestAdaptationError, match="need a data:video/"):
asyncio.run(prepare_reference_media("request", mismatched, str(tmp_path), policy=MediaPolicy()))
assert not _uploads(tmp_path)
@pytest.mark.parametrize("chunk_chars", [media_policy.DATA_URL_CHUNK_CHARS, 3, 4, 5, 7])
def test_inline_reference_payload_may_be_wrapped_and_percent_escaped(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
chunk_chars: int) -> None:
@@ -599,6 +614,41 @@ def test_fasth3_ref2va_limits_references_at_admission(tmp_path: Path) -> None:
)
def test_fasth3_ref2va_continuation_video_becomes_a_continuation_reference(tmp_path: Path) -> None:
request = VideoGenerationRequest(
prompt="a fox",
task="ref2va",
aspect_ratio="16:9",
num_frames=141,
image_reference=[{"image_url": "/tmp/reference.png"}],
continuation_video="/tmp/clip.mkv",
)
adapted = build_generation_request(
"video_gen_test",
request,
_args("MiniMaxAI/MiniMax-H3", override_pipeline_cls_name="MiniMaxH3Ref2VAModularPipeline"),
served_model_name="minimax-h3",
output_dir=str(tmp_path),
)
references = adapted.inputs.references
assert [(reference.media_type, reference.source) for reference in references] == [
("image", "/tmp/reference.png"),
("continuation", "/tmp/clip.mkv"),
]
def test_continuation_video_needs_a_ref2va_server(tmp_path: Path) -> None:
request = VideoGenerationRequest(prompt="a fox", aspect_ratio="16:9", num_frames=141, continuation_video="/tmp/clip.mkv")
with pytest.raises(RequestAdaptationError, match="continuation_video requires"):
build_generation_request(
"video_gen_test",
request,
_args("FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2"),
served_model_name="fasth3",
output_dir=str(tmp_path),
)
def test_aspect_ratio_overrides_operator_dimensions(tmp_path: Path) -> None:
default_request = parse_config(
GenerationRequest,
@@ -41,6 +41,7 @@ def _new_runtime_video_generator() -> VideoGenerator:
generator.fastvideo_args = SimpleNamespace(
model_path="test-model",
prompt_txt=None,
output_type="pil",
workload_type=SimpleNamespace(value="t2v"),
pipeline_config=SimpleNamespace(apply_request_constraints=lambda request, sampling_param: sampling_param),
)
@@ -265,6 +266,351 @@ def test_prepare_output_path_empty_prompt_fallback(tmp_path):
assert os.path.basename(result) == "output.mp4"
@pytest.mark.parametrize("entry", ["generate", "generate_video", "sampling_param"])
def test_generate_honors_output_video_name(monkeypatch, tmp_path, entry):
generator = _new_runtime_video_generator()
_patch_sampling_param_from_pretrained(monkeypatch)
captured = {}
def fake_generate_single_video(prompt, sampling_param, **kwargs):
captured["prompt"] = prompt
captured["name"] = sampling_param.output_video_name
return {"prompts": prompt, "video_path": kwargs["output_path"]}
monkeypatch.setattr(generator, "_generate_single_video", fake_generate_single_video)
output = {"output_path": str(tmp_path), "output_video_name": "my_clip"}
if entry == "generate":
result = generator.generate({"prompt": "a red fox", "output": output})
output_path = result.video_path
elif entry == "generate_video":
with pytest.warns(DeprecationWarning):
result = generator.generate_video(prompt="a red fox", **output)
output_path = result["video_path"]
else:
result = generator._generate_video_impl("a red fox", sampling_param=SamplingParam(**output))
output_path = result["video_path"]
assert output_path == str(tmp_path / "my_clip.mp4")
assert captured == {"prompt": "a red fox", "name": "my_clip"}
@pytest.mark.parametrize("name,expected", [
(None, "a red fox.mp4"),
("", "a red fox.mp4"),
("my_clip", "my_clip.mp4"),
("my_clip.mp4", "my_clip.mp4"),
("my_clip.MP4", "my_clip.mp4"),
("my.clip.v1", "my.clip.v1.mp4"),
(" my:clip?*.mp4", "myclip.mp4"),
(r"../nested\my:clip?.mp4", "nestedmyclip.mp4"),
(":/\\*?<>| ..", "output.mp4"),
("a" * 120, "a" * 120 + ".mp4"),
])
def test_prepare_output_path_requested_name(tmp_path, name, expected):
generator = _new_video_generator()
directory = tmp_path / "outputs"
result = generator._prepare_output_path(str(directory), "a red fox", output_video_name=name)
assert result == str(directory / expected)
assert directory.is_dir()
@pytest.mark.parametrize("workload,extension", [("t2v", ".mp4"), ("t2i", ".jpg"), ("t2a", ".wav")])
def test_prepare_output_path_explicit_file_overrides_requested_name(tmp_path, workload, extension):
generator = _new_runtime_video_generator()
generator.fastvideo_args.workload_type = WorkloadType.from_string(workload)
target = tmp_path / ("explicit" + extension)
target.write_bytes(b"existing output")
result = generator._prepare_output_path(str(target), "prompt", output_video_name="ignored.png")
assert result == str(tmp_path / ("explicit_1" + extension))
assert target.read_bytes() == b"existing output"
@pytest.mark.parametrize("workload,name,expected", [
("t2i", "my_image", "my_image.png"),
("t2i", "my_image.png", "my_image.png"),
("t2i", "my_image.jpg", "my_image.jpg"),
("t2i", "my_image.JPEG", "my_image.jpeg"),
("t2i", "my_image.webp", "my_image.webp"),
("t2a", "my_audio", "my_audio.wav"),
("t2a", "my_audio.wav", "my_audio.wav"),
])
def test_prepare_output_path_requested_name_uses_workload_format(tmp_path, workload, name, expected):
generator = _new_runtime_video_generator()
generator.fastvideo_args.workload_type = WorkloadType.from_string(workload)
assert generator._prepare_output_path(str(tmp_path), "prompt", output_video_name=name) == str(tmp_path / expected)
def test_prepare_output_path_requested_name_in_extension_directory(tmp_path):
generator = _new_video_generator()
directory = tmp_path / "outputs.mp4"
directory.mkdir()
existing = directory / "my_clip.mp4"
existing.write_bytes(b"existing output")
result = generator._prepare_output_path(str(directory), "prompt", output_video_name="my_clip.mp4")
assert result == str(directory / "my_clip_1.mp4")
assert existing.read_bytes() == b"existing output"
@pytest.mark.parametrize("batch_source", ["prompt_file", "prompt_list"])
def test_generate_batch_honors_output_video_name(monkeypatch, tmp_path, batch_source):
generator = _new_runtime_video_generator()
_patch_sampling_param_from_pretrained(monkeypatch)
captured_prompts = []
def fake_generate_single_video(prompt, sampling_param, **kwargs):
captured_prompts.append(prompt)
assert sampling_param.output_video_name == "my_clip.mp4"
with open(kwargs["output_path"], "wb") as output:
output.write(b"generated output")
return {"prompts": prompt, "video_path": kwargs["output_path"]}
monkeypatch.setattr(generator, "_generate_single_video", fake_generate_single_video)
request = {"output": {"output_path": str(tmp_path), "output_video_name": "my_clip.mp4"}}
if batch_source == "prompt_file":
prompt_file = tmp_path / "prompts.txt"
prompt_file.write_text("first prompt\nsecond prompt\n", encoding="utf-8")
request["inputs"] = {"prompt_path": str(prompt_file)}
else:
request["prompt"] = ["first prompt", "second prompt"]
results = generator.generate(request)
assert [result.video_path for result in results] == [str(tmp_path / "my_clip.mp4"), str(tmp_path / "my_clip_1.mp4")]
assert [result.prompt_index for result in results] == [0, 1]
assert captured_prompts == ["first prompt", "second prompt"]
@pytest.mark.parametrize("name,expected", [(None, "input.mp4"), ("custom", "custom.mp4")])
def test_generate_optional_prompt_respects_output_video_name(monkeypatch, tmp_path, name, expected):
generator = _new_runtime_video_generator()
generator.fastvideo_args.pipeline_config.prompt_optional = True
monkeypatch.setattr(generator, "_generate_single_video", lambda **kwargs: kwargs)
sampling = SamplingParam(video_path="/data/input.mp4", output_path=str(tmp_path), output_video_name=name)
result = generator._generate_video_impl(prompt=None, sampling_param=sampling)
assert result["prompt"] == ""
assert result["output_path"] == str(tmp_path / expected)
@pytest.mark.parametrize("name", ["a" * 256, "a" * 252, "\u754c" * 84])
@pytest.mark.parametrize("explicit_path", [False, True])
def test_prepare_output_path_rejects_overlong_filename(monkeypatch, tmp_path, name, explicit_path):
generator = _new_video_generator()
monkeypatch.setattr(video_generator_module.os, "pathconf", lambda *args: 255, raising=False)
output_path = str(tmp_path / (name + ".mp4")) if explicit_path else str(tmp_path)
with pytest.raises(ValueError, match="Output filename exceeds the 255-byte filesystem limit"):
generator._prepare_output_path(output_path, "prompt", output_video_name=name)
@pytest.mark.parametrize("name", ["a" * 251, "\u754c" * 83 + "ab", "?" * 300 + "clip"])
def test_prepare_output_path_checks_sanitized_filename_bytes(monkeypatch, tmp_path, name):
generator = _new_video_generator()
monkeypatch.setattr(video_generator_module.os, "pathconf", lambda *args: 255, raising=False)
result = generator._prepare_output_path(str(tmp_path), "prompt", output_video_name=name)
assert os.path.basename(result) == name.replace("?", "") + ".mp4"
assert len(os.fsencode(os.path.basename(result))) <= 255
with open(result, "wb") as output:
output.write(b"generated output")
@pytest.mark.parametrize("existing_count,stem_length,rejected", [(1, 249, False), (1, 251, True), (10, 249, True)])
def test_prepare_output_path_checks_collision_suffix_bytes(monkeypatch, tmp_path, existing_count, stem_length, rejected):
generator = _new_video_generator()
monkeypatch.setattr(video_generator_module.os, "pathconf", lambda *args: 255, raising=False)
name = "a" * stem_length
existing_files = [tmp_path / (name + ".mp4")]
existing_files.extend(tmp_path / f"{name}_{index}.mp4" for index in range(1, existing_count))
for path in existing_files:
path.write_bytes(b"existing output")
if rejected:
with pytest.raises(ValueError, match="Output filename exceeds the 255-byte filesystem limit"):
generator._prepare_output_path(str(tmp_path), "prompt", output_video_name=name)
else:
result = generator._prepare_output_path(str(tmp_path), "prompt", output_video_name=name)
assert result == str(tmp_path / f"{name}_{existing_count}.mp4")
assert len(os.fsencode(os.path.basename(result))) == 255
with open(result, "wb") as output:
output.write(b"generated output")
assert all(path.read_bytes() == b"existing output" for path in existing_files)
def test_prepare_output_path_respects_filesystem_name_limit(monkeypatch, tmp_path):
generator = _new_video_generator()
directory = tmp_path / "new_output_directory"
def pathconf(path, key):
assert path == str(directory)
assert directory.is_dir()
assert key == "PC_NAME_MAX"
return 20
monkeypatch.setattr(video_generator_module.os, "pathconf", pathconf, raising=False)
with pytest.raises(ValueError, match="Output filename exceeds the 20-byte filesystem limit"):
generator._prepare_output_path(str(directory), "prompt", output_video_name="a" * 17)
assert generator._prepare_output_path(str(directory), "prompt", output_video_name="a" * 16) == str(
directory / ("a" * 16 + ".mp4"))
@pytest.mark.parametrize("unavailable", [AttributeError, OSError, ValueError, -1, 0])
def test_prepare_output_path_filename_limit_fallback(monkeypatch, tmp_path, unavailable):
generator = _new_video_generator()
def pathconf(*args):
if isinstance(unavailable, type):
raise unavailable("name limit unavailable")
return unavailable
monkeypatch.setattr(video_generator_module.os, "pathconf", pathconf, raising=False)
with pytest.raises(ValueError, match="Output filename exceeds the 255-byte filesystem limit"):
generator._prepare_output_path(str(tmp_path), "prompt", output_video_name="a" * 252)
assert generator._prepare_output_path(str(tmp_path), "prompt", output_video_name="clip") == str(tmp_path / "clip.mp4")
@pytest.mark.parametrize("entry", ["generate", "generate_video", "prompt_file"])
@pytest.mark.parametrize("collision", [False, True])
def test_generate_rejects_overlong_filename_before_model_execution(monkeypatch, tmp_path, entry, collision):
generator = _new_runtime_video_generator()
_patch_sampling_param_from_pretrained(monkeypatch)
monkeypatch.setattr(video_generator_module.os, "pathconf", lambda *args: 255, raising=False)
executed = []
def fake_generate_single_video(**kwargs):
executed.append(kwargs)
return {"prompts": kwargs["prompt"], "video_path": kwargs["output_path"]}
monkeypatch.setattr(generator, "_generate_single_video", fake_generate_single_video)
name = "a" * (251 if collision else 256)
if collision:
(tmp_path / (name + ".mp4")).write_bytes(b"existing output")
output = {"output_path": str(tmp_path), "output_video_name": name}
if entry == "prompt_file":
prompt_file = tmp_path / "prompts.txt"
prompt_file.write_text("first prompt\nsecond prompt\n", encoding="utf-8")
assert generator.generate({"inputs": {"prompt_path": str(prompt_file)}, "output": output}) == []
elif entry == "generate":
with pytest.raises(ValueError, match="Output filename exceeds"):
generator.generate({"prompt": "prompt", "output": output})
else:
with pytest.warns(DeprecationWarning), pytest.raises(ValueError, match="Output filename exceeds"):
generator.generate_video(prompt="prompt", **output)
assert not executed
if collision:
assert (tmp_path / (name + ".mp4")).read_bytes() == b"existing output"
@pytest.mark.parametrize("entry", ["generate", "generate_video", "prompt_file", "prompt_list"])
@pytest.mark.parametrize("naming", ["prompt", "custom", "explicit"])
@pytest.mark.parametrize("output_type,save_video,return_frames,return_samples", [
("pil", False, True, False),
("pil", False, False, True),
("latent", True, False, True),
], ids=["frames", "samples", "latent"])
def test_generate_in_memory_preserves_results_with_long_names(
monkeypatch, tmp_path, entry, naming, output_type, save_video, return_frames, return_samples
):
prompt = "\u754c" * 100
output = torch.ones((1, 16, 1, 2, 2) if output_type == "latent" else (1, 3, 2, 16, 16)) * 0.5
fastvideo_args = _single_video_args(output_type=output_type)
fastvideo_args.model_path = "test-model"
fastvideo_args.prompt_txt = None
generator = _single_video_generator(_single_video_output_batch(output), fastvideo_args)
_patch_sampling_param_from_pretrained(monkeypatch)
directory = tmp_path / "unused_outputs"
output_path = directory / ("a" * 256 + ".mp4") if naming == "explicit" else directory
output_options = {
"output_path": str(output_path),
"output_video_name": "a" * 256 if naming == "custom" else None,
"save_video": save_video,
"return_frames": return_frames,
"return_samples": return_samples,
}
sampling_options = {"height": 16, "width": 16, "num_frames": 2, "num_inference_steps": 1}
request = {"prompt": prompt, "sampling": sampling_options, "output": output_options}
if entry == "prompt_file":
prompt_file = tmp_path / "prompts.txt"
prompt_file.write_text(f"{prompt}\n{prompt}\n", encoding="utf-8")
request["prompt"] = None
request["inputs"] = {"prompt_path": str(prompt_file)}
elif entry == "prompt_list":
request["prompt"] = [prompt, prompt]
def fail_path_operation(*args, **kwargs):
pytest.fail("In-memory generation must not prepare an output file")
with monkeypatch.context() as patches:
patches.setattr(video_generator_module.os, "pathconf", fail_path_operation, raising=False)
patches.setattr(video_generator_module.os, "makedirs", fail_path_operation)
if entry == "generate_video":
with pytest.warns(DeprecationWarning):
results = [generator.generate_video(prompt=prompt, **sampling_options, **output_options)]
else:
generated = generator.generate(request)
results = generated if isinstance(generated, list) else [generated]
results = [result.to_legacy_dict() for result in results]
assert len(results) == (2 if entry in {"prompt_file", "prompt_list"} else 1)
for result in results:
assert result["prompts"] == prompt
torch.testing.assert_close(result["samples"], output)
assert result["video_path"] is None
if return_frames:
assert len(result["frames"]) == 2
assert result["frames"][0].shape == (16, 16, 3)
else:
assert result["frames"] is None
assert not directory.exists()
@pytest.mark.parametrize("output_type", ["pil", "latent"])
@pytest.mark.parametrize("save_video", [False, True])
def test_generate_output_path_uses_effective_request_options(monkeypatch, tmp_path, output_type, save_video):
output = torch.ones((1, 16, 1, 2, 2) if output_type == "latent" else (1, 3, 2, 16, 16)) * 0.5
default_args = _single_video_args(output_type="latent" if output_type == "pil" else "pil")
generator = _single_video_generator(_single_video_output_batch(output), default_args)
request_args = _single_video_args(output_type=output_type)
request_args.model_path = "test-model"
request_args.prompt_txt = None
sampling_param = _small_sampling_param(save_video=not save_video)
sampling_param.output_path = str(tmp_path / "outputs")
monkeypatch.setattr(video_generator_module.os, "pathconf", lambda *args: 255, raising=False)
def generate():
return generator._generate_video_impl(
prompt="\u754c" * 100,
sampling_param=sampling_param,
fastvideo_args=request_args,
save_video=save_video,
return_samples=True,
)
if save_video and output_type != "latent":
def fail_execute(*args):
pytest.fail("Invalid output filenames must be rejected before model execution")
monkeypatch.setattr(generator.executor, "execute_forward", fail_execute)
with pytest.raises(ValueError, match="Output filename exceeds the 255-byte filesystem limit"):
generate()
else:
result = generate()
assert result["video_path"] is None
torch.testing.assert_close(result["samples"], output)
assert not (tmp_path / "outputs").exists()
def test_generate_single_video_metadata_only_skips_output_materialization(monkeypatch, tmp_path):
output_batch = _single_video_output_batch(
_NoCpuMaterializationOutput(),
@@ -0,0 +1,225 @@
# SPDX-License-Identifier: Apache-2.0
"""MiniMax-H3 Ref2VA continuation clips: validation, decoding and their place in the packed sequence."""
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
from PIL import Image
import pytest
import torch
from fastvideo.pipelines.basic.minimax_h3 import MiniMaxH3Reference
from fastvideo.pipelines.basic.minimax_h3.packing import (
MINIMAX_H3_AUDIO_CHANNELS,
MiniMaxH3ContinuationLayout,
_reference_temporal_span,
audio_latent_num_frames,
build_ref2va_packed_sequence,
video_latent_num_frames,
)
from fastvideo.models import pinned_offload
from fastvideo.models.schedulers.scheduling_minimax_h3 import MiniMaxH3Scheduler
from fastvideo.pipelines.basic.minimax_h3.reference import (
MINIMAX_H3_PREPARED_CONTINUATION_KEY,
prepare_continuation,
validate_references,
)
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_conditioning import MINIMAX_H3_TEXT_TOKEN_TAGS_KEY
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_denoising import _h3_vsa_ref2va_segments
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_input_preparation import MiniMaxH3InputPreparationStage
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_latent_preparation import MiniMaxH3LatentPreparationStage
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
TEXT_TOKENS = 137
LATENT_HEIGHT, LATENT_WIDTH = 48, 84
ROWS_PER_FRAME = (LATENT_HEIGHT // 2) * (LATENT_WIDTH // 2)
def _image_reference():
return SimpleNamespace(media_type="image",
num_latent_frames=1,
latent_height=64,
latent_width=40,
has_audio=False,
num_audio_latents=0)
def _layout(clip_audio_latents: int, num_frames: int = 141, clip_frames: int = 22):
clip = MiniMaxH3ContinuationLayout(video_latent_num_frames(clip_frames), clip_audio_latents)
packed = build_ref2va_packed_sequence(
torch.ones(TEXT_TOKENS, dtype=torch.long),
[_image_reference()],
video_latent_num_frames(num_frames),
LATENT_HEIGHT,
LATENT_WIDTH,
audio_latent_num_frames(num_frames),
(1, 2, 2),
continuation=clip,
)
return packed, clip
@pytest.mark.parametrize("clip_audio_latents", [37, 0])
def test_clip_rows_take_the_coordinates_of_the_target_start(clip_audio_latents):
packed, clip = _layout(clip_audio_latents)
positions = packed.position_ids
clip_rows = clip.num_latent_frames * ROWS_PER_FRAME
conditions = packed.num_condition_video_rows
clip_video = packed.video_indices[conditions - clip_rows:conditions]
target_start = packed.video_indices[conditions:conditions + clip_rows]
assert torch.equal(positions[clip_video], positions[target_start])
image_rows = (64 // 2) * (40 // 2)
assert conditions == image_rows + clip_rows
origin = TEXT_TOKENS + 1.0 + max(float(clip_audio_latents), _reference_temporal_span(clip.num_latent_frames))
assert positions[target_start[0], 0].item() == pytest.approx(origin)
audio_conditions = packed.num_condition_audio_rows
assert audio_conditions == clip_audio_latents * MINIMAX_H3_AUDIO_CHANNELS
clip_audio = packed.audio_indices[:audio_conditions]
target_audio = packed.audio_indices[audio_conditions:]
for channel in range(MINIMAX_H3_AUDIO_CHANNELS if clip_audio_latents else 0):
clip_channel = clip_audio[channel * clip_audio_latents:(channel + 1) * clip_audio_latents]
start = channel * packed.num_audio_latents
assert torch.equal(positions[clip_channel], positions[target_audio[start:start + clip_audio_latents]])
@pytest.mark.parametrize("clip_audio_latents", [37, 0])
def test_vsa_keeps_the_clip_dense(clip_audio_latents):
packed, clip = _layout(clip_audio_latents)
segments = _h3_vsa_ref2va_segments(packed, (1, 2, 2))
assert [segment for segment in segments if isinstance(segment, tuple)] == [
(packed.num_video_latent_frames, LATENT_HEIGHT, LATENT_WIDTH)
]
assert clip.num_latent_frames * ROWS_PER_FRAME in segments
def test_a_clip_as_long_as_the_target_is_rejected():
with pytest.raises(ValueError, match="does not fit"):
_layout(37, num_frames=22, clip_frames=22)
def test_a_continuation_is_not_a_reference():
image = MiniMaxH3Reference(source="picture.png")
clip = MiniMaxH3Reference(source="clip.mkv", media_type="continuation")
assert len(validate_references([image] * 9 + [MiniMaxH3Reference("a.wav", media_type="audio")] * 3 + [clip])) == 13
with pytest.raises(ValueError, match="at most one continuation"):
validate_references([image, clip, clip])
with pytest.raises(ValueError, match="besides its clip"):
validate_references([clip])
def test_continuation_clips_are_checked_against_the_contract():
height, width = 768, 1344
frames = np.zeros((22, height, width, 3), dtype=np.uint8)
prepared = prepare_continuation(MiniMaxH3Reference(frames, media_type="continuation"), height, width, 32000)
assert prepared.frames.shape == (22, height, width, 3) and prepared.waveform is None
with pytest.raises(ValueError, match="5, 22, 39 frames"):
prepare_continuation(MiniMaxH3Reference(frames[:20], media_type="continuation"), height, width, 32000)
with pytest.raises(ValueError, match="target canvas"):
prepare_continuation(MiniMaxH3Reference(frames, media_type="continuation"), width, height, 32000)
class _Posterior:
def __init__(self, latents: torch.Tensor) -> None:
self.latents = latents
def sample(self, generator=None) -> torch.Tensor:
return self.latents
def mode(self) -> torch.Tensor:
return self.latents
class _VideoVAE:
"""Encodes each clip to latent frames of 2.0 and each still image to 1.0, on the H3 latent geometry."""
latent_channels = 4
spatial_compression_ratio = 16
def normalize_pixels(self, pixels):
return pixels
def normalize_latents(self, latents):
return latents
def _latents(self, pixels: torch.Tensor, frames: int, value: float):
height, width = pixels.shape[-2] // 16, pixels.shape[-1] // 16
return SimpleNamespace(latent_dist=_Posterior(torch.full((1, 4, frames, height, width), value)))
def encode_keyframe(self, pixels):
return self._latents(pixels, 1, 1.0)
def encode_pixels(self, pixels):
return self._latents(pixels, video_latent_num_frames(pixels.shape[2]), 2.0)
class _AudioVAE:
"""Encodes each stereo channel to one latent per 800 samples whose values number the channel and step."""
latent_channels = 4
sampling_rate = 32000
def normalize_latents(self, latents):
return latents
def encode(self, waveform):
channels, steps = waveform.shape[0], waveform.shape[-1] // 800
index = torch.arange(channels * steps, dtype=torch.float32).reshape(channels, 1, steps)
return SimpleNamespace(latent_dist=_Posterior(index.repeat(1, self.latent_channels, 1)))
def _args():
return SimpleNamespace(
pin_cpu_memory=False,
vae_cpu_offload=False,
vae_parallel_encode=False,
pipeline_config=SimpleNamespace(
dit_config=SimpleNamespace(patch_size=(1, 2, 2)),
vae_config=SimpleNamespace(arch_config=SimpleNamespace(latent_channels=4)),
),
)
def test_a_clip_with_sound_reaches_the_packed_sequence():
height = width = 64
clip_frames = np.zeros((22, height, width, 3), dtype=np.uint8)
clip_samples = round(22 / 24 * 32000)
clip = MiniMaxH3Reference(clip_frames,
media_type="continuation",
soundtrack=torch.linspace(-0.5, 0.5, clip_samples).repeat(2, 1),
sample_rate=32000)
image = MiniMaxH3Reference(Image.new("RGB", (64, 64), "red"))
batch = ForwardBatch(data_type="video",
prompt="x",
height=height,
width=width,
num_frames=141,
references=[image, clip],
generator=torch.Generator("cpu").manual_seed(0))
video, audio = _VideoVAE(), _AudioVAE()
MiniMaxH3InputPreparationStage(video, audio, ref2va=True)._prepare_ref2va(batch)
assert [reference.media_type for reference in batch.references] == ["image"]
prepared = batch.extra[MINIMAX_H3_PREPARED_CONTINUATION_KEY]
assert prepared.frames.shape == (22, height, width, 3) and prepared.waveform.shape == (2, clip_samples)
stage = MiniMaxH3LatentPreparationStage(video, audio, MiniMaxH3Scheduler(), ref2va=True)
with patch.object(pinned_offload, "load"), patch.object(pinned_offload, "unload"):
video_rows, audio_rows = stage._encode_ref2va_conditions(batch, _args(), torch.device("cpu"))
image_rows = (batch.references[0].latent_height // 2) * (batch.references[0].latent_width // 2)
clip_rows = video_latent_num_frames(22) * (height // 16 // 2) * (width // 16 // 2)
assert video_rows.shape[0] == image_rows + clip_rows
clip_audio_latents = clip_samples // 800
expected_audio = torch.arange(2 * clip_audio_latents, dtype=torch.float32)[:, None].repeat(1, 4)
assert torch.equal(audio_rows, expected_audio)
batch.extra[MINIMAX_H3_TEXT_TOKEN_TAGS_KEY] = torch.ones(5, dtype=torch.long)
layout = stage._build_layout(batch, _args())
assert layout.num_condition_video_rows == image_rows + clip_rows
assert layout.num_condition_audio_rows == 2 * clip_audio_latents
assert layout.reference_segments[-2:] == (
("audio", 2 * clip_audio_latents, (0, 0, 0)),
("continuation", clip_rows, (video_latent_num_frames(22), height // 16, width // 16)),
)
@@ -0,0 +1,66 @@
# SPDX-License-Identifier: Apache-2.0
"""Without a fixed training shape, H3 rows train at their own length instead of being cropped."""
from types import SimpleNamespace
import pytest
import torch
from fastvideo.pipelines.basic.minimax_h3.packing import audio_latent_num_frames, video_latent_num_frames
from fastvideo.train.models.minimax_h3.minimax_h3 import MiniMaxH3Model
def _model(num_latent_t: int, num_frames: int) -> MiniMaxH3Model:
model = MiniMaxH3Model.__new__(MiniMaxH3Model)
model.training_config = SimpleNamespace(data=SimpleNamespace(
native_shape_bucketing=False, num_latent_t=num_latent_t, num_frames=num_frames))
return model
def _row(num_frames: int, audio_frames: int | None = None) -> dict[str, torch.Tensor]:
if audio_frames is None:
audio_frames = audio_latent_num_frames(num_frames)
return {
"vae_latent": torch.zeros(1, 24, video_latent_num_frames(num_frames), 4, 2),
"audio_latent": torch.zeros(1, 2, 32, audio_frames),
}
def _resolve(model: MiniMaxH3Model, raw_batch: dict[str, torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]:
return model._resolve_clean_latents(raw_batch, "data", torch.float32, torch.device("cpu"))
@pytest.mark.parametrize("num_frames", [124, 158, 243, 362])
def test_unset_shape_keeps_each_row_length(num_frames: int) -> None:
video, audio = _resolve(_model(0, 0), _row(num_frames))
assert video.shape[2] == video_latent_num_frames(num_frames)
assert audio.shape[-1] == audio_latent_num_frames(num_frames)
def test_unset_shape_rejects_mismatched_latent_clocks() -> None:
with pytest.raises(ValueError, match="158 video frames need 263"):
_resolve(_model(0, 0), _row(158, audio_frames=264))
def test_fixed_shape_still_crops_and_warns_once(monkeypatch: pytest.MonkeyPatch) -> None:
import fastvideo.train.models.minimax_h3.minimax_h3 as module
warnings = []
monkeypatch.setattr(module.logger, "warning_once", warnings.append)
video, audio = _resolve(_model(37, 124), _row(243))
assert video.shape[2] == 37
assert audio.shape[-1] == audio_latent_num_frames(124)
assert len(warnings) == 1 and "Cropping vae_latent from 72 to" in warnings[0]
def test_fixed_shape_at_its_own_length_does_not_warn(monkeypatch: pytest.MonkeyPatch) -> None:
import fastvideo.train.models.minimax_h3.minimax_h3 as module
warnings = []
monkeypatch.setattr(module.logger, "warning_once", warnings.append)
_resolve(_model(37, 124), _row(124))
assert warnings == []
@@ -0,0 +1,120 @@
# SPDX-License-Identifier: Apache-2.0
"""Ref2VA SFT preprocessing keeps each target clip's own canvas and native frame count."""
from pathlib import Path
import numpy as np
import pytest
import torch
from fastvideo.pipelines.basic.minimax_h3.packing import (
MINIMAX_H3_TEXT_TAG,
audio_latent_num_frames,
resolve_canvas_size,
video_latent_num_frames,
)
from fastvideo.pipelines.basic.minimax_h3.ref2va_manifest import MiniMaxH3Ref2VARawSample
from fastvideo.pipelines.preprocess import preprocess_minimax_h3_overfit as overfit
from fastvideo.pipelines.preprocess import preprocess_minimax_h3_ref2va as ref2va
_SAMPLES_PER_AUDIO_LATENT = 800 # 32 kHz / 40 latents per second
@pytest.mark.parametrize(
("available", "expected"),
[(124, 124), (140, 124), (141, 141), (157, 141), (209, 209), (362, 362), (1000, 362)],
)
def test_native_num_frames_rounds_down_to_17n_plus_5(available: int, expected: int) -> None:
assert overfit.native_num_frames(available) == expected
def test_native_num_frames_rejects_clips_under_five_seconds() -> None:
with pytest.raises(ValueError, match="at least 124"):
overfit.native_num_frames(123)
def _stub_media(monkeypatch: pytest.MonkeyPatch, num_source_frames: int, seconds: float) -> None:
"""Decode to tiny 30-fps frames and a 48 kHz stereo soundtrack; skip the canvas resize."""
frames = np.zeros((num_source_frames, 8, 4, 3), dtype=np.uint8)
waveform = torch.zeros(2, int(seconds * 48000))
monkeypatch.setattr(overfit, "decode_reference_video", lambda _path: (frames, 30.0, (waveform, 48000)))
monkeypatch.setattr(overfit, "prepare_reference_frames", lambda decoded, num_frames: decoded[:num_frames])
def test_native_media_keeps_native_length_and_whole_audio_latents(monkeypatch: pytest.MonkeyPatch) -> None:
# 210 frames at 30 fps resample to 168 at 24 fps; the longest native count is 158.
_stub_media(monkeypatch, num_source_frames=210, seconds=7.0)
frames, waveform = overfit.load_training_media(Path("clip.mp4"), num_frames=None)
assert frames.shape[0] == 158
# 158 frames last 210666 samples, past the 263rd whole latent; the VAE would pad a 264th.
assert audio_latent_num_frames(158) == 263
assert waveform.shape == (2, 263 * _SAMPLES_PER_AUDIO_LATENT)
def test_fixed_overfit_media_audio_length_is_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
_stub_media(monkeypatch, num_source_frames=200, seconds=7.0)
monkeypatch.setattr(
overfit,
"prepare_reference_frames",
lambda decoded, num_frames: np.zeros((num_frames, overfit.VIDEO_HEIGHT, overfit.VIDEO_WIDTH, 3), np.uint8),
)
frames, waveform = overfit.load_training_media(Path("clip.mp4"))
# 124 frames end inside the 207th audio latent, so the whole-latent cap cuts nothing.
uncapped = overfit.prepare_reference_waveform(torch.zeros(2, 7 * 48000),
48000,
overfit.AUDIO_SAMPLE_RATE,
max_duration=overfit.NUM_FRAMES / 24)
assert frames.shape[0] == overfit.NUM_FRAMES
assert uncapped.shape[-1] < audio_latent_num_frames(overfit.NUM_FRAMES) * _SAMPLES_PER_AUDIO_LATENT
assert waveform.shape == uncapped.shape
def test_build_record_uses_the_clip_geometry(monkeypatch: pytest.MonkeyPatch) -> None:
"""A portrait 158-frame clip yields a record the training collator accepts."""
num_frames = 158
height, width = resolve_canvas_size(9, 16)
target = np.zeros((num_frames, height, width, 3), dtype=np.uint8)
waveform = torch.zeros(2, audio_latent_num_frames(num_frames) * _SAMPLES_PER_AUDIO_LATENT)
monkeypatch.setattr(ref2va, "load_training_media", lambda _path, num_frames: (target, waveform))
monkeypatch.setattr(ref2va, "_prepare_references", lambda _refs, _num_frames: [])
monkeypatch.setattr(
ref2va,
"encode_video_latents",
lambda frames, *_args: torch.zeros(24, video_latent_num_frames(frames.shape[0]), frames.shape[1] // 16,
frames.shape[2] // 16),
)
monkeypatch.setattr(
ref2va,
"encode_audio_latents",
lambda audio, *_args: torch.zeros(2, 32, audio.shape[-1] // _SAMPLES_PER_AUDIO_LATENT),
)
monkeypatch.setattr(ref2va, "encode_ref_visual_anchor", lambda *_args: torch.empty(0, 96))
monkeypatch.setattr(ref2va, "encode_ref_audio_anchor", lambda *_args: torch.empty(0, 32))
monkeypatch.setattr(
ref2va,
"encode_ref2va_conditioning",
lambda *_args: (torch.zeros(4, 5120), torch.full((4, ), MINIMAX_H3_TEXT_TAG)),
)
sample = MiniMaxH3Ref2VARawSample(
sample_id="portrait",
target_file="portrait.mp4",
target_video_path=Path("portrait.mp4"),
caption="A person talks to the camera.",
references=(),
)
record = ref2va._build_record(
sample,
model_path=Path("model"),
model_index={},
fastvideo_args=None,
patch_size=(1, 2, 2),
)
assert (record["num_frames"], record["height"], record["width"]) == (num_frames, height, width)
assert record["vae_latent_shape"] == [24, video_latent_num_frames(num_frames), height // 16, width // 16]
assert record["audio_latent_shape"] == [2, 32, audio_latent_num_frames(num_frames)]
@@ -19,6 +19,7 @@ from fastvideo.dataset.sequence_capacity import select_sequence_capacity, valida
from fastvideo.distributed import get_sp_group
from fastvideo.forward_context import set_forward_context
from fastvideo.layers.pdd import shifted_noise_amount
from fastvideo.logger import init_logger
from fastvideo.models.schedulers.scheduling_minimax_h3 import MiniMaxH3Scheduler
from fastvideo.pipelines import TrainingBatch
from fastvideo.pipelines.basic.minimax_h3.packing import (
@@ -49,6 +50,8 @@ from fastvideo.train.utils.module_state import apply_trainable
from fastvideo.train.utils.moduleloader import load_module_from_path
from fastvideo.train.utils.lora import finalize_lora_training, LoraConfig
logger = init_logger(__name__)
if TYPE_CHECKING:
from fastvideo.train.utils.training_config import TrainingConfig
@@ -370,10 +373,26 @@ class MiniMaxH3Model(ModelBase):
if latents_source == "data" and native_shapes:
self._validate_native_latents(raw_batch, video_latents, audio_latents)
elif latents_source == "data" and data_config.num_latent_t <= 0 and data_config.num_frames <= 0:
# With no fixed shape configured, each row trains at its own
# length, as the Ref2VA SFT preprocessor records it. Its video and
# audio latents must still describe the same frame count.
num_video_latents = int(video_latents.shape[2])
if (num_video_latents - 2) % 5:
raise ValueError(f"vae_latent has {num_video_latents} frames; MiniMax H3 encodes 5 * n + 2")
num_frames = (num_video_latents - 2) // 5 * 17 + 5
if audio_latents.shape[-1] != audio_latent_num_frames(num_frames):
raise ValueError(f"audio_latent has {audio_latents.shape[-1]} frames; {num_frames} video frames "
f"need {audio_latent_num_frames(num_frames)}")
elif not native_shapes:
# Preserve the legacy fixed-shape contract for configs that have
# not opted into exact-shape bucketing.
if data_config.num_latent_t > 0:
if video_latents.shape[2] > data_config.num_latent_t:
logger.warning_once(
f"Cropping vae_latent from {video_latents.shape[2]} to training.data.num_latent_t="
f"{data_config.num_latent_t} frames, and audio to match. Leave num_latent_t and "
"num_frames unset to train each row at its own length.")
video_latents = video_latents[:, :, :data_config.num_latent_t]
expected_audio_frames = audio_latent_num_frames(data_config.num_frames)
audio_latents = audio_latents[:, :, :, :expected_audio_frames]