Compare commits

..
Author SHA1 Message Date
Junda Chen 61ebcbb635 [bugfix] Reset VAE tiling for each request 2026-10-15 00:31:25 -07:00
724 changed files with 8174 additions and 95343 deletions
@@ -1,37 +0,0 @@
---
date: 2026-11-02
experiment: FastVideo CI repair on main (Transformer and Unit lanes)
category: infrastructure
severity: important
---
# pytest 9 monkeypatch cannot restore torch flag proxies
## What Happened
After the CI image picked up pytest 9, the Transformer lane reported 22
teardown errors (`'cuBLASModule' object has no attribute 'allow_tf32'`) and
the Unit lane reported 28 setup errors in `test_lora_side_branch.py`
(`torch._dynamo.config ... has no attribute 'disable'`). The test bodies
still passed.
## Root Cause
pytest 9's `monkeypatch.setattr` reads the previous value from the target's
instance `__dict__`. `torch.backends.cuda.matmul` and `torch._dynamo.config`
serve their flags through `__getattr__`, so pytest records the value as
missing. Teardown then calls `delattr`: on cuBLAS that raises and leaves TF32
changed, and on the dynamo config it deletes the `disable` entry, so every
later `setattr` on it fails. pytest 8.4 restores both correctly.
## Fix / Workaround
Use the owner's own scoping instead of monkeypatch:
`torch._dynamo.config.patch(name=value)` for dynamo/inductor config, and the
`tf32_disabled` fixture in `fastvideo/tests/conftest.py` for the TF32 flags.
## Prevention
Do not `monkeypatch.setattr` attributes of `torch.backends.cuda.matmul`,
`torch._dynamo.config`, `torch._inductor.config` or other torch config
proxies. Module functions such as `torch.compiler.is_compiling` are safe.
+10 -24
View File
@@ -1,15 +1,15 @@
---
name: ci-runner
description: Work on FastVideo's Spark 4 and Slinky Slurm GPU CI lanes, static Buildkite graph, trusted ci-runner policy, lane scripts, and hardware validation.
description: Work on FastVideo's Slurm-only, change-aware GPU CI lanes, static Buildkite graph, trusted ci-runner policy, lane scripts, and GB200 validation.
---
# GPU CI lanes
# Slinky Slurm CI lanes
FastVideo's `ci-runner` Buildkite queue is the control plane for all active
GPU CI. The trusted uploader runs on the Slinky login plane. Slinky agents
dispatch cluster lanes through Slurm into isolated Enroot containers. The
Spark 4 agent dispatches eligible single-GPU lanes through local Slurm into
isolated Docker containers. Both execute the immutable PR SHA.
GPU CI. A private host-owned dispatcher leases GPUs from the Slinky Slurm tray
and runs the immutable PR SHA inside an isolated Enroot container. Buildkite
pipeline upload and Slurm submission occur on the login plane; every test
payload executes on Slurm compute.
The files under `fastvideo/tests/modal/` and `.buildkite/scripts/pr_test.sh`
are dormant rollback code. Never add an active Buildkite or slash-command
@@ -26,21 +26,7 @@ and the coordination contract with that bundle.
GPU lane. Each step pins a unique key and label, a 90-minute timeout, the
trusted `/opt/fastvideo-ci-runner/run-ci` command (`run-unit` is the one
compatibility wrapper), step-level internal `TEST_TYPE`, and
`queue: "ci-runner"` plus a `backend` agent tag.
- `backend` is `"${FV_SPARK_BACKEND:-slurm}"` for the Spark-capable
(single-GPU) lanes and a literal `"slurm"` for ssim, training,
distillation, self-forcing, lora-training, training-vsa and performance.
A Spark runner sets `FASTVIDEO_CI_BACKEND=spark4`; gate cluster-only tests on
that flag, never on memory heuristics. Keep
`SPARK_CAPABLE_LANES` in `test_ci_test_collection.py` in sync.
- The trusted uploader fills `FV_SPARK_BACKEND`: `spark4` for `fastcheck`,
`merge` and `direct` builds, including unset scope treated as Fastcheck,
while an agent tagged `queue=ci-runner,backend=spark4` is connected and
not paused, `slurm`
otherwise. Selection happens at upload time. Legacy graphs without backend
tags need explicit normalization; setting the variable alone does not pin
those jobs to Slurm. It rejects any other `backend` value. The upload step
in each entry pipeline requires `backend=slurm` so it never lands on the Spark runner.
`queue: "ci-runner"`.
- Active CI contains no `pr_test.sh` command, Modal invocation, default queue,
Buildkite plugin, `soft_fail`, or job-controlled artifact glob.
- The six Fastcheck lanes use `:microscope:` labels. Full-Suite-only lanes use
@@ -59,8 +45,8 @@ and the coordination contract with that bundle.
- Focused merge builds may pass allowlisted golden-gate and SSIM test basenames.
The private host validates the lane plan and basenames before staging them,
and the in-container scripts validate them again. Direct `/test ssim`,
explicit `/test full`, and the weekly main-branch Full Suite
(`ci-scheduled-full-suite.yml`) run the complete SSIM matrix.
explicit `/test full`, and the weekly main-branch schedule run the complete
SSIM matrix.
- The trusted uploader serves exactly three entry pipelines:
`pr-fastcheck` for automatic PR builds, `ci` for slash-command/ready-label
API builds, and `fastvideo-performance-lane` for the weekly schedule. Keep
@@ -72,7 +58,7 @@ and the coordination contract with that bundle.
- Tests must preserve an inherited `MASTER_PORT`. Packed containers share the
tray network namespace, so the private runner assigns a distinct port range
per GPU lease and the SSIM scheduler assigns task offsets within its range.
- The Slinky ARM64 runner image includes the pinned FA4 CuTe overlay validated on
- The ARM64 runner image includes the pinned FA4 CuTe overlay validated on
GB200. Keep SSIM at `FASTVIDEO_FA4=1` because its references were seeded with
FA4; keep lanes with FA2 baselines at `FASTVIDEO_FA4=0`. A runner image change
must revalidate both the FA4 import and an actual GB200 forward kernel.
-22
View File
@@ -23,8 +23,6 @@ notify:
# dispatcher, and every test payload executes inside the Slinky Slurm tray.
# fastvideo/tests/modal remains available only for an explicit manual rollback;
# no active pipeline or slash-command route invokes it.
# Agent tag `backend` routes a lane: Spark-capable lanes default to slurm and move to a
# DGX Spark runner when FV_SPARK_BACKEND=spark4; the rest are pinned to slurm.
# Buildkite hands jobs to free agents in the order they appear here. Golden-gate comes first
# because every later merge lane waits for it; the fastcheck lanes follow from longest to
# shortest measured runtime, so the longest lane never starts last and stretches the build.
@@ -49,7 +47,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":microscope: Unit Tests"
key: "unit"
@@ -73,7 +70,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":microscope: Kernel Tests"
key: "kernel-tests"
@@ -97,7 +93,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":microscope: DreamVerse App Tests"
key: "dreamverse"
@@ -121,7 +116,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":microscope: Encoder Tests"
key: "encoder"
@@ -145,7 +139,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":microscope: VAE Tests"
key: "vae"
@@ -169,7 +162,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":microscope: Transformer Tests"
key: "transformer"
@@ -193,7 +185,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":bar_chart: SSIM Tests"
key: "ssim"
@@ -221,7 +212,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "slurm"
- label: ":test_tube: LoRA Inference Tests"
key: "lora-inference"
@@ -244,7 +234,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":test_tube: LoRA Extraction Tests"
key: "lora-extraction"
@@ -267,7 +256,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":test_tube: Training Tests"
key: "training"
@@ -292,7 +280,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "slurm"
- label: ":test_tube: Distillation DMD Tests"
key: "distillation"
@@ -315,7 +302,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "slurm"
- label: ":test_tube: Self-Forcing Tests"
key: "self-forcing"
@@ -338,7 +324,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "slurm"
- label: ":test_tube: LoRA Training Tests"
key: "lora-training"
@@ -363,7 +348,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "slurm"
- label: ":test_tube: Training Tests VSA"
key: "training-vsa"
@@ -388,7 +372,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "slurm"
- label: ":test_tube: Inference Tests VMoBA"
key: "inference-vmoba"
@@ -411,7 +394,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":test_tube: Performance Tests"
key: "performance"
@@ -434,7 +416,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "slurm"
- label: ":test_tube: API Server Tests"
key: "api-server"
@@ -457,7 +438,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":test_tube: Train Framework Tests"
key: "train-framework"
@@ -480,7 +460,6 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
- label: ":test_tube: Eval Metrics Tests"
key: "eval"
@@ -503,4 +482,3 @@ steps:
limit: 2
agents:
queue: "ci-runner"
backend: "${FV_SPARK_BACKEND:-slurm}"
+1 -1
View File
@@ -75,7 +75,7 @@ if [[ $machine_arch =~ ^(aarch64|arm64)$ ]]; then
npm run e2e -- \
--project=chromium \
--project=mobile-chromium \
--grep-invert='streams, plays, and surfaces a downloadable clip|starts a new project and switches back to the prior session|saved projects persist across a page reload|sends validated assets and plays a clearly labeled sample'
--grep-invert='streams, plays, and surfaces a downloadable clip|starts a new project and switches back to the prior session|saved projects persist across a page reload'
else
BACKEND_HOST=127.0.0.1 BACKEND_PORT="$BACKEND_PORT" CI=1 \
npm run e2e -- \
+1 -1
View File
@@ -2,4 +2,4 @@
# Canonical Slurm CI selection for the VMoBA-inference lane.
set -euo pipefail
exec pytest ./fastvideo/tests/inference/vmoba/test_vmoba_inference.py -vs
exec python fastvideo/tests/inference/vmoba/test_vmoba_inference.py
+2 -5
View File
@@ -9,12 +9,9 @@ mkdir -p "$PERF_REPORTS_DIR"
if [[ ${BUILDKITE_PULL_REQUEST:-false} =~ ^[1-9][0-9]*$ ]]; then
export PERF_RUN_SOURCE=pr
export PERF_UPLOAD_POLICY=never
export PERF_UPLOAD_POLICY=pass
elif [ "${BUILDKITE_BRANCH:-}" = main ] \
&& { [ "${BUILDKITE_SOURCE:-}" = schedule ] || [ "${TEST_SCOPE:-}" = full ] \
|| { [ "${BUILDKITE_SOURCE:-}" = api ] \
&& [ "${BUILDKITE_PIPELINE_SLUG:-}" = fastvideo-performance-lane ] \
&& [ "${TEST_SCOPE:-}" = direct ]; }; }; then
&& { [ "${BUILDKITE_SOURCE:-}" = schedule ] || [ "${TEST_SCOPE:-}" = full ]; }; then
export PERF_RUN_SOURCE=scheduled_main
export PERF_UPLOAD_POLICY=always
elif [ "${TEST_SCOPE:-}" = direct ]; then
-2
View File
@@ -24,7 +24,6 @@ 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 \
@@ -32,7 +31,6 @@ exec pytest \
./fastvideo/tests/inference/test_inference_regional_compile.py \
./fastvideo/tests/attention/ \
./fastvideo/tests/layers/test_pdd_linear.py \
./fastvideo/tests/layers/test_lora_side_branch.py \
./fastvideo/tests/layers/test_triton_fused_norm.py \
./fastvideo/tests/modal/test_kernel_build_cache.py \
./fastvideo/tests/modal/test_pr_test.py \
-22
View File
@@ -9,28 +9,6 @@ merge_protections:
- check-success=fastcheck-passed
- check-success=full-suite-passed
# Mirrors the `paths` filter of .github/workflows/ci-macos-mlx.yml so the
# required checks always exist whenever this protection applies.
- name: MLX smoke requirements
if:
- base = main
- or:
- files~=^\.github/workflows/ci-macos-mlx\.yml$
- files~=^fastvideo/mlx_runtime/
- files~=^fastvideo/tests/mlx/
- files~=^fastvideo/tests/platforms/test_mps_vsa_error\.py$
- files~=^fastvideo/tests/platforms/test_cpu_sdpa\.py$
- files~=^fastvideo/platforms/cpu\.py$
- files~=^fastvideo/platforms/mps\.py$
- files~=^fastvideo/platforms/__init__\.py$
- files~=^fastvideo/__init__\.py$
- files~=^examples/inference/basic/mlx_[^/]*\.py$
- files~=^fastvideo/benchmarks/mlx_[^/]*\.py$
- files~=^pyproject\.toml$
success_conditions:
- check-success=mlx-smoke
- check-success=mlx-smoke-linux-cpu
pull_request_rules:
# ============================================================
+4 -9
View File
@@ -104,9 +104,9 @@ SAFE_PATTERNS = (
"comfyui/**",
"docs/**",
"examples/**",
"docs-site/**",
"requirements-docs.in",
"requirements-docs.txt",
"mkdocs.yml",
"requirements-mkdocs.in",
"requirements-mkdocs.txt",
"scripts/**",
"tests/__init__.py",
"tests/local_tests/**",
@@ -131,11 +131,6 @@ class FamilyCoverage:
FAMILY_COVERAGE = (
FamilyCoverage(
re.compile(r"(^|[/_.-])qwen[_-]?image[_-]?2[._-]?1([/_.-]|$)"),
("test_qwen_image21.py", ),
("test_qwen_image21_similarity.py", ),
),
FamilyCoverage(
re.compile(r"(^|[/_.-])flux[_-]?2([/_.-]|$)"),
("test_flux2_klein.py", ),
@@ -326,7 +321,7 @@ def classify_paths(paths: list[str], removed_paths: Iterable[str] = ()) -> Merge
plan.require_all("changed-file API failed; failing closed")
continue
if path in {"requirements-docs.in", "requirements-docs.txt"}:
if path in {"requirements-mkdocs.in", "requirements-mkdocs.txt"}:
plan.reasons.append(f"documentation dependencies need no GPU integration: {path}")
continue
-23
View File
@@ -2,9 +2,6 @@ name: macOS MLX Smoke
on:
pull_request:
# The defaults plus ready_for_review: both jobs skip drafts, and Mergify requires them
# for MLX changes, so marking a draft ready must run them.
types: [opened, synchronize, reopened, ready_for_review]
branches: [main]
paths:
- ".github/workflows/ci-macos-mlx.yml"
@@ -88,19 +85,9 @@ jobs:
fastvideo/tests/mlx/test_mlx_affine_dq_gemm.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_parity.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_sdpa.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_nax.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_regressions.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_prefix_kv.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_mode.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_spatial.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_ref_layout.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_pdd.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_reference_prep.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_ref2va_pipeline.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_ref_conditioning.py \
fastvideo/tests/mlx/test_mlx_qwen3_vl_vision.py \
fastvideo/tests/mlx/test_mlx_fasth3_omniref_example.py \
fastvideo/tests/mlx/test_mlx_fastwan_benchmark.py \
fastvideo/tests/mlx/test_taehv_decode.py \
fastvideo/tests/mlx/test_frame_upsample.py \
@@ -163,19 +150,9 @@ jobs:
fastvideo/tests/mlx/test_mlx_affine_dq_gemm.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_parity.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_sdpa.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_nax.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_vsa_regressions.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_prefix_kv.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_mode.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_fast_spatial.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_ref_layout.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_pdd.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_reference_prep.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_ref2va_pipeline.py \
fastvideo/tests/mlx/test_mlx_minimax_h3_ref_conditioning.py \
fastvideo/tests/mlx/test_mlx_qwen3_vl_vision.py \
fastvideo/tests/mlx/test_mlx_fasth3_omniref_example.py \
fastvideo/tests/mlx/test_mlx_fastwan_benchmark.py \
fastvideo/tests/mlx/test_taehv_decode.py \
fastvideo/tests/mlx/test_frame_upsample.py \
@@ -1,73 +0,0 @@
name: Scheduled Performance
on:
schedule:
# Sunday 23:00 America/Los_Angeles, including daylight saving time.
- cron: "0 6 * * 1"
- cron: "0 7 * * 1"
workflow_dispatch:
permissions:
contents: read
jobs:
trigger:
if: github.repository == 'hao-ai-lab/FastVideo' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Select the Pacific-time schedule
id: schedule
env:
EVENT_NAME: ${{ github.event_name }}
SCHEDULE_EXPR: ${{ github.event.schedule }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == schedule ]]; then
expected='0 7 * * 1'
if [[ "$(TZ=America/Los_Angeles date +%z)" == -0700 ]]; then
expected='0 6 * * 1'
fi
if [[ "$SCHEDULE_EXPR" != "$expected" ]]; then
echo 'run=false' >> "$GITHUB_OUTPUT"
exit 0
fi
fi
echo 'run=true' >> "$GITHUB_OUTPUT"
- name: Trigger weekly performance on Slinky Slurm
if: steps.schedule.outputs.run == 'true'
env:
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
SOURCE_SHA: ${{ github.sha }}
SOURCE_BRANCH: ${{ github.event.repository.default_branch }}
BK_ORG: ${{ vars.BUILDKITE_ORG_SLUG }}
BK_PIPELINE: fastvideo-performance-lane
run: |
set -euo pipefail
if [[ ${#SOURCE_SHA} -ne 40 || "$SOURCE_SHA" == *[!0-9a-f]* ]]; then
echo 'SOURCE_SHA must be a full immutable commit SHA' >&2
exit 1
fi
payload=$(python3 - <<'PY'
import json
import os
print(json.dumps({
"commit": os.environ["SOURCE_SHA"],
"branch": os.environ["SOURCE_BRANCH"],
"message": "Weekly performance on Slinky Slurm",
"ignore_pipeline_branch_filters": True,
"env": {
"TEST_SCOPE": "direct",
"FULL_SUITE": "false",
"TEST_TYPE": "performance",
"PR_NUMBER": "false",
"PR_TITLE": "Scheduled performance",
},
}))
PY
)
curl -sS --fail-with-body --connect-timeout 10 --max-time 60 -X POST \
"https://api.buildkite.com/v2/organizations/${BK_ORG}/pipelines/${BK_PIPELINE}/builds" \
-H "Authorization: Bearer $BUILDKITE_API_TOKEN" \
-H "Content-Type: application/json" \
--data-raw "$payload"
@@ -1,4 +1,4 @@
name: Scheduled Full Suite
name: Scheduled Full SSIM
on:
schedule:
@@ -13,7 +13,7 @@ jobs:
if: github.repository == 'hao-ai-lab/FastVideo'
runs-on: ubuntu-latest
steps:
- name: Trigger weekly full suite on Slinky Slurm
- name: Trigger weekly full SSIM on Slinky Slurm
env:
BUILDKITE_API_TOKEN: ${{ secrets.BUILDKITE_API_TOKEN }}
SOURCE_SHA: ${{ github.sha }}
@@ -32,13 +32,13 @@ jobs:
'{
commit: $commit,
branch: $branch,
message: "Weekly full suite on main",
message: "Weekly full SSIM on Slinky Slurm",
ignore_pipeline_branch_filters: true,
env: {
TEST_SCOPE: "full",
FULL_SUITE: "true",
TEST_TYPE: "all",
TEST_SCOPE: "scheduled",
FULL_SUITE: "false",
TEST_TYPE: "ssim",
PR_NUMBER: "false",
PR_TITLE: "Scheduled full suite"
PR_TITLE: "Scheduled full SSIM"
}
}')"
+2 -3
View File
@@ -22,9 +22,8 @@ jobs:
trigger:
if: >-
inputs.pr_number > 0
|| (github.event.pull_request.base.ref == github.event.repository.default_branch
&& ((github.event.action == 'labeled' && github.event.label.name == 'ready')
|| github.event.action == 'synchronize'))
|| (github.event.action == 'labeled' && github.event.label.name == 'ready')
|| github.event.action == 'synchronize'
runs-on: ubuntu-latest
# Job-level concurrency: only this guarded job acquires the group, so an
# unrelated `labeled` event (which skips the job) cannot cancel an in-flight
+34
View File
@@ -0,0 +1,34 @@
name: Close Stale Issues and PRs
on:
schedule:
# Daily at 1:30 AM UTC
- cron: '30 1 * * *'
jobs:
stale:
if: github.repository == 'hao-ai-lab/FastVideo'
permissions:
issues: write
pull-requests: write
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
operations-per-run: 500
exempt-draft-pr: true
exempt-issue-labels: 'keep-open,pinned,security,Bug,RFC'
exempt-pr-labels: 'keep-open,pinned'
labels-to-add-when-unstale: 'unstale'
labels-to-remove-when-stale: 'unstale'
days-before-issue-stale: 90
days-before-issue-close: 30
stale-issue-label: 'stale'
days-before-pr-stale: 60
days-before-pr-close: 14
stale-pr-label: 'stale'
+2 -22
View File
@@ -56,9 +56,7 @@ jobs:
# CUDA matrix: Python 3.12 x {12.6.3, 13.0.0} x {amd64, arm64}. Each architecture
# builds natively and pushes only by digest; publish-cuda-manifests is the sole
# owner of the shared tags. CUDA 12.6 arm64 targets Hopper (sm_90a / GH200 class),
# because CUDA 12.6 cannot compile sm_121. CUDA 13 arm64 targets GB10 as 12.1a
# (sm_121a), as a native Spark build does: the plain 12.1 target drops the
# arch-specific attn_qat_infer FP4 kernels.
# because CUDA 12.6 cannot compile sm_121. CUDA 13 arm64 targets GB10 (sm_121).
# 12.6.3/cu126 owns the default `py3.12`/`latest` tags and keeps its versioned
# aliases; 13.0.0/cu130 is published under explicit versioned tags. Flash-attn
# 2.8.3 comes from the architecture-specific prebuilt releases.
@@ -80,12 +78,6 @@ jobs:
torch_cuda_arch_list:
amd64: '9.0a'
arm64: '9.0a'
install_flash_attn:
amd64: '1'
arm64: '1'
install_fa4_cute:
amd64: '1'
arm64: '1'
- version: '13.0.0'
suffix: '-cuda13.0.0'
torch_backend: 'cu130'
@@ -93,17 +85,7 @@ jobs:
cmake_build_parallel_level: '1'
torch_cuda_arch_list:
amd64: '9.0a'
arm64: '12.1a'
# FA4 cute does not run on GB10 (sm_12x), and its cutlass-dsl pin
# breaks flashinfer there; GB10 FP4 attention uses fastvideo-kernel.
# FA2 gives no measured GB10 speedup and changes the output relative
# to a native Spark install, so the GB10 image matches that install.
install_flash_attn:
amd64: '1'
arm64: '0'
install_fa4_cute:
amd64: '1'
arm64: '0'
arm64: '12.1'
architecture:
- name: amd64
runner: ubuntu-latest
@@ -123,8 +105,6 @@ jobs:
CUDA_VERSION=${{ matrix.cuda.version }}
UV_TORCH_BACKEND=${{ matrix.cuda.torch_backend }}
TORCH_CUDA_ARCH_LIST=${{ matrix.cuda.torch_cuda_arch_list[matrix.architecture.name] }}
INSTALL_FLASH_ATTN=${{ matrix.cuda.install_flash_attn[matrix.architecture.name] }}
INSTALL_FA4_CUTE=${{ matrix.cuda.install_fa4_cute[matrix.architecture.name] }}
CMAKE_BUILD_PARALLEL_LEVEL=${{ matrix.cuda.cmake_build_parallel_level }}
FLASH_ATTN_WHEEL_TAG=${{ matrix.cuda.fa_tag }}
FLASH_ATTN_WHEEL_RELEASE=https://github.com/mjun0812/flash-attention-prebuild-wheels/releases/download/v0.9.17
+12 -48
View File
@@ -7,11 +7,9 @@ on:
- 'docs/**'
- 'examples/**'
- 'scripts/inference/**'
- 'docs-site/**'
- 'fastvideo/**'
- 'README.md'
- 'requirements-docs.in'
- 'requirements-docs.txt'
- 'mkdocs.yml'
- 'requirements-mkdocs.in'
- 'requirements-mkdocs.txt'
- 'scripts/check_docs_links.py'
- '.github/workflows/infra-docs.yml'
pull_request:
@@ -20,11 +18,9 @@ on:
- 'docs/**'
- 'examples/**'
- 'scripts/inference/**'
- 'docs-site/**'
- 'fastvideo/**'
- 'README.md'
- 'requirements-docs.in'
- 'requirements-docs.txt'
- 'mkdocs.yml'
- 'requirements-mkdocs.in'
- 'requirements-mkdocs.txt'
- 'scripts/check_docs_links.py'
- '.github/workflows/infra-docs.yml'
@@ -39,7 +35,6 @@ concurrency:
jobs:
build:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -56,7 +51,7 @@ jobs:
uses: astral-sh/setup-uv@v3
- name: Install dependencies
run: uv pip install --system -r requirements-docs.txt
run: uv pip install --system -r requirements-mkdocs.txt
- name: Setup Pages
uses: actions/configure-pages@v4
@@ -64,16 +59,10 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: |
docs/package-lock.json
docs-site/package-lock.json
node-version: '22'
- name: Install cookbook dependencies
run: |
npm ci --prefix docs
npm ci --prefix docs-site
run: npm ci --prefix docs
- name: Validate and generate cookbook catalogs
run: |
@@ -82,25 +71,8 @@ jobs:
python docs/tests/test_cookbook_assets.py
npm run build:catalog --prefix docs
- name: Check site types and formatting
run: |
npm run check --prefix docs-site
npm run format:check --prefix docs-site
- name: Build and validate documentation
run: |
npm run build --prefix docs-site
npm run validate --prefix docs-site
npm run test:content --prefix docs-site
python -m unittest discover -s docs-site/tests -p 'test_*.py'
python docs/check_cookbook_assets.py docs-site/dist
- name: Install test browser
working-directory: docs-site
run: npx playwright install --with-deps chromium
- name: Test documentation and cookbook interactions
run: npm test --prefix docs-site
- name: Build documentation
run: mkdocs build
- name: Check docs links
run: python scripts/check_docs_links.py
@@ -108,15 +80,7 @@ jobs:
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./docs-site/dist
- name: Upload browser failure traces
if: failure()
uses: actions/upload-artifact@v4
with:
name: docs-browser-results
path: docs-site/test-results
if-no-files-found: ignore
path: ./site
deploy:
environment:
+2 -44
View File
@@ -12,52 +12,10 @@ permissions:
issues: write
jobs:
# Any pyproject.toml edit triggers this workflow, but the registry rejects a
# version it already has ("The node version already exists"). Publish only
# when [project].version changed, as the PyPI workflow does.
check-version-change:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'hao-ai-lab' }}
outputs:
changed: ${{ steps.check.outputs.changed }}
steps:
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Compare [project].version with the version before this push
id: check
shell: python
env:
BEFORE_SHA: ${{ github.event.before }}
run: |
import os
import subprocess
import tomllib
def version(text: str) -> str | None:
return tomllib.loads(text).get("project", {}).get("version")
def git(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", *args], capture_output=True, text=True)
# A push can carry several commits; compare with the tip before the push, not HEAD~1.
base = os.environ.get("BEFORE_SHA", "").strip()
if not base or set(base) == {"0"} or git("fetch", "--no-tags", "--depth=1", "origin", base).returncode:
base = "HEAD~1"
new = version(open("pyproject.toml", encoding="utf-8").read())
previous = git("show", f"{base}:pyproject.toml")
old = version(previous.stdout) if previous.returncode == 0 else None
changed = new is not None and new != old
print(f"base: {base}, previous version: {old}, current version: {new}, changed: {changed}")
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"changed={str(changed).lower()}\n")
publish-node:
name: Publish Custom Node to registry
needs: check-version-change
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'hao-ai-lab' && (needs.check-version-change.outputs.changed == 'true' || github.event_name == 'workflow_dispatch') }}
if: ${{ github.repository_owner == 'hao-ai-lab' }}
steps:
- name: Check out code
uses: actions/checkout@v4
@@ -67,4 +25,4 @@ jobs:
uses: Comfy-Org/publish-node-action@v1
with:
## Add your own personal access token to your Github Repository secrets and reference it here.
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+3 -1
View File
@@ -52,8 +52,9 @@ dist/
eggs/
.eggs/
# Generated documentation
# MkDocs documentation
site/
docs/assets/cookbook-serving.json
docs/assets/cookbook-config/
examples/serving/clients/node_modules/
docs/node_modules/
@@ -62,6 +63,7 @@ docs/examples/
docs/inference/examples/
docs/training/examples/
docs/distillation/examples/
!requirements-mkdocs.txt
# VSCode
.vscode/
-1
View File
@@ -21,7 +21,6 @@ repos:
rev: v0.43.0
hooks:
- id: yapf
require_serial: true
args: [--in-place, --verbose]
language_version: python3.12
additional_dependencies: [toml] # TODO: Remove when yapf is upgraded
+1 -1
View File
@@ -6,7 +6,7 @@
- Tests:
- `fastvideo/tests/` for package-level tests (dataset, encoders, inference, training, SSIM, workflow).
- `tests/local_tests/` for additional local/component checks.
- Docs and guides: canonical Markdown in `docs/`, the Blume app in `docs-site/`, and contributor docs in `docs/contributing/`.
- Docs and guides: `docs/` (MkDocs source), with contributor docs in `docs/contributing/`.
- Runnable examples and scripts: `examples/` and `scripts/`.
- Static assets: `assets/` (including `assets/images/`, `assets/videos/`, and `assets/prompts/`) and `comfyui/assets/`.
@@ -48,7 +48,7 @@ class MiniMaxH3GenerationBackend:
"FASTVIDEO_ATTENTION_BACKEND": attention_backend,
"FASTVIDEO_FA4": "1",
"FASTVIDEO_MINIMAX_H3_FUSIONS": "all",
"FASTVIDEO_VSA_PTX_BLACKWELL": "0",
"FASTVIDEO_VSA_SM100A": "0",
})
os.environ.pop("FASTVIDEO_INFERENCE_TORCH_COMPILE", None)
@@ -73,7 +73,7 @@ def test_initialize_builds_vsa_datafree_fasth3_generator(monkeypatch):
monkeypatch.setenv("FASTVIDEO_ATTENTION_BACKEND", "test-attention")
monkeypatch.setenv("FASTVIDEO_FA4", "0")
monkeypatch.setenv("FASTVIDEO_MINIMAX_H3_FUSIONS", "0")
monkeypatch.setenv("FASTVIDEO_VSA_PTX_BLACKWELL", "1")
monkeypatch.setenv("FASTVIDEO_VSA_SM100A", "1")
monkeypatch.setenv("FASTVIDEO_INFERENCE_TORCH_COMPILE", "1")
backend = MiniMaxH3GenerationBackend(gpu_id=0)
@@ -108,7 +108,7 @@ def test_initialize_builds_vsa_datafree_fasth3_generator(monkeypatch):
assert os.environ["FASTVIDEO_ATTENTION_BACKEND"] == "VIDEO_SPARSE_ATTN_H3"
assert os.environ["FASTVIDEO_FA4"] == "1"
assert os.environ["FASTVIDEO_MINIMAX_H3_FUSIONS"] == "all"
assert os.environ["FASTVIDEO_VSA_PTX_BLACKWELL"] == "0"
assert os.environ["FASTVIDEO_VSA_SM100A"] == "0"
assert "FASTVIDEO_INFERENCE_TORCH_COMPILE" not in os.environ
@@ -3,37 +3,6 @@ import { stat } from 'node:fs/promises';
import { test, expect, type WebSocket as PWWebSocket } from '@playwright/test';
test.describe('mock-backed generation smoke', () => {
test('leave dialog blocks navigation and keeps cancel and confirm usable', async ({ page, request }) => {
const health = await request.get('/healthz');
const body = health.ok() ? await health.json() : {};
test.skip(body.service !== 'ltx2-streaming-mock-server', 'Requires the local mock runtime.');
await page.goto('/');
await page.getByLabel('Continuation prompt').fill('A paper fox walks through a sunlit forest.');
await page.getByRole('button', { name: 'Generate', exact: true }).click();
const leave = page.getByRole('button', { name: 'Leave', exact: true });
await expect(leave).toBeEnabled();
await leave.click();
const dialog = page.getByRole('dialog', { name: 'Leave session?' });
await expect(dialog).toBeVisible();
expect(await dialog.evaluate((element) => element.closest('.isolate') === null)).toBe(true);
await expect(page.getByRole('button', { name: 'Toggle sidebar' }).click({ trial: true, timeout: 1000 })).rejects.toThrow();
for (let index = 0; index < 4; index++) {
await page.keyboard.press('Tab');
await expect.poll(() => dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true);
}
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(leave).toBeFocused();
await leave.click();
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await expect(leave).toBeFocused();
await leave.click();
await dialog.getByRole('button', { name: 'Leave session', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Generate', exact: true })).toBeVisible();
});
test('accepts a custom prompt and enters the streaming state', async ({ page, request }) => {
const health = await request.get('/healthz');
const body = health.ok() ? await health.json() : {};
+1 -1
View File
@@ -2739,7 +2739,7 @@ export default function Page() {
<Header timeLeft={headerTimeLeft} formatTime={formatTime} onToggleSidebar={() => setSidebarOpen((prev) => !prev)} />
<div className={cn(
"relative isolate flex flex-1 min-h-0 flex-col px-4 pb-2 sm:px-6 sm:pb-12",
"relative flex flex-1 min-h-0 flex-col px-4 pb-2 sm:px-6 sm:pb-12",
!isViewingMode && !showActiveProject && generationMode !== "t2va"
? "justify-start overflow-y-auto pt-4"
: "justify-center",
@@ -1,7 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
@@ -20,7 +19,6 @@ export default function LeaveSessionModal({
onConfirmLeave = () => {},
}: LeaveSessionModalProps) {
const [suppress, setSuppress] = useState(false);
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) setSuppress(false);
@@ -38,58 +36,43 @@ export default function LeaveSessionModal({
}
return (
<DialogPrimitive.Root open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay asChild>
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/45 px-4 backdrop-blur-[3px]">
<DialogPrimitive.Content
asChild
onInteractOutside={(event) => event.preventDefault()}
onOpenAutoFocus={() => {
previousFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
if (previousFocus.current?.isConnected) previousFocus.current.focus();
}}
>
<div
className="w-full max-w-md rounded-3xl border border-border/70 bg-card/95 p-6 shadow-2xl"
>
<div className="space-y-3">
<div className="space-y-1">
<DialogPrimitive.Title className="text-lg font-semibold text-foreground">
Leave session?
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-muted-foreground">
This will end your current session. You will need to queue again for a GPU to start a new project.
</DialogPrimitive.Description>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="suppress-leave-warning"
checked={suppress}
onCheckedChange={(v) => setSuppress(v === true)}
/>
<Label htmlFor="suppress-leave-warning" className="text-sm text-muted-foreground cursor-pointer">
Do not warn again
</Label>
</div>
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirm}>
Leave session
</Button>
</div>
</div>
</DialogPrimitive.Content>
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/45 px-4 backdrop-blur-[3px]">
<div
role="dialog"
aria-modal="true"
aria-labelledby="leave-session-title"
className="w-full max-w-md rounded-3xl border border-border/70 bg-card/95 p-6 shadow-2xl"
>
<div className="space-y-3">
<div className="space-y-1">
<h2 id="leave-session-title" className="text-lg font-semibold text-foreground">
Leave session?
</h2>
<p className="text-sm text-muted-foreground">
This will end your current session. You will need to queue again for a GPU to start a new project.
</p>
</div>
</DialogPrimitive.Overlay>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
<div className="flex items-center gap-2">
<Checkbox
id="suppress-leave-warning"
checked={suppress}
onCheckedChange={(v) => setSuppress(v === true)}
/>
<Label htmlFor="suppress-leave-warning" className="text-sm text-muted-foreground cursor-pointer">
Do not warn again
</Label>
</div>
</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirm}>
Leave session
</Button>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -276,7 +276,7 @@ any GPU work begins. The model directory needs `transformer`, `text_encoder`,
and `modular_model_index.json`. See [Download weights](#download-weights) for
the complete checkpoint.
**`FastH3's ptx_blackwell route needs fastvideo-kernel built with the Blackwell VSA
**`FastH3's sm100a route needs fastvideo-kernel built with the Blackwell VSA
extension`.** Startup checks for the fast sparse-attention kernel before
loading any weights. Follow [Install](#install) to build it, or set
`inference.vsa_kernel: triton` in your config to use the slower fallback.
@@ -193,14 +193,6 @@ class FastH3Backend:
# --------------------------------------------------------------- profile
def _vsa_kernel(self) -> str:
"""inference.vsa_kernel; the pre-rename value sm100a still selects ptx_blackwell, with a warning."""
kernel = str(self._config.inference.get("vsa_kernel", "ptx_blackwell"))
if kernel == "sm100a":
logger.warning("inference.vsa_kernel: sm100a is deprecated; use ptx_blackwell")
return "ptx_blackwell"
return kernel
def _apply_profile_environment(self) -> None:
"""Set the FastH3 profile environment, as the reference CLI does.
@@ -209,11 +201,11 @@ class FastH3Backend:
experiment settings cannot silently change what gets served.
"""
cfg = self._config.inference
vsa_kernel = self._vsa_kernel()
vsa_kernel = str(cfg.get("vsa_kernel", "sm100a"))
fusions = "all" if bool(cfg.get("h3_fusions", True)) else "0"
environment: dict[str, str | None] = {
"FASTVIDEO_ATTENTION_BACKEND": "VIDEO_SPARSE_ATTN_H3",
"FASTVIDEO_VSA_PTX_BLACKWELL": "1" if vsa_kernel == "ptx_blackwell" else "0",
"FASTVIDEO_VSA_SM100A": "1" if vsa_kernel == "sm100a" else "0",
"FASTVIDEO_VSA_CUTEDSL": "0",
# A non-empty path enables the diagnostic probe; it must stay unset.
"FASTVIDEO_H3_VSA_PROBE": None,
@@ -249,15 +241,15 @@ class FastH3Backend:
if not present:
raise RuntimeError("FastH3's FA4 route needs the pinned flash-attn-4 package. Install it, "
"or set inference.fa4: false in your config.")
if self._vsa_kernel() == "ptx_blackwell":
if str(cfg.get("vsa_kernel", "sm100a")) == "sm100a":
try:
from fastvideo_kernel import block_sparse_attn_ptx_blackwell
from fastvideo_kernel import block_sparse_attn_sm100a
except ImportError:
present = False
else:
present = bool(getattr(block_sparse_attn_ptx_blackwell, "_HAS_VSA_PTX_BLACKWELL", False))
present = bool(getattr(block_sparse_attn_sm100a, "_HAS_VSA_SM100A", False))
if not present:
raise RuntimeError("FastH3's ptx_blackwell route needs fastvideo-kernel built with the Blackwell VSA "
raise RuntimeError("FastH3's sm100a route needs fastvideo-kernel built with the Blackwell VSA "
"extension. Install a matching wheel, or set inference.vsa_kernel: triton.")
def _generator_config(self) -> Any:
@@ -26,8 +26,8 @@ inference:
vsa_sparsity: 0.9
vsa_tile_size: 64
# ptx_blackwell is the Blackwell VSA kernel; `triton` is the ~2.5x slower fallback.
vsa_kernel: ptx_blackwell
# sm100a is the Blackwell VSA kernel; `triton` is the ~2.5x slower fallback.
vsa_kernel: sm100a
fa4: true
h3_fusions: true
inference_torch_compile: true
+6 -30
View File
@@ -20,12 +20,10 @@
# --build-arg FLASH_ATTN_WHEEL_TAG=cu130torch2.12 \
# -t fastvideo-dev:py3.12-cuda13.0.0 .
#
# # DGX Spark / GB10 (aarch64, Blackwell sm_121a): the matching arm64
# # DGX Spark / GB10 (aarch64, Blackwell sm_121): the matching arm64
# # prebuilt flash-attn wheel is selected automatically from TARGETARCH.
# docker build --platform linux/arm64 -f docker/Dockerfile \
# --build-arg TORCH_CUDA_ARCH_LIST=12.1a \
# --build-arg INSTALL_FLASH_ATTN=0 --build-arg INSTALL_FA4_CUTE=0 \
# -t fastvideo-dev:spark .
# --build-arg TORCH_CUDA_ARCH_LIST=12.1 -t fastvideo-dev:spark .
#
# The ARG defaults below are the source of truth for the default image.
# ROCm builds use docker/rocm7_1.Dockerfile (different base + flow).
@@ -47,7 +45,7 @@ ARG PYTHON_VERSION=3.12
# CUDA arch fastvideo-kernel is compiled for. Build machines have no GPU, so
# build.sh uses this instead of probing a live device. Default Hopper (sm_90a),
# matching the released kernel wheel; override e.g. TORCH_CUDA_ARCH_LIST=12.1a
# matching the released kernel wheel; override e.g. TORCH_CUDA_ARCH_LIST=12.1
# for GB10/Blackwell (DGX Spark).
ARG TORCH_CUDA_ARCH_LIST=9.0a
@@ -62,9 +60,6 @@ ARG CMAKE_BUILD_PARALLEL_LEVEL=1
# cuXXXtorchY.Y tag must match UV_TORCH_BACKEND (below): cu130torch2.12 for the
# default 13.0 base, cu126torch2.12 for 12.6 (both backends are on torch 2.12).
ARG FLASH_ATTN_VERSION=2.8.3
# INSTALL_FLASH_ATTN=0 leaves FA2 out (the DGX Spark image): on GB10 it gives no
# measured speedup and changes the output relative to a native Spark install.
ARG INSTALL_FLASH_ATTN=1
ARG FLASH_ATTN_WHEEL_TAG=cu130torch2.12
ARG FLASH_ATTN_WHEEL_RELEASE=https://github.com/mjun0812/flash-attention-prebuild-wheels/releases/download/v0.9.17
ARG FLASH_ATTN_WHEEL_RELEASE_ARM64=https://github.com/mjun0812/flash-attention-prebuild-wheels/releases/download/v0.9.22
@@ -74,11 +69,6 @@ ARG FLASH_ATTN_WHEEL_RELEASE_ARM64=https://github.com/mjun0812/flash-attention-p
# flashinfer/quack pull in. After the wheel install we overlay this cutlass-4.5-safe
# upstream cute (flash-attn-4) so the image runs FA4 instead of the FA2 fallback.
ARG FA4_CUTE_REF=14c377950125c70b7a9dabf9c561fca53715ac7d
# FA4 cute supports SM 9/10/11 only; GB10 (sm_121) runs FP4 attention through
# fastvideo-kernel instead. INSTALL_FA4_CUTE=0 skips the overlay so the image
# keeps the cutlass-dsl that flashinfer resolves: this revision pins
# nvidia-cutlass-dsl==4.6.0.dev0, which flashinfer 0.7.2 cannot import against.
ARG INSTALL_FA4_CUTE=1
# Provided automatically by BuildKit/buildx (e.g. "amd64" / "arm64") and used to
# select the prebuilt flash-attn wheel. Empty under a plain `docker build` without
@@ -182,9 +172,7 @@ RUN --mount=type=cache,target=/opt/uv/cache \
exit 1 \
;; \
esac && \
if [ "${INSTALL_FLASH_ATTN}" != "1" ]; then \
echo "Skipping flash-attn (INSTALL_FLASH_ATTN=${INSTALL_FLASH_ATTN})"; \
elif [ -n "${FLASH_ATTN_WHEEL_TAG}" ]; then \
if [ -n "${FLASH_ATTN_WHEEL_TAG}" ]; then \
echo "Installing prebuilt flash-attn wheel (${FLASH_ATTN_WHEEL_TAG}, ${PYTAG}, ${FLASH_ATTN_WHEEL_PLATFORM})" && \
uv pip install "${FLASH_ATTN_TARGET_RELEASE}/flash_attn-${FLASH_ATTN_VERSION}+${FLASH_ATTN_WHEEL_TAG}-${PYTAG}-${PYTAG}-${FLASH_ATTN_WHEEL_PLATFORM}.whl"; \
else \
@@ -203,24 +191,12 @@ RUN --mount=type=cache,target=/opt/uv/cache \
# an FA2-less image. The pinned stack is validated on ARM64 GB200 (sm_100) as well
# as x86; FA4 remains opt-in through FASTVIDEO_FA4=1 so lanes with FA2 baselines
# keep their existing numerics.
# With INSTALL_FA4_CUTE=0 any wheel's stale cute is still removed, so
# `import flash_attn.cute` fails cleanly and FastVideo's guarded import falls back.
RUN --mount=type=cache,target=/opt/uv/cache \
source $HOME/.local/bin/env && \
source /opt/venv/bin/activate && \
python -c "import glob, shutil; [shutil.rmtree(d, ignore_errors=True) for d in glob.glob('/opt/venv/lib/python*/site-packages/flash_attn/cute')]" && \
if [ "${INSTALL_FA4_CUTE}" = "1" ] && [ "${INSTALL_FLASH_ATTN}" != "1" ]; then \
echo "INSTALL_FA4_CUTE=1 overlays the FA2 install; it requires INSTALL_FLASH_ATTN=1" >&2; \
exit 1; \
elif [ "${INSTALL_FA4_CUTE}" = "1" ]; then \
uv pip install "flash-attn-4 @ git+https://github.com/Dao-AILab/flash-attention.git@${FA4_CUTE_REF}#subdirectory=flash_attn/cute" && \
python -c "import flash_attn; assert hasattr(flash_attn, 'flash_attn_func'), 'FA2 was clobbered by the cute overlay'; import flash_attn.cute; print('FA2 + FA4 cute OK')"; \
else \
python -c "import flashinfer; print('flashinfer', flashinfer.__version__, 'OK; FA4 cute skipped')" && \
if [ "${INSTALL_FLASH_ATTN}" = "1" ]; then \
python -c "import flash_attn; assert hasattr(flash_attn, 'flash_attn_func'); print('FA2 OK')"; \
fi; \
fi
uv pip install "flash-attn-4 @ git+https://github.com/Dao-AILab/flash-attention.git@${FA4_CUTE_REF}#subdirectory=flash_attn/cute" && \
python -c "import flash_attn; assert hasattr(flash_attn, 'flash_attn_func'), 'FA2 was clobbered by the cute overlay'; import flash_attn.cute; print('FA2 + FA4 cute OK')"
COPY . .
-11
View File
@@ -1,11 +0,0 @@
# syntax=docker/dockerfile:1.7
# CI-only GB10 image. The regular Spark image keeps its native SDPA defaults.
ARG SPARK_BASE_IMAGE=ghcr.io/hao-ai-lab/fastvideo/fastvideo-dev:py3.12-cuda13.0.0-latest
FROM ${SPARK_BASE_IMAGE}
# VMoBA and the Wan causal gate require FA2. Install only this wheel so the
# validated torch/flashinfer/CUTLASS stack from the base image stays intact.
ARG FLASH_ATTN_WHEEL=https://github.com/mjun0812/flash-attention-prebuild-wheels/releases/download/v0.9.22/flash_attn-2.8.3+cu130torch2.12-cp312-cp312-linux_aarch64.whl
RUN /root/.local/bin/uv pip install --python /opt/venv/bin/python --no-deps "${FLASH_ATTN_WHEEL}" && \
/opt/venv/bin/python -c "import glob, shutil; [shutil.rmtree(p) for p in glob.glob('/opt/venv/lib/python*/site-packages/flash_attn/cute')]" && \
/opt/venv/bin/python -c "import flash_attn, flashinfer; assert hasattr(flash_attn, 'flash_attn_varlen_func'); print('FA2', flash_attn.__version__, 'flashinfer', flashinfer.__version__)"
-9
View File
@@ -1,9 +0,0 @@
node_modules/
.blume/
dist/
.env.local
public/
test-results/
playwright-report/
.generated/
.venv/
-10
View File
@@ -1,10 +0,0 @@
node_modules
.blume
dist
public
package-lock.json
test-results
playwright-report
.generated
.venv
migration-anchors.json
-117
View File
@@ -1,117 +0,0 @@
# Documentation and cookbook plan
Readers should be able to choose a model, find their hardware path, generate a
result, and then customize or train it without searching several sections for
the same instructions.
## One home for each kind of information
| Area | Question it answers | Owns |
| --------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Fast Models | Which FastVideo release should I choose? | Our released families, checkpoint exports, collections, short capability descriptions, links to recipes |
| Support matrix | What else can FastVideo run? | The wider supported model set and optimization compatibility |
| Hardware and runtimes | Where can I run it? | NVIDIA RTX/workstation and data center CUDA paths, DGX Spark, Apple MLX, AMD ROCm, platform requirements and limits |
| Documentation | How does FastVideo work? | Installation, the first generation, configuration, CLI, parallelism, offloading, integrations, training, architecture and API reference |
| Cookbook | How do I run this model for this task? | One family home, runtime choices, runnable recipes, model-specific configuration, troubleshooting and evidence |
Fast Models highlights our models without implying that FastVideo only supports
those releases. CUDA and ROCm are runtimes; RTX, data center GPUs and DGX Spark
are hardware paths. MLX is the Apple Silicon runtime. These distinctions should
be visible before readers choose an installation command.
## Included now
- One Hardware and runtimes overview links the five hardware paths. AMD is
explicitly source-backed support with incomplete installation/device coverage,
rather than a promise that CUDA recipes or kernels work on every AMD GPU.
- The getting-started sidebar contains Quick start, Installation, Hardware and
runtimes, and Python API basics.
- Run inference contains shared tasks. Hardware and Integrations are expandable
groups; individual hardware recipes do not interrupt onboarding.
- Model-specific references sit beside their family recipes in the Cookbook
sidebar. There is no separate Model guides list competing with the cookbook.
Existing LTX, Kandinsky and FastH3 reference URLs remain intact.
- Fast Models points FastH3 and FastWan readers to their cookbook families.
FastMetal also has a direct MLX setup entry.
- Generated examples have one visible page title, with the source link below it.
Their previous heading fragments remain available.
## The intended reader flow
1. Start at **Fast Models** to choose a released model, or the support matrix for
the broader catalog.
2. Check **Hardware and runtimes**, then follow the matching installation guide.
3. Open the model's **Cookbook family**. Choose the task and runtime, configure
a recipe, and run it.
4. Follow links to shared configuration, offloading or optimization docs when
more explanation is needed.
5. Move to training, distillation or contributor guides for development work.
The quick start remains a short route for readers who want a first result before
browsing the catalog.
## Next editorial pass
1. **Consolidate onboarding.** Keep one first-video tutorial. Remove repeated
installation blocks from the inference quick start after preserving its unique
image-conditioning examples. Make Installation a platform chooser and keep
installation commands in platform guides.
2. **Give every model one family home.** Expand the cookbook family page with a
short capability overview, runtime/task recipes, checkpoint choices, limits,
troubleshooting and next steps. Fold overlapping model-specific guides into
that page or link a detailed reference from it. Preserve old routes with
redirects before deleting any page.
3. **Separate explanation from recipes.** Hardware owns environment setup;
cookbook owns model commands; shared docs explain the options. The RTX PRO
6000 page's kernel internals and rejected experiments belong in research or
contributor material, separate from its runnable recipe.
4. **Reduce pointer pages and duplicate references.** Replace the two-sentence
ComfyUI pointer with a useful integration guide, or link directly to its
maintained README. Reconcile the two attention-backend development guides.
Let Architecture own the package map instead of repeating it in the agent guide.
5. **Split long optimization material by task.** Keep attention selection,
quantization, distributed execution and offloading cross-linked, with one
owner for each setting. Keep STA and mask search clearly archived.
6. **Fill the ROCm evidence gap.** Document a complete AMD installation and model
run with GPU architecture, ROCm/PyTorch versions, model revision, attention
backend, expected output and measured memory. Source implementation alone
should not turn into a broad device-support claim.
## Per-page structure
Task guides follow: outcome → requirements → runnable example → expected output
→ configuration → troubleshooting → next step. A model recipe adds its checkpoint,
workload, runtime, hardware evidence and measured limitations. Detailed reference
and implementation history should not interrupt setup commands.
See the [authoring guide](../docs/contributing/documentation.md). Examples and
Python reference stay source-generated and searchable; they do not need hundreds
of sidebar entries.
## Publishing
Blume replaces MkDocs through the existing GitHub Pages workflow at
`https://haoailab.com/FastVideo/`. Only `main` deploys. The migration manifest
checks previous routes and heading fragments. This PR changes navigation and
presentation while keeping the technical content available; the consolidation
steps above remain editorial follow-ups.
## Cookbook model pages
The cookbook catalog includes the maintained video, image, audio, and world
models, with category and family filters. FastH3 appears first. Each checkpoint
has its own page; related task or hardware recipes become variants on that page.
Family groups hold the pages alongside their broader runbooks.
FastH3 8-Step V2, FastWan2.1 1.3B, Wan2.1 I2V 14B, and LTX2 Distilled use the five
existing editable serving deployments. Their pages have a model header, YAML
actions, generated commands, and a compact hardware/options matrix. Other models
publish existing source commands and YAML without inventing editable settings.
Hardware-fit validation remains unverified. New quantization, encoder, or runtime
choices require a backend recipe before becoming selectable.
The retired page-copy and tab handlers, cookbook JavaScript, and theme styles are
removed. Canonical callouts now use Blume syntax, so builds no longer execute a
migration codemod. Existing family URLs show React model cards with their setup
and troubleshooting content. The homepage adds a direct FastH3 recipe link while
keeping the existing logo, news, project cards, typography, and backgrounds.
-134
View File
@@ -1,134 +0,0 @@
# FastVideo documentation
Blume builds the documentation and React cookbook at
[haoailab.com/FastVideo](https://haoailab.com/FastVideo/). This PR replaces the
previous MkDocs site using the existing GitHub Pages publishing workflow.
![Documentation homepage](docs-preview.jpg)
![Fast Models catalog](fast-models-preview.jpg)
## Develop and validate
Read [Documenting FastVideo](../docs/contributing/documentation.md) for setup,
commands, source ownership, and the per-page writing template. Use Node 24 and
Python 3.12. `npm run dev --prefix docs-site` serves under `/FastVideo`.
`npm run build --prefix docs-site` prepares the content and writes `dist/`.
`npm run preview --prefix docs-site` serves that static build.
Edit canonical guides under `docs/`, the welcome page under `content/`, the model
catalog in `lib/fast-models.ts`, and sidebar grouping in `navigation.json`.
Preparation regenerates `.generated/` and `public/`; neither is an editable copy.
The [documentation structure plan](INFORMATION_ARCHITECTURE.md) records the new
navigation and proposed page consolidations. URLs stay stable in this PR.
Hardware and integrations sit beneath Run inference. Model-specific references
sit beside their family recipes in the Cookbook tab. Hardware and runtimes gives
readers a single starting point for CUDA, MLX and ROCm.
## Build architecture
- `scripts/prepare-python-docs.py` uses Griffe to extract modules, classes,
signatures, docstrings, and source directly from Python files. It never imports
FastVideo or needs PyTorch. Only Griffe, Markdown, and PyYAML are installed from
`requirements-docs.txt`. It also regenerates examples and serving metadata.
- `scripts/transfer-docs.mjs` rewrites links, preserves fragments, copies
assets, and overlays the handwritten application pages. Canonical guides use
native Blume callouts and React family cards; no migration converter runs during builds.
- `migration-anchors.json` is a frozen compatibility contract for the previous
site's URLs and heading fragments. Tests check the published output against it.
Do not regenerate it to hide missing routes or anchors.
- `scripts/prepare-content.mjs` builds and copies cookbook catalogs and assets.
React model cards replace the old family selectors. Source commands and
serving configurations remain owned by their checked-in recipes.
- `.github/workflows/infra-docs.yml` validates and uploads `docs-site/dist/` with
the existing Pages actions. Deployment runs only on `main`. The domain,
environment and `/FastVideo` prefix are unchanged.
## Homepage and catalog
The welcome page uses Blume frame mode for its centered logo and accessible
heading. News comes from the first two dated entries under `NEWS` in the root
README; projects come from its Awesome work section. The original introduction
and key features come from canonical `docs/index.md`.
The star button fetches GitHub's public repository count on load and every five
minutes while visible. Failed initial requests show "Star"; failed refreshes
retain the previous count. No credentials are used. The Trendshift badge loads
from Trendshift. The lab mark in `assets/hao-ai-lab.png` is the supplied image.
Fast Models uses family cards, setup links, family filters, search, and consistent
checkpoint rows. The curated links were checked against the public Hugging Face
API on October 16, 2026. Published weights do not establish runtime or hardware
compatibility. Check the support matrix and matching setup guide.
## Extend the cookbook
`islands/Cookbook.tsx` owns the React UI and its loading/edit state. Shared
configuration behavior stays in `docs/js/cookbook-config.mjs`. The typed adapter
in `lib/cookbook.ts` describes the part of that existing contract used by this UI.
Read [the API contract](../docs/cookbook/design.md) and
[recipe authoring guide](../docs/contributing/cookbook_configuration.md) before
adding fields or recipes.
- Keep native serving YAMLs and `docs/cookbook/recipes/` as the source of truth.
Do not add another recipe database or hardcode field lists in the UI.
- Use `getOptions` for field metadata and `resolveConfig` for validated YAML,
server commands, and client instructions. Clear generated output when invalid.
- The URL stores the selected deployment. Edit values stay local to the page;
URL serialization of all edited values is follow-up work.
- Reset restores the baseline. Missing fields remain inherited. Explicit `false`,
`0`, and `null` keep their meanings. Switching deployments discards edits.
- Installation and deployment guides resolve against the current origin and
deployment prefix. Family runbook URLs remain available in the Blume site.
- VRAM fit is unknown. Add a calculator or a verified-run badge only with the
measurements and evidence contract described in #1949.
Blume builds the documentation shell with Astro. Components in `islands/` are
ordinary React; `client = "load"` enables hydration. `components/ui/` contains
editable shadcn components, `components.json` configures the CLI, and `theme.css`
maps their tokens to Blume's light/dark palette. Blume supplies Tailwind and reads
`tsconfig.json` aliases. Add components from this directory:
```bash
npx shadcn@4.21.5 add dialog
```
The two npm overrides keep Astro's React integration and React plugin aligned
with the versions used by Blume 2.2.3. Without them, npm's resolver cycles between
the integration's newer plugin and Blume's pinned plugin. Recheck and remove the
overrides when upgrading Blume.
The initial dependency audit reports 10 transitive advisories in Blume's dependency
tree, including its unused Vercel adapter and Markdown build dependencies. The
ordinary `npm audit fix` does not clear them; review upstream fixes before
production deployment. Do not apply `npm audit fix --force`, whose proposed
replacement is `blume@0.0.0`.
## Verify UI changes
```bash
npm run format:check --prefix docs-site
npm run check --prefix docs-site
npm run test:cookbook --prefix docs
npm run build --prefix docs-site
npm run validate --prefix docs-site
npm run test:content --prefix docs-site
cd docs-site
npx playwright install chromium
npm test
```
The browser suite starts a production preview. It checks edits and downloads,
invalid-output suppression, reset, switching runtimes, retry, obsolete loads,
keyboard editing, and mobile overflow under `/FastVideo`. Keep shared API tests
in `docs/tests/`; add React interaction tests here. No serving process is launched.
## Review and follow-up
This PR switches the existing Pages artifact from MkDocs to Blume when merged.
The original routes and fragments are checked against the frozen migration
manifest. Review CI and the catalog/sidebar on desktop and mobile before merging.
The remaining cookbook design and editor coverage in #1949 can continue without
keeping a second documentation build. The editorial consolidation proposals are
listed separately in `INFORMATION_ARCHITECTURE.md`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

-30
View File
@@ -1,30 +0,0 @@
import { defineConfig } from "blume";
import { readFileSync } from "node:fs";
export default defineConfig({
title: "FastVideo",
description: "A unified inference and post-training framework for accelerated video generation.",
content: { root: ".generated/content" },
logo: { image: "/icon.svg", text: "FastVideo" },
theme: { accent: "blue", background: { light: "#ffffff", dark: "#1c1d20" }, mode: "system" },
search: {
popular: [
{ label: "Quick start", href: "/getting_started/quick_start", icon: "rocket" },
{ label: "Fast Models", href: "/fast-models", icon: "boxes" },
{ label: "Installation", href: "/getting_started/installation", icon: "download" },
{ label: "Supported models", href: "/inference/support_matrix", icon: "video" },
{ label: "Python API", href: "/api", icon: "code" },
],
},
navigation: {
actions: [{ label: "GitHub", href: "https://github.com/hao-ai-lab/FastVideo" }],
tabs: [
{ label: "Documentation", path: "/" },
{ label: "Cookbook", path: "/cookbook" },
],
sidebar: { items: JSON.parse(readFileSync(new URL("./.generated/navigation.json", import.meta.url), "utf8")) },
},
// Keep the production prefix in local builds so asset-path regressions are visible.
deployment: { site: "https://haoailab.com", base: "/FastVideo" },
seo: { robots: true, sitemap: true, og: { enabled: false } },
});
-20
View File
@@ -1,20 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "theme.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
@@ -1,369 +0,0 @@
import { useEffect, useId, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import {
backendLabel,
gpuScopeNote,
memoryText,
resolveHardware,
searchHardware,
type CustomHardware,
type HardwareCatalogState,
type HardwareRecord,
type HardwareSelection,
type MemoryType,
type MemoryUnit,
} from "@/lib/hardware";
interface Draft {
name: string;
backend: string;
memory_type: MemoryType;
memory: string;
memory_unit: MemoryUnit;
budget: string;
ram: string;
}
function draftFrom(custom: CustomHardware | undefined, backend: string): Draft {
return {
name: custom?.name ?? "",
backend: custom?.backend ?? (backend === "mlx" ? "mlx" : "cuda"),
memory_type: custom?.memory_type ?? (backend === "mlx" ? "unified" : "dedicated"),
memory: custom?.memory_gb ? String(custom.memory_gb) : "",
memory_unit: custom?.memory_unit ?? "GB",
budget: custom?.usable_budget_gb ? String(custom.usable_budget_gb) : "",
ram: custom?.host_ram_gb ? String(custom.host_ram_gb) : "",
};
}
function amount(value: string): number | undefined {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 4096 ? parsed : undefined;
}
export default function HardwarePicker({
backend,
catalog,
status,
error,
value,
onChange,
gpuCount = 1,
reload,
}: {
backend: string;
catalog: HardwareRecord[];
status: HardwareCatalogState["status"];
error?: string;
value: HardwareSelection;
onChange: (selection: HardwareSelection) => void;
gpuCount?: number;
reload?: () => void;
}) {
const id = useId();
const panelId = `${id}-lookup`;
const triggerRef = useRef<HTMLButtonElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState<Draft>(() => draftFrom(value.custom, backend));
const record = resolveHardware(value, catalog);
const unknown = status === "ready" && !!value.id && value.id !== "custom" && !record;
const loading = status === "loading" && !!value.id && value.id !== "custom";
const candidates = catalog.filter((item) => item.backend === (backend === "mlx" ? "mlx" : "cuda"));
const matches = searchHardware(candidates, query).slice(0, 20);
const draftMemory = amount(draft.memory);
const draftBudget = amount(draft.budget);
const budgetTooLarge = draftMemory !== undefined && draftBudget !== undefined && draftBudget > draftMemory;
const validDraft = !!draft.name.trim() && !!draftMemory && !budgetTooLarge;
const scope = gpuScopeNote(record, gpuCount);
useEffect(() => {
if (value.custom) setDraft(draftFrom(value.custom, backend));
}, [backend, value.custom]);
useEffect(() => {
if (status === "unavailable") setAdding(true);
}, [status]);
useEffect(() => {
if (open && status === "ready" && !adding) searchRef.current?.focus();
}, [adding, open, status]);
function close() {
setOpen(false);
triggerRef.current?.focus();
}
function choose(recordId: string) {
onChange({ id: recordId });
setOpen(false);
setQuery("");
}
function save(event: React.FormEvent) {
event.preventDefault();
const memory = amount(draft.memory);
if (!validDraft || !memory) return;
onChange({
id: "custom",
custom: {
name: draft.name.trim(),
backend: draft.backend,
memory_type: draft.memory_type,
memory_gb: memory,
memory_unit: draft.memory_unit,
usable_budget_gb: amount(draft.budget),
host_ram_gb: draft.memory_type === "unified" ? undefined : amount(draft.ram),
},
});
setOpen(false);
setAdding(false);
}
return (
<div
className="hardware-picker"
onKeyDown={(event) => {
if (event.key === "Escape" && open) {
event.stopPropagation();
close();
}
}}
>
{record ? (
<div className="hardware-selected">
<div>
<span className="hardware-selected-name">{record.name}</span>
<span className="hardware-selected-meta">
{memoryText(record)} · {backendLabel(record.backend)}
{record.form_factor ? ` · ${record.form_factor}` : ""}
</span>
{record.usable_budget_gb ? (
<span className="hardware-selected-meta">
Usable memory: {record.usable_budget_gb} {record.memory_unit}
</span>
) : null}
{record.host_ram_gb && record.memory_type !== "unified" ? (
<span className="hardware-selected-meta">
Host RAM: {record.host_ram_gb} {record.memory_unit}
</span>
) : null}
</div>
<button type="button" className="hardware-clear" onClick={() => onChange({ id: "" })}>
Clear hardware
</button>
</div>
) : value.id === "custom" ? (
<p className="hardware-current" role="status">
Add this device's memory to finish selecting it.
</p>
) : loading ? (
<p className="hardware-current" role="status">
Loading hardware catalog…
</p>
) : unknown ? (
<p className="hardware-current" role="status">
The saved device ({value.id}) is not in this catalog.
</p>
) : (
<p className="hardware-current" role="status">
No hardware selected.
</p>
)}
{record ? (
<p className="hardware-address-note">
{record.memory_type === "unified"
? "Shared with the CPU; not dedicated VRAM."
: record.memory_type === "dedicated"
? "Per device, not a combined pool."
: "Host memory, not GPU VRAM."}
</p>
) : null}
{scope ? <p className="hardware-scope">{scope}</p> : null}
<div className="hardware-picker-actions">
{!record && value.id ? (
<button type="button" className="hardware-clear" onClick={() => onChange({ id: "" })}>
Clear hardware
</button>
) : null}
<button
ref={triggerRef}
type="button"
className="hardware-add"
aria-expanded={open}
aria-controls={panelId}
onClick={() => {
setOpen(!open);
setAdding(false);
}}
>
{record ? "Change hardware" : backend === "mlx" ? "+ Find or add a Mac" : "+ Find or add a GPU"}
</button>
</div>
{record || open ? (
<p className="hardware-picker-caption">
Sets the setup guide and memory budget. Choose checkpoint precision in the recipe settings.
</p>
) : null}
{open ? (
<div className="hardware-lookup" id={panelId} onKeyDown={(event) => event.key === "Escape" && close()}>
{status === "loading" ? <p className="hardware-lookup-status">Loading hardware catalog…</p> : null}
{status === "unavailable" ? (
<div className="hardware-lookup-status">
<p>Hardware catalog unavailable. Add your device manually.</p>
{error ? <span>{error}</span> : null}
{reload ? (
<button type="button" className="hardware-retry" onClick={reload}>
Retry
</button>
) : null}
</div>
) : null}
{status === "ready" ? (
<>
<Input
ref={searchRef}
aria-label="Search hardware"
type="search"
placeholder="Search by name, alias, or memory"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div className="hardware-result-list" role="radiogroup" aria-label="Matching devices">
{matches.map((item) => (
<label key={item.id} className="hardware-result" data-selected={value.id === item.id}>
<input
type="radio"
name={`${id}-hardware`}
checked={value.id === item.id}
onChange={() => choose(item.id)}
/>
<span>
<strong>{item.name}</strong>
<small>
{memoryText(item)} · {backendLabel(item.backend)}
</small>
</span>
</label>
))}
</div>
{!matches.length ? <p className="hardware-lookup-status">No matching devices.</p> : null}
</>
) : null}
<button
type="button"
className="hardware-manual-toggle"
aria-expanded={adding}
onClick={() => setAdding(!adding)}
>
{adding ? "Hide manual entry" : "Can't find it? Add a device"}
</button>
{adding ? (
<form className="hardware-form" onSubmit={save}>
<p className="hardware-form-title">Add a device</p>
<div className="hardware-form-grid">
<label className="hardware-form-field hardware-form-wide">
<span>Device name</span>
<Input
value={draft.name}
maxLength={80}
placeholder={backend === "mlx" ? "e.g. M4 Max" : "e.g. RTX 4090"}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
/>
</label>
<label className="hardware-form-field">
<span>Backend</span>
<select
value={draft.backend}
onChange={(event) => setDraft({ ...draft, backend: event.target.value })}
>
<option value="cuda">CUDA</option>
<option value="mlx">Apple MLX</option>
</select>
</label>
<label className="hardware-form-field">
<span>Memory type</span>
<select
value={draft.memory_type}
onChange={(event) => setDraft({ ...draft, memory_type: event.target.value as MemoryType })}
>
<option value="dedicated">GPU memory (VRAM)</option>
<option value="unified">Unified memory</option>
<option value="system">System memory (RAM)</option>
</select>
</label>
<label className="hardware-form-field">
<span>Total memory</span>
<Input
type="number"
min="1"
step="any"
inputMode="decimal"
value={draft.memory}
onChange={(event) => setDraft({ ...draft, memory: event.target.value })}
/>
</label>
<label className="hardware-form-field">
<span>Memory unit</span>
<select
value={draft.memory_unit}
onChange={(event) => setDraft({ ...draft, memory_unit: event.target.value as MemoryUnit })}
>
<option value="GB">GB</option>
<option value="GiB">GiB</option>
</select>
</label>
<label className="hardware-form-field">
<span>Usable memory (optional)</span>
<Input
type="number"
min="1"
step="any"
inputMode="decimal"
placeholder="Your own limit"
value={draft.budget}
onChange={(event) => setDraft({ ...draft, budget: event.target.value })}
/>
</label>
{draft.memory_type !== "unified" ? (
<label className="hardware-form-field">
<span>Host RAM (optional)</span>
<Input
type="number"
min="1"
step="any"
inputMode="decimal"
value={draft.ram}
onChange={(event) => setDraft({ ...draft, ram: event.target.value })}
/>
</label>
) : null}
</div>
{budgetTooLarge ? <p className="hardware-form-error">Usable memory cannot exceed total memory.</p> : null}
<p className="hardware-form-note">
Total memory is the device's capacity. Usable memory is the part FastVideo may use, such as a MIG slice
or a limit you set. Unified memory is shared with the CPU.
</p>
<div className="hardware-form-actions">
<button type="submit" disabled={!validDraft}>
Save device
</button>
<button
type="button"
onClick={() => {
setAdding(false);
setDraft(draftFrom(value.custom, backend));
}}
>
Cancel
</button>
</div>
</form>
) : null}
</div>
) : null}
</div>
);
}
@@ -1,24 +0,0 @@
import type { ReactNode } from "react";
import { sitePath, type DirectoryModel } from "@/lib/cookbook-pages";
export default function ModelHeader({ model, actions }: { model: DirectoryModel; actions?: ReactNode }) {
return (
<>
<a className="recipe-back" href={sitePath("/cookbook")}>
← All recipes
</a>
<header className="recipe-model-header">
<img src={sitePath(`/assets/logos/${model.logo}.webp`)} alt="" width="48" height="48" />
<div>
<p className="recipe-publisher">{model.provider}</p>
<h1>{model.title}</h1>
<p className="recipe-model-task">{model.tags.join(" · ")}</p>
{!model.model.startsWith("./") && (
<a href={`https://huggingface.co/${model.model}`}>View on Hugging Face ↗</a>
)}
</div>
<div className="recipe-model-actions">{actions}</div>
</header>
</>
);
}
@@ -1,119 +0,0 @@
import { useId, useState } from "react";
import type { MemoryAssessment } from "@/lib/capacity";
export type Validity = "valid" | "invalid" | "loading";
export type Fit = "fits" | "tight" | "exceeds" | "unsupported" | "unknown";
const validityLabel: Record<Validity, string> = {
valid: "Schema checks passed",
invalid: "Configuration invalid",
loading: "Checking configuration",
};
const fitLabel: Record<Fit, string> = {
fits: "Fits estimate",
tight: "Tight fit",
exceeds: "Exceeds memory",
unsupported: "Unsupported",
unknown: "Memory not estimated",
};
/**
* Two facts, each with its own evidence: the recipe schema accepts the values,
* and the analytical estimate covers the selected device. Neither is a promise
* that the recipe runs.
*/
export default function RecipeFacts({
validity,
memory,
validityText,
validityDetail,
detail,
retryMemory,
}: {
validity: Validity;
memory?: MemoryAssessment;
subject?: "configuration" | "example";
validityText?: string;
validityDetail?: string;
detail?: string;
retryMemory?: () => void;
}) {
const [open, setOpen] = useState(false);
const id = useId();
const fit: Fit = memory?.status ?? "unknown";
const estimated = fit === "fits" || fit === "tight" || fit === "exceeds";
const label = fitLabel[fit];
return (
<div className="recipe-facts">
<div className="recipe-fact-list" aria-label="Recipe checks">
<span data-state={validity}>
{validity === "valid" ? (validityText ?? validityLabel.valid) : validityLabel[validity]}
</span>
<span data-state={fit}>{label}</span>
</div>
<button
type="button"
className="recipe-facts-toggle"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
>
Details
</button>
{memory?.reason && (
<p className="matrix-note" role="status">
{memory.reason}
</p>
)}
{memory?.basis === "checkpoint" && (
<div className="recipe-memory-breakdown">
<p>
Known checkpoint data: {memory.checkpointGiB?.toFixed(1)} GiB. Device budget:{" "}
{memory.capacityGiB?.toFixed(1)} GiB.
</p>
<dl>
{Object.entries(memory.parts ?? {}).map(([name, size]) => (
<div key={name}>
<dt>{name.replaceAll("_", " ")}</dt>
<dd>{size.toFixed(1)} GiB</dd>
</div>
))}
</dl>
</div>
)}
{retryMemory && (
<button type="button" onClick={retryMemory}>
Retry memory data
</button>
)}
{open && (
<div className="recipe-facts-detail" id={id}>
<p>
<strong>{validityLabel[validity]}.</strong>{" "}
{validityDetail ??
(validity === "valid"
? "These values pass the recipe schema and runtime topology checks. Model loading, selected components and GPU execution have not been verified."
: validity === "invalid"
? "Output is hidden until the highlighted fields are fixed."
: "Values are still being checked.")}
</p>
{!memory && (
<p>
<strong>{label}.</strong> FastVideo has no capacity model for this recipe, so it does not estimate memory
use here.
</p>
)}
{memory?.notes?.map((note) => (
<p key={note}>{note}</p>
))}
{estimated && (
<p>
<strong>{label}.</strong>{" "}
{`The ${memory?.basis === "measured" ? "recorded peak" : "analytical estimate"} needs ${memory?.requiredGiB?.toFixed(1)} GiB of ${memory?.capacityGiB?.toFixed(1)} GiB available. This applies only to the covered execution settings.`}
</p>
)}
{detail && <p>{detail}</p>}
</div>
)}
</div>
);
}
-39
View File
@@ -1,39 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import { Slot } from "radix-ui";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
-61
View File
@@ -1,61 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
import { Slot } from "radix-ui";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -1,163 +0,0 @@
"use client";
import * as React from "react";
import { type HTMLMotionProps, type Variants, motion } from "motion/react";
import { cn } from "cn";
const curtainVariants: Variants = {
visible: {
clipPath: "polygon(0 0,100% 0,100% 100%,0 100%)",
transition: { duration: 0.4, ease: [0.25, 1.5, 0.5, 1] },
},
hidden: {
clipPath: "polygon(50% 0,50% 0,50% 100%,50% 100%)",
transition: { duration: 0.3, ease: [0.25, 1.5, 0.5, 1] },
},
};
interface CardCurtainRevealContextValue {
revealed: boolean;
/** False on touch-only devices: details stay visible and the curtain never draws. */
canHover: boolean;
}
const CardCurtainRevealContext = React.createContext<CardCurtainRevealContextValue | undefined>(undefined);
function useCardCurtainRevealContext() {
const context = React.useContext(CardCurtainRevealContext);
if (!context) throw new Error("useCardCurtainRevealContext must be used within a CardCurtainReveal component");
return context;
}
function useCanHover() {
const [canHover, setCanHover] = React.useState(true);
React.useEffect(() => {
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setCanHover(query.matches);
sync();
query.addEventListener("change", sync);
return () => query.removeEventListener("change", sync);
}, []);
return canHover;
}
interface CardCurtainRevealProps extends React.HTMLAttributes<HTMLDivElement> {
/** Reveals the card without a pointer, for example while its link has keyboard focus. */
active?: boolean;
}
const CardCurtainReveal = React.forwardRef<HTMLDivElement, CardCurtainRevealProps>(
({ children, className, active = false, ...props }, ref) => {
const [isMouseIn, setIsMouseIn] = React.useState(false);
const canHover = useCanHover();
const handleMouseEnter = React.useCallback(() => setIsMouseIn(true), []);
const handleMouseLeave = React.useCallback(() => setIsMouseIn(false), []);
const value = React.useMemo(
() => ({ revealed: !canHover || isMouseIn || active, canHover }),
[active, canHover, isMouseIn],
);
return (
<CardCurtainRevealContext.Provider value={value}>
<div
ref={ref}
className={cn("relative flex flex-col gap-2 overflow-hidden", className)}
data-revealed={value.revealed || undefined}
data-curtain-open={(canHover && value.revealed) || undefined}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
{children}
</div>
</CardCurtainRevealContext.Provider>
);
},
);
CardCurtainReveal.displayName = "CardCurtainReveal";
const CardCurtainRevealFooter = React.forwardRef<HTMLDivElement, HTMLMotionProps<"div">>(
({ className, ...props }, ref) => {
const { revealed } = useCardCurtainRevealContext();
return (
<motion.div
ref={ref}
className={className}
variants={curtainVariants}
initial={false}
animate={revealed ? "visible" : "hidden"}
{...props}
/>
);
},
);
CardCurtainRevealFooter.displayName = "CardCurtainRevealFooter";
const CardCurtainRevealBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex-1 p-6", className)} {...props} />,
);
CardCurtainRevealBody.displayName = "CardCurtainRevealBody";
interface CardCurtainRevealTitleProps extends HTMLMotionProps<"h2"> {
/** Distance in pixels the title rests below its revealed position. */
hiddenOffset?: number;
as?: "h2" | "h3" | "h4";
}
const CardCurtainRevealTitle = React.forwardRef<HTMLHeadingElement, CardCurtainRevealTitleProps>(
({ className, hiddenOffset = 170, as = "h2", ...props }, ref) => {
const { revealed } = useCardCurtainRevealContext();
const Heading = motion[as];
return (
<Heading
ref={ref}
className={className}
initial={false}
animate={{ y: revealed ? 0 : hiddenOffset }}
transition={{ duration: 0.3, ease: "easeOut" }}
{...props}
/>
);
},
);
CardCurtainRevealTitle.displayName = "CardCurtainRevealTitle";
const CardCurtain = React.forwardRef<HTMLDivElement, HTMLMotionProps<"div">>(({ className, ...props }, ref) => {
const { revealed, canHover } = useCardCurtainRevealContext();
if (!canHover) return null;
return (
<motion.div
ref={ref}
aria-hidden="true"
className={cn("pointer-events-none absolute inset-0 size-full mix-blend-difference", className)}
variants={curtainVariants}
initial={false}
animate={revealed ? "visible" : "hidden"}
{...props}
/>
);
});
CardCurtain.displayName = "CardCurtain";
const CardCurtainRevealDescription = React.forwardRef<HTMLDivElement, HTMLMotionProps<"div">>(
({ className, ...props }, ref) => {
const { revealed } = useCardCurtainRevealContext();
return (
<motion.div
ref={ref}
className={className}
variants={curtainVariants}
initial={false}
animate={revealed ? "visible" : "hidden"}
{...props}
/>
);
},
);
CardCurtainRevealDescription.displayName = "CardCurtainRevealDescription";
export {
CardCurtainReveal,
CardCurtainRevealBody,
CardCurtainRevealFooter,
CardCurtainRevealDescription,
CardCurtainRevealTitle,
CardCurtain,
};
-20
View File
@@ -1,20 +0,0 @@
import * as React from "react";
import { cn } from "cn";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
-17
View File
@@ -1,17 +0,0 @@
import * as React from "react";
import { cn } from "cn";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
-24
View File
@@ -1,24 +0,0 @@
---
title: Cookbook
description: Recipes for video, image, audio, and world generation.
mode: wide
---
Pick a model, choose your hardware, and copy the command. Every recipe starts from checked-in YAML, so the settings you see are the settings that run.
Before you pick a device, check [weights and quantization](weights/) for measured
checkpoint sizes and the FastH3 precision variants.
<a id="inference-cookbook"></a>
<a id="video-models-heading"></a>
<a id="image-models-heading"></a>
<a id="audio-models-heading"></a>
<a id="world-models-heading"></a>
<a id="planned-families-heading"></a>
<Cookbook />
<noscript>
Interactive configuration needs JavaScript. Browse the{" "}
<a href="https://github.com/hao-ai-lab/FastVideo/tree/main/examples">runnable examples</a> directly.
</noscript>
@@ -1,7 +0,0 @@
---
title: LTX2 Distilled
description: Configure incremental video delivery with the native WebSocket server.
mode: frame
---
<Cookbook modelId="ltx2-distilled" />
@@ -1,7 +0,0 @@
---
title: FastH3 4-Step V1
description: Configure FastH3 4-Step V1 and copy inference or serving commands.
mode: frame
---
<Cookbook modelId="fasth3-4step" />
@@ -1,7 +0,0 @@
---
title: FastH3 8-Step V2
description: Configure distilled video generation with synchronized audio on CUDA or Apple Silicon.
mode: frame
---
<Cookbook modelId="fasth3-8step" />
@@ -1,14 +0,0 @@
---
title: FastH3 Omni v1
description: Configure FastH3 Omni v1 reference-to-video serving and copy inference or serving commands.
mode: frame
---
<Cookbook modelId="fasth3-omniref" />
## Apple silicon
The MLX path composes the BF16 Omni release with the NVFP4 text encoder, converts
the DiT to INT6, and runs the same image and audio references on Metal. Follow
[FastH3 Omni on MLX](/inference/fasth3_omniref_mlx).
For copyable local commands, open the [MLX recipe](/cookbook/minimax-h3/models/fasth3-omniref-mlx).
@@ -1,7 +0,0 @@
---
title: FastH3 Preview V0.2
description: Configure FastH3 Preview V0.2 and copy inference or serving commands.
mode: frame
---
<Cookbook modelId="fasth3-preview" />
@@ -1,7 +0,0 @@
---
title: FastH3 Trim
description: Configure FastH3 Trim NVFP4 and copy inference or serving commands.
mode: frame
---
<Cookbook modelId="fasth3-trim" />
@@ -1,7 +0,0 @@
---
title: Open-H3-Regenerate-2K
description: Configure Open-H3-Regenerate-2K serving, which regenerates a 768P MiniMax-H3 draft at 2560x1440.
mode: frame
---
<Cookbook modelId="open-h3-regenerate-2k" />
@@ -1,7 +0,0 @@
---
title: FastWan2.1 1.3B
description: Configure distilled text-to-video generation on CUDA.
mode: frame
---
<Cookbook modelId="fastwan21" />
@@ -1,7 +0,0 @@
---
title: Wan2.1 I2V 14B
description: Configure image-to-video generation with a reference image.
mode: frame
---
<Cookbook modelId="wan21-i2v" />
-115
View File
@@ -1,115 +0,0 @@
---
title: Weights and quantization
description: Measured checkpoint sizes for every cookbook model, plus the FastH3 quantized variants and which one fits a given GPU.
mode: wide
---
Every number here is the size of the safetensors files in the model repository,
measured on 2026-10-27 with the Hugging Face tree API. Treat it as the weight
floor. A run also needs activations, VSA workspace, and decode buffers, so
weights that fit do not guarantee a run that fits.
The cookbook editor starts from each recipe's maintained deployment. Changing
a quantization control does not select one of the published exports below.
## Catalog weights
Sizes are GiB at the checkpoint's runtime precision. Repositories that store
F32 weights are converted to BF16 here, which is what the loader uses.
| Model | Weights | Transformer | Text encoder | VAE |
| ----------------------- | ------- | ----------- | ------------ | ---- |
| FastH3 8-Step V2 | 137.7 | 65.3 | 62.1 | 10.3 |
| FastH3 4-Step V1 | 137.7 | 65.3 | 62.1 | 10.3 |
| FastH3 Trim | 56.8 | 34.9 | 15.3 | 6.5 |
| FastH3 Omni v1 | 92.5 | 65.3 | 15.3 | 11.9 |
| Pika H3 Flex | 136.7 | 64.3 | 62.1 | 10.3 |
| FastWan2.1 1.3B | 13.6 | 2.8 | 10.6 | 0.3 |
| Wan2.1 T2V 14B | 37.4 | 26.6 | 10.6 | 0.3 |
| Wan2.1 I2V 14B | 41.9 | 30.5 | 10.6 | 0.3 |
| Wan2.2 I2V A14B | 58.8 | 53.2 | 5.3 | 0.3 |
| Hunyuan 1.5 I2V 480p | 32.2 | 15.5 | 13.6 | 2.3 |
| Hunyuan 1.5 T2V 480p | 24.9 | 15.5 | 7.0 | 2.4 |
| Kandinsky 5 Lite | 11.1 | 2.2 | 8.5 | 0.5 |
| Kandinsky 6 Pro | 41.9 | 32.5 | 8.0 | 1.4 |
| Kandinsky 6 Pro distill | 37.5 | 28.1 | 8.0 | 1.4 |
| LTX2 Distilled | 66.9 | 35.2 | 25.4 | 3.6 |
Pika H3 Flex's transformer is the 61.7 GiB H3 trunk plus its 2.6 GiB task adapter.
Ref2VA swaps in `transformer_ref` and the `ref2va` adapter, which are the same size.
The FastVideo organization publishes the same FastH3 model at several
precisions. The transformer shrinks with precision; the text encoder and VAE
only shrink when the repository ships them quantized too.
| Checkpoint | Variant | Weights | Transformer | Text encoder | VAE |
| ----------------- | -------------- | ------- | ----------- | ------------ | ---- |
| 8-Step V2 | BF16 | 137.7 | 65.3 | 62.1 | 10.3 |
| 8-Step V2 | FP8 | 69.2 | 47.4 | 15.3 | 6.5 |
| 8-Step V2 | NVFP4 | 114.5 | 49.8 | 62.1 | 2.5 |
| 8-Step V2 | NVFP4 Consumer | 58.7 | 36.9 | 15.3 | 6.5 |
| Trim | BF16 | 56.8 | 34.9 | 15.3 | 6.5 |
| Trim | FP8 | 41.7 | 19.9 | 15.3 | 6.5 |
| Trim | NVFP4 | 32.9 | 11.1 | 15.3 | 6.5 |
| Trim | MLX INT6 | 33.0 | 14.3 | 14.2 | 4.5 |
| 4-Step V1 | BF16 | 137.7 | 65.3 | 62.1 | 10.3 |
| 4-Step V1 | NVFP4 | 114.5 | 49.8 | 62.1 | 2.5 |
| Omni v1 | BF16 DiT | 65.3 | 65.3 | n/a | n/a |
| Omni v1 | FP8 | 67.2 | 47.4 | 15.3 | 4.5 |
| Omni v1 | NVFP4 QAD | 63.3 | 37.0 | 15.3 | 11.0 |
| NVFP4 VAE decoder | quality | 5.8 | n/a | n/a | 5.8 |
| NVFP4 VAE decoder | fast | 1.3 | n/a | n/a | 1.3 |
The NVFP4 checkpoint keeps the BF16 text encoder, which is why it is larger
than FP8. The Consumer variant pairs the smaller NVFP4 transformer with the
FP8 text encoder and VAE, which brings the listed weights to 58.7 GiB. These
export sizes do not establish device fit. Loading, offload, quantization
profiles, and temporary buffers affect runtime residency. The decoder-only
rows exclude the VAE encoder and can contain master weights that the loader
packs before inference.
## Pick by GPU
| GPU family | Examples | Use | Why |
| ---------------- | ----------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------- |
| Blackwell | RTX 5090, RTX PRO 6000, B200, GB200 | NVFP4 or FP8 with a matching maintained profile | Native FP4 tensor cores cut the transformer to about half of BF16 |
| Hopper | H100, H200 | FP8 | FP8 tensor cores; the NVFP4 path requires Blackwell |
| Ada | RTX 4090, L40S | FP8 | FP8 works, NVFP4 does not |
| Ampere and older | A100, RTX 3090 | BF16 | No FP8 or FP4 hardware |
| Apple silicon | M-series | MLX INT6 or INT8 | The MLX conversions are INT6 and INT8, not FP4 |
| DGX Spark | GB10 | NVFP4 with sequential load | Unified memory, so the host RAM counts toward the budget |
## What the cookbook does now
FastH3 pages show known checkpoint sizes beside the selected device budget.
These numbers are not a runtime fit check. Offload moves weights between phases;
sequence parallelism does not necessarily shard weights; unified memory is one
shared pool. The page does not turn those file sizes into a residency claim.
When an encoder, decoder or transformer precision changes, the page keeps sizes
for unchanged components and names the missing data. It never substitutes the
size of a different published export. A complete peak estimate still requires
loading, placement, activations and workspace models for the selected path.
The transformer quantization control writes
`generator.engine.quantization.transformer_quant`, so the copied YAML names the
precision. Encoder and decoder choices use component paths and stay visible in
the YAML too.
## Automatic selection
The editor does not pick a precision from the device yet. When it does, the
rule is:
1. Read the device architecture and memory from the hardware catalog.
2. Prefer the smallest variant the architecture accelerates: NVFP4 on
Blackwell, FP8 on Hopper and Ada, INT6 on Apple, BF16 everywhere else.
3. Keep the BF16 text encoder when the quantized checkpoint ships one, and
switch the decoder to NVFP4 only on Blackwell.
4. Expose the choice as a deployment setting, not a hidden default, so the
copied command still shows which checkpoint runs.
That needs one deployment per variant in
[`docs/cookbook/recipes`](https://github.com/hao-ai-lab/FastVideo/tree/main/docs/cookbook/recipes)
and a match rule in the editor. Until then, select the precision by hand and
read the weights fact.
-10
View File
@@ -1,10 +0,0 @@
---
title: Fast Models
description: Explore FastVideo's model releases and inference recipes.
mode: wide
---
<FastModels />
Model links reviewed October 16, 2026. Browse all releases on
[FastVideo's Hugging Face organization](https://huggingface.co/FastVideo).
-65
View File
@@ -1,65 +0,0 @@
---
title: Welcome to FastVideo
description: Run, train, and accelerate video generation models with FastVideo.
mode: frame
---
<div className="home-page">
<header className="home-hero not-prose">
<h1 id="welcome-to-fastvideo" className="home-logo">
<img src="/assets/logos/logo.svg" alt="Welcome to FastVideo" width={252} height={105} />
</h1>
<p className="home-description">Run, train, and accelerate video generation models with FastVideo.</p>
<nav className="home-project-links" aria-label="About the project">
<GitHubStars />
<a className="home-lab-link" href="https://haoailab.com/">
<img src="/assets/hao-ai-lab.png" width={40} height={24} alt="" aria-hidden="true" />
Hao AI Lab
</a>
<a className="home-models-link" href="/fast-models">Fast Models <span aria-hidden="true">→</span></a>
</nav>
<div className="home-trendshift">
<a href="https://trendshift.io/repositories/14509?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-14509" target="_blank" rel="noopener noreferrer">
<img src="https://trendshift.io/api/badge/repositories/14509" alt="hao-ai-lab/FastVideo | Trendshift" width={250} height={55} />
</a>
</div>
</header>
<include>/_snippets/home-description.mdx</include>
<a className="home-featured-model not-prose" href="/cookbook/minimax-h3/fasth3-8step">
<img src="/assets/logos/fastvideo.webp" alt="" width={40} height={40} />
<span>
<small>Start with a FastVideo model</small>
<strong>FastH3 8-Step V2</strong>
</span>
<span className="home-featured-action">Configure a recipe →</span>
</a>
<include>/_snippets/home-news.mdx</include>
<CardGroup cols={2}>
<Card title="Quick start" href="/getting_started/quick_start" icon="rocket">
Generate your first video with the Python API.
</Card>
<Card title="Installation" href="/getting_started/installation" icon="download">
Set up FastVideo on NVIDIA GPUs or Apple Silicon.
</Card>
<Card title="Cookbook" href="/cookbook" icon="book-open">
Choose a model recipe, customize its settings, and download a serving configuration.
</Card>
<Card title="Training and distillation" href="/training/overview" icon="layers">
Prepare data, fine-tune models, and build faster generators.
</Card>
</CardGroup>
<include>/_snippets/home-details.mdx</include>
<a id="documentation"></a>
## Projects and research
Explore work using FastVideo's code or the attention research behind it.
<include>/_snippets/home-projects.mdx</include>
</div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

File diff suppressed because it is too large Load Diff
-114
View File
@@ -1,114 +0,0 @@
import { useId, useState } from "react";
import { families } from "@/lib/fast-models";
export const client = "load";
const local = (path: string) => `${import.meta.env.BASE_URL.replace(/\/$/, "")}${path}`;
export default function FastModels() {
const [family, setFamily] = useState("All models");
const [query, setQuery] = useState("");
const id = useId();
const visible = families
.map((group) => ({
...group,
models: group.models.filter(
(model) =>
(family === "All models" || family === group.name) &&
`${group.name} ${model.id}`.toLowerCase().includes(query.trim().toLowerCase()),
),
}))
.filter((group) => group.models.length);
const count = visible.reduce((total, group) => total + group.models.length, 0);
return (
<div className="models-page not-prose">
<section className="model-families" aria-label="Model families">
{families.map((group) => (
<article className="model-family" key={group.name}>
<a href={group.collection} className="model-family-visual" aria-label={`${group.name} collection`}>
<span aria-hidden="true">{group.mark}</span>
<img src={local("/icon.svg")} alt="" width="24" height="24" />
</a>
<div className="model-family-body">
<h2>{group.name}</h2>
<p>{group.summary}</p>
<p className="model-family-detail">{group.detail}</p>
<div className="model-family-links">
<a href={group.collection}>
View collection <span aria-hidden="true">↗</span>
</a>
<a href={local(group.guide)}>
{group.name === "FastMetal" ? "MLX setup" : "Recipes and setup"} <span aria-hidden="true">→</span>
</a>
</div>
</div>
</article>
))}
</section>
<section className="model-catalog" aria-labelledby="checkpoint-catalog">
<header className="model-catalog-heading">
<div>
<h2 id="checkpoint-catalog">Browse checkpoints</h2>
<p>Pick the checkpoint that matches your runtime.</p>
</div>
<a href="https://huggingface.co/collections/FastVideo/fastvideo-fasth3">
Earlier FastH3 releases <span aria-hidden="true">↗</span>
</a>
</header>
<div className="model-catalog-controls">
<div className="model-filters" role="group" aria-label="Filter by model family">
{["All models", ...families.map((group) => group.name)].map((name) => (
<button key={name} type="button" aria-pressed={family === name} onClick={() => setFamily(name)}>
{name}
</button>
))}
</div>
<div className="model-search">
<label htmlFor={id}>Search checkpoints</label>
<input
id={id}
type="search"
placeholder="Name, family, or precision…"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</div>
<p className="model-result-count" role="status">
{count} {count === 1 ? "checkpoint" : "checkpoints"}
</p>
{visible.map((group) => (
<section className="model-checkpoints" key={group.name} aria-labelledby={`catalog-${group.name}`}>
<h3 id={`catalog-${group.name}`}>{group.name}</h3>
<ul>
{group.models.map((model) => (
<li key={model.id}>
<div>
<a href={model.url}>{model.id.replace(/^FastVideo-/, "").replace(/-Diffusers$/, "")}</a>
<code>FastVideo/{model.id}</code>
</div>
<a className="model-checkpoint-link" href={model.url} aria-label={`Open ${model.id} on Hugging Face`}>
Hugging Face <span aria-hidden="true">↗</span>
</a>
</li>
))}
</ul>
</section>
))}
{!count && (
<div className="model-empty">
<p>No checkpoints match these filters.</p>
<button
type="button"
onClick={() => {
setQuery("");
setFamily("All models");
}}
>
Clear filters
</button>
</div>
)}
</section>
</div>
);
}
-55
View File
@@ -1,55 +0,0 @@
import { useEffect, useState } from "react";
export const client = "load";
const repository = "https://github.com/hao-ai-lab/FastVideo";
const endpoint = "https://api.github.com/repos/hao-ai-lab/FastVideo";
const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 });
const exact = new Intl.NumberFormat("en");
export default function GitHubStars() {
const [stars, setStars] = useState<number | null>(null);
useEffect(() => {
const controller = new AbortController();
async function refresh() {
try {
const response = await fetch(endpoint, {
headers: { Accept: "application/vnd.github+json" },
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]),
});
if (!response.ok) return;
const data = await response.json();
if (Number.isSafeInteger(data.stargazers_count) && data.stargazers_count >= 0 && !controller.signal.aborted) {
setStars(data.stargazers_count);
}
} catch {
// Keep the link usable when offline or when the public API is rate limited.
}
}
void refresh();
const timer = setInterval(
() => {
if (document.visibilityState === "visible") void refresh();
},
5 * 60 * 1000,
);
return () => {
controller.abort();
clearInterval(timer);
};
}, []);
const label = stars === null ? "Star FastVideo on GitHub" : `FastVideo on GitHub, ${exact.format(stars)} stars`;
return (
<a className="home-github-stars" href={repository} aria-label={label} title={label}>
<svg width="17" height="17" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38v-1.49c-2.23.48-2.7-1.08-2.7-1.08-.36-.92-.89-1.17-.89-1.17-.73-.5.06-.49.06-.49.8.06 1.22.82 1.22.82.71 1.22 1.87.87 2.33.67.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.96 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.65 7.65 0 0 1 8 3.72c.68 0 1.36.09 2 .27 1.53-1.03 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.28.82 2.15 0 3.08-1.87 3.75-3.65 3.95.29.25.54.74.54 1.49v2.21c0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
<svg className="home-star" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="m12 2.5 2.94 5.96 6.58.96-4.76 4.64 1.12 6.55L12 17.52l-5.88 3.09 1.12-6.55L2.48 9.42l6.58-.96L12 2.5Z" />
</svg>
<span>{stars === null ? "Star" : compact.format(stars).toLowerCase()}</span>
</a>
);
}
-41
View File
@@ -1,41 +0,0 @@
import { modelDirectory, sitePath } from "@/lib/cookbook-pages";
/** Family runbooks link to the same model pages as the cookbook catalog. */
export default function RecipeFamily({ family }: { family: string }) {
const models = modelDirectory.filter(
(model) =>
!model.hidden &&
(model.recipes.some((recipe) => recipe.family === family) ||
(family === "minimax_h3" && (model.family === "FastH3" || model.familyPath === "/cookbook/minimax-h3"))),
);
return (
<div className="recipe-directory not-prose">
<a className="recipe-back" href={sitePath("/cookbook")}>
← All recipes
</a>
<div className="recipe-card-grid">
{models.map((model) => (
<a className="recipe-card recipe-card-static" key={model.id} href={sitePath(model.path)}>
<div className="recipe-card-top">
<img src={sitePath(`/assets/logos/${model.logo}.webp`)} alt="" width="40" height="40" />
<span className="recipe-card-kicker">{model.provider}</span>
</div>
<h2>{model.title}</h2>
<div className="recipe-tags">
<span>{model.category}</span>
{model.tags.slice(0, 2).map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
<footer>
<span>
{model.platforms.map((platform) => (platform === "Apple Silicon" ? "MLX" : platform)).join(" · ")}
</span>
<span>{model.configurable ? "Configure →" : "View recipe →"}</span>
</footer>
</a>
))}
</div>
</div>
);
}
-563
View File
@@ -1,563 +0,0 @@
import { useEffect, useState } from "react";
import { Tabs } from "radix-ui";
import { parse, stringify } from "yaml";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ModelHeader from "@/components/cookbook/ModelHeader";
import HardwarePicker from "@/components/cookbook/HardwarePicker";
import RecipeFacts from "@/components/cookbook/RecipeFacts";
import { configAndLaunch, fileAndLaunch, sdkCommand, pythonSource } from "@/lib/cookbook-commands";
import { sitePath, type DirectoryModel } from "@/lib/cookbook-pages";
import { api } from "@/lib/cookbook";
import {
gpuCountFrom,
readHardwareSelection,
resolveHardware,
useHardwareCatalog,
writeHardwareSelection,
type HardwareSelection,
} from "@/lib/hardware";
interface Control {
name: string;
label: string;
value: string | number | boolean;
spans?: number[][];
path?: string[];
flag?: string;
integer?: boolean;
booleanOptional?: boolean;
choices?: (string | number | { value: string | number; label: string })[];
}
interface Example {
source: string;
controls: Control[];
}
const labels: Record<string, string> = {
prompt: "Prompt",
num_gpus: "GPU count",
height: "Height",
width: "Width",
num_frames: "Frames",
seed: "Seed",
num_inference_steps: "Inference steps",
guidance_scale: "Guidance scale",
fps: "FPS",
host: "Host",
port: "Port",
served_model_name: "Served model name",
};
/** Every source page uses the model editor; settings modify the maintained example itself. */
export default function SourceRecipe({ model }: { model: DirectoryModel }) {
const [selected, setSelected] = useState(model.recipes[0].id);
const [mode, setMode] = useState("sdk");
const [loaded, setLoaded] = useState<{ url: string; example: Example }>();
const [notice, setNotice] = useState("");
const [showEnv, setShowEnv] = useState(false);
const [showClient, setShowClient] = useState(false);
const [outputTab, setOutputTab] = useState("commands");
const [error, setError] = useState("");
const [edits, setEdits] = useState<Record<number, string | number | boolean>>({});
const [hardware, setHardware] = useState<HardwareSelection>(() =>
readHardwareSelection(new URL(window.location.href).searchParams),
);
const hardwareCatalog = useHardwareCatalog();
const recipe = model.recipes.find((recipe) => recipe.id === selected)!;
const yamlSource =
mode === "serving" ? recipe.serving?.source : /\.ya?ml$/.test(recipe.source) ? recipe.source : undefined;
const sourceUrl = sitePath(
yamlSource
? `/assets/cookbook-sources/${recipe.id}-${mode === "serving" ? "serving" : "inference"}.yaml`
: `/assets/cookbook-examples/${recipe.id}.json`,
);
const example = loaded?.url === sourceUrl ? loaded.example : undefined;
useEffect(() => {
const controller = new AbortController();
setError("");
fetch(sourceUrl, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`Example could not be loaded: HTTP ${response.status}.`);
let data: Example;
if (!yamlSource) data = await response.json();
else {
const source = await response.text();
const config = parse(source);
const controls: Control[] = [];
function visit(value: Record<string, unknown>, path: string[] = []) {
for (const [name, item] of Object.entries(value)) {
if (item && typeof item === "object" && !Array.isArray(item))
visit(item as Record<string, unknown>, [...path, name]);
else if (labels[name] && ["string", "number", "boolean"].includes(typeof item))
controls.push({
name,
label: labels[name],
value: item as Control["value"],
integer: !["guidance_scale", "audio_end_in_s"].includes(name),
path: [...path, name],
});
}
}
visit(config);
data = { source, controls };
}
if (!yamlSource) {
for (const knob of recipe.knobs ?? []) {
const field = data.controls.find((field) => field.name === knob.key);
if (field) field.choices = knob.options;
else
data.controls.push({
name: knob.key,
label: labels[knob.key] ?? knob.label,
value: knob.default,
flag: knob.flag,
spans: [],
choices: knob.options,
});
}
}
if (!controller.signal.aborted) setLoaded({ url: sourceUrl, example: data });
})
.catch((error) => {
if (!controller.signal.aborted) setError(error.message);
});
return () => controller.abort();
}, [sourceUrl, yamlSource, recipe]);
function selectMode(next: string) {
const reset = (next === "serving") !== (mode === "serving");
setMode(next);
if (reset) setEdits({});
setNotice(reset ? "Serving uses a different native example, so generation edits were reset." : "");
}
async function copy(value: string, message = "Copied to clipboard.") {
try {
await navigator.clipboard.writeText(value);
setNotice(message);
} catch {
setNotice("Copy failed. Select and copy the text below.");
}
}
let source = example?.source ?? "";
let failure = "";
for (const [key, value] of Object.entries(edits)) {
const field = example?.controls[Number(key)];
if (
field &&
typeof field.value === "number" &&
(!Number.isFinite(value) ||
((field.integer ?? Number.isInteger(field.value)) && !Number.isInteger(value)) ||
(field.name === "num_gpus" && Number(value) < 1))
)
failure = "Enter valid numeric settings before copying the example.";
}
let config: Record<string, unknown> | undefined;
if (example && yamlSource) {
try {
const root = parse(source) as Record<string, unknown>;
config = root;
for (const [key, value] of Object.entries(edits)) {
const path = example.controls[Number(key)].path!;
const parent = path
.slice(0, -1)
.reduce<Record<string, unknown>>((item, name) => item[name] as Record<string, unknown>, root);
parent[path.at(-1)!] = value;
}
source = stringify(root);
} catch {
failure = "The checked-in YAML could not be parsed, so runnable output is withheld.";
}
} else if (example) {
source = pythonSource(source, example.controls, edits);
}
const baselineCommand =
mode === "serving" && recipe.serving
? `${recipe.serving.env ? `${recipe.serving.env} ` : ""}fastvideo serve --config ${recipe.serving.source}`
: recipe.command;
const localFile = recipe.source.replace(/[^/]+$/, `cookbook_${recipe.id.replaceAll("-", "_")}.py`);
const command = yamlSource
? baselineCommand.replace(/--config\s+\S+/, "--config config.yaml")
: baselineCommand.replaceAll(recipe.source, localFile) +
Object.entries(edits)
.filter(([key]) => example?.controls[Number(key)].flag)
.map(([key, value]) => {
const field = example!.controls[Number(key)];
return typeof value === "boolean" && field.booleanOptional
? ` ${value ? field.flag : field.flag!.replace(/^--/, "--no-")}`
: ` ${field.flag} '${String(value).replaceAll("'", "'\"'\"'")}'`;
})
.join("");
const sdk = mode === "sdk" && !!yamlSource;
// The SDK consumes only generator + request; serving and CLI keep server and every native field.
const displayedSource =
config && sdk
? stringify({
generator: config.generator as Record<string, unknown>,
request: config.request ?? config.default_request,
})
: source;
const launch = sdk ? command.replace(/fastvideo generate --config config\.yaml$/, sdkCommand) : command;
const ready = !!example && !failure;
const workload = recipe.task.toLowerCase().includes("image") ? "i2v" : "t2v";
const clientCommand =
mode === "serving" && ready
? api.sampleRequest(
{
runtime: { interface: "rest", server_defaults: { host: "127.0.0.1", port: 8000 } },
model: { id: model.model },
workload,
},
parse(source),
).command
: undefined;
const launchScript = yamlSource
? configAndLaunch(displayedSource, launch)
: fileAndLaunch(localFile, displayedSource, launch);
const environment = [...baselineCommand.matchAll(/(?:^|\s)([A-Z][A-Z0-9_]*=(?:'[^']*'|"[^"]*"|[^\s]+))/g)]
.map((match) => match[1])
.join("\n");
const mlx = recipe.gpu_types.includes("Apple Silicon");
const hardwareRecord = resolveHardware(hardware, hardwareCatalog.records);
const gpuCount = config ? gpuCountFrom(config) : (recipe.hardware?.gpu_count ?? 1);
const chooseHardware = (selection: HardwareSelection) => {
setHardware(selection);
const url = new URL(window.location.href);
writeHardwareSelection(url, selection);
window.history.replaceState(null, "", url);
};
const control = (field: Control, index: number) => {
const value = Object.hasOwn(edits, index) ? edits[index] : field.value;
return (
<div className="matrix-field" key={index}>
{field.choices || typeof value === "boolean" ? (
<span className="matrix-field-label">{field.label}</span>
) : (
<label htmlFor={`source-${index}`} className="matrix-field-label">
{field.label}
</label>
)}
{field.choices ? (
<div role="group" aria-label={field.label} className="option-chips">
{field.choices.map((option) => {
const choice = typeof option === "object" ? option.value : option;
return (
<button
type="button"
key={String(choice)}
aria-pressed={value === choice}
onClick={() => setEdits({ ...edits, [index]: choice })}
>
{typeof option === "object" ? option.label : String(option)}
</button>
);
})}
</div>
) : typeof value === "boolean" ? (
<div role="group" aria-label={field.label} className="option-chips">
{[true, false].map((choice) => (
<button
type="button"
key={String(choice)}
aria-pressed={value === choice}
onClick={() => setEdits({ ...edits, [index]: choice })}
>
{choice ? "On" : "Off"}
</button>
))}
</div>
) : (
<Input
id={`source-${index}`}
type={typeof field.value === "number" ? "number" : "text"}
value={typeof value === "number" && Number.isNaN(value) ? "" : String(value)}
step={typeof field.value === "number" && (field.integer ?? Number.isInteger(field.value)) ? 1 : "any"}
onChange={(event) =>
setEdits({
...edits,
[index]:
typeof field.value === "number"
? event.target.value === ""
? NaN
: Number(event.target.value)
: event.target.value,
})
}
/>
)}
</div>
);
};
return (
<div className="cookbook not-prose recipe-detail">
<ModelHeader
model={model}
actions={
<>
<Button
variant="outline"
disabled={!ready}
onClick={() => copy(displayedSource, yamlSource ? "Copied config.yaml." : "Copied Python example.")}
>
Copy {yamlSource ? "YAML" : "Python"}
</Button>
<Button
disabled={!ready}
onClick={() => {
const url = URL.createObjectURL(new Blob([displayedSource], { type: "text/plain" }));
const link = document.createElement("a");
link.href = url;
link.download = yamlSource ? "config.yaml" : localFile.split("/").at(-1)!;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}}
>
Download {yamlSource ? "YAML" : "Python"}
</Button>
</>
}
/>
<div className="recipe-run-modes option-chips" role="group" aria-label="Run mode">
{["sdk", "inference", ...(recipe.serving ? ["serving"] : [])].map((entry) => (
<button type="button" key={entry} aria-pressed={mode === entry} onClick={() => selectMode(entry)}>
{entry === "sdk" ? "Python SDK" : entry === "inference" ? "CLI" : "Serving"}
</button>
))}
</div>
<section className="recipe-command-box" aria-label="Source recipe">
<Tabs.Root value={outputTab} onValueChange={setOutputTab}>
<div className="recipe-command-toolbar">
<div className="recipe-command-tools">
<button
type="button"
disabled={!ready}
onClick={() =>
copy(
launchScript,
yamlSource ? "Copied config.yaml and launch command." : "Copied example and launch command.",
)
}
>
{yamlSource ? "Copy config + command" : "Copy example + command"}
</button>
{mode === "serving" && (
<button
type="button"
disabled={!clientCommand}
aria-expanded={showClient}
aria-controls="source-client-command"
onClick={() => {
setOutputTab("commands");
setShowClient(!showClient);
}}
>
Show request
</button>
)}
{environment && (
<button type="button" aria-pressed={showEnv} onClick={() => setShowEnv(!showEnv)}>
Show environment
</button>
)}
</div>
<Tabs.List className="recipe-tabs" aria-label="Output format">
<Tabs.Trigger value="commands">Commands</Tabs.Trigger>
<Tabs.Trigger value="source">{yamlSource ? "config.yaml" : "Python example"}</Tabs.Trigger>
</Tabs.List>
</div>
{showEnv && (
<pre className="recipe-environment" tabIndex={0} aria-label="Environment variables">
<code>{environment}</code>
</pre>
)}
{(error || failure) && (
<p className="recipe-error" role="alert">
{error || failure}
</p>
)}
<Tabs.Content value="commands" className="recipe-command-content">
<div className="recipe-code-heading">
<span>{mode === "serving" ? "Serve" : mode === "sdk" ? "Python SDK" : "Generate"}</span>
</div>
<pre tabIndex={0} aria-label={mode === "serving" ? "Server command" : "Inference command"}>
<code>{launch}</code>
</pre>
<p className="matrix-note">
Uses your settings; the copy action includes the source file and launch command. Run from an installed
FastVideo checkout.
</p>
{clientCommand && (
<details
className="recipe-request-code"
id="source-client-command"
open={showClient}
onToggle={(event) => setShowClient(event.currentTarget.open)}
>
<summary>Example request</summary>
<button type="button" onClick={() => copy(clientCommand, "Copied example request.")}>
Copy request command
</button>
<pre tabIndex={0} aria-label="Client command">
<code>{clientCommand}</code>
</pre>
</details>
)}
</Tabs.Content>
<Tabs.Content value="source" className="recipe-command-content">
<pre tabIndex={0} aria-label="Source example">
<code>{displayedSource || "Loading example…"}</code>
</pre>
</Tabs.Content>
</Tabs.Root>
<p role="status" className="recipe-copy-status">
{notice}
</p>
</section>
<details className="recipe-install">
<summary>
Installation and setup <span>{mlx ? "MLX" : "CUDA"}</span>
</summary>
{mode === "serving" && recipe.serving && (
<pre>
<code>{recipe.serving.install}</code>
</pre>
)}
{mode === "serving" && recipe.serving?.prepare && (
<>
<pre>
<code>{recipe.serving.prepare}</code>
</pre>
<Button variant="outline" onClick={() => copy(recipe.serving!.prepare!)}>
Copy preparation commands
</Button>
</>
)}
<a href={sitePath(model.familyPath)}>Model setup guide ↗</a>
</details>
<section className="recipe-matrix" aria-label="Configuration settings">
<div className="matrix-row">
<h2>Hardware</h2>
<div className="matrix-row-content">
<div className="hardware-backends option-chips" role="group" aria-label="Hardware backend">
{[
...new Set(model.recipes.map((entry) => (entry.gpu_types.includes("Apple Silicon") ? "MLX" : "CUDA"))),
].map((platform) => (
<button
type="button"
key={platform}
aria-pressed={platform === (mlx ? "MLX" : "CUDA")}
onClick={() => {
if (platform === (mlx ? "MLX" : "CUDA")) return;
const target = model.recipes.find((entry) =>
entry.gpu_types.includes(platform === "MLX" ? "Apple Silicon" : "NVIDIA"),
)!;
setSelected(target.id);
setMode("sdk");
setEdits({});
setNotice(`Switched to the ${platform} recipe; generation edits were reset.`);
}}
>
{platform}
</button>
))}
</div>
<div className="hardware-resource-grid">
<HardwarePicker
backend={mlx ? "mlx" : "cuda"}
catalog={hardwareCatalog.records}
status={hardwareCatalog.status}
error={hardwareCatalog.error}
reload={hardwareCatalog.reload}
value={hardware}
onChange={chooseHardware}
gpuCount={gpuCount}
/>
<div className="matrix-resources">
<span className="matrix-resource-title">Resources</span>
{example?.controls.map((field, i) => (field.name === "num_gpus" ? control(field, i) : null))}
{!example?.controls.some((field) => field.name === "num_gpus") && (
<p>{mlx ? "1 Apple Silicon host" : `${recipe.hardware?.gpu_count ?? 1} GPU in the source example`}</p>
)}
<a href={sitePath(mlx ? "/getting_started/installation/mlx" : "/getting_started/installation/gpu")}>
Setup guide ↗
</a>
</div>
</div>
<RecipeFacts
validity={failure ? "invalid" : example ? "valid" : "loading"}
validityText={yamlSource ? "YAML parses" : "Example settings applied"}
validityDetail="Checks that the YAML parses and that edited numbers are valid. It does not run the recipe."
subject={yamlSource ? "configuration" : "example"}
/>
</div>
</div>
{model.recipes.filter((entry) => entry.gpu_types.includes(mlx ? "Apple Silicon" : "NVIDIA")).length > 1 && (
<div className="matrix-row">
<h2>Recipe</h2>
<div className="matrix-row-content option-chips" role="group" aria-label="Recipe variant">
{model.recipes
.filter((entry) => entry.gpu_types.includes(mlx ? "Apple Silicon" : "NVIDIA"))
.map((entry) => (
<button
type="button"
key={entry.id}
aria-pressed={selected === entry.id}
onClick={() => {
setNotice("");
setSelected(entry.id);
setMode("sdk");
setEdits({});
}}
>
{entry.label}
</button>
))}
</div>
</div>
)}
<div className="matrix-row">
<h2>Generation settings</h2>
<div className="matrix-row-content">
<div className="matrix-options-grid">
{example?.controls.map((field, i) =>
field.name !== "num_gpus" && field.path?.[0] !== "server" ? control(field, i) : null,
)}
</div>
{example && !example.controls.length && (
<p className="matrix-note">
This example uses its model defaults. Open the Python example to customize its pipeline.
</p>
)}
</div>
</div>
{example?.controls.some((field) => field.path?.[0] === "server") && (
<details className="matrix-advanced">
<summary>
Advanced <span>Server settings</span>
</summary>
<div className="matrix-row">
<h2>Server</h2>
<div className="matrix-row-content matrix-options-grid">
{example.controls.map((field, i) => (field.path?.[0] === "server" ? control(field, i) : null))}
</div>
</div>
</details>
)}
</section>
<div className="recipe-bottom-actions">
<a
href={`https://github.com/hao-ai-lab/FastVideo/blob/main/${mode === "serving" ? recipe.serving?.source : recipe.source}`}
>
View original example ↗
</a>
<Button
variant="ghost"
onClick={() => {
setEdits({});
setNotice("Restored the baseline example.");
}}
>
Reset changes
</Button>
</div>
</div>
);
}
-53
View File
@@ -1,53 +0,0 @@
import { useEffect, useState } from "react";
// @ts-expect-error The shared ES module has no TypeScript declarations.
import { loadCapacityIndex, loadCapacityModel } from "../../docs/js/cookbook-capacity.mjs";
export { assessRecipe, assessWeightsRecipe, weightsFor } from "./memory-assessment";
export type { MemoryAssessment } from "./memory-assessment";
interface CapacityModel {
id: string;
name?: string;
examples?: { deployment_id?: string }[];
reference_runs?: unknown[];
[key: string]: unknown;
}
interface CapacityState {
status: "loading" | "ready" | "unavailable" | "error";
model?: CapacityModel;
error?: string;
}
const CAPACITY_INDEX = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/assets/cookbook-config/capacity/index.json`;
/**
* Load the capacity model for one cookbook entry. Most recipes do not have one;
* the hook stays `unavailable` and the UI says so instead of guessing.
*/
export function useCapacityModel(modelId: string | undefined): CapacityState & { retry: () => void } {
const [state, setState] = useState<CapacityState>({ status: "loading" });
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!modelId) {
setState({ status: "unavailable" });
return;
}
const controller = new AbortController();
setState({ status: "loading" });
loadCapacityIndex(new URL(CAPACITY_INDEX, window.location.origin).href, { signal: controller.signal })
.then(async (index: { models: { id: string }[] }) => {
if (!index.models.some((model) => model.id === modelId)) {
if (!controller.signal.aborted) setState({ status: "unavailable" });
return;
}
const model = (await loadCapacityModel(index, modelId, { signal: controller.signal })) as CapacityModel;
if (!controller.signal.aborted) setState({ status: "ready", model });
})
.catch((error: unknown) => {
if (!controller.signal.aborted)
setState({ status: "error", error: error instanceof Error ? error.message : "Unable to load memory data." });
});
return () => controller.abort();
}, [modelId, attempt]);
return { ...state, retry: () => setAttempt((value) => value + 1) };
}
-82
View File
@@ -1,82 +0,0 @@
import { stringify } from "yaml";
import type { Result, Value } from "./cookbook";
export function pythonSource(
source: string,
controls: { value: string | number | boolean; spans?: number[][] }[],
edits: Record<number, string | number | boolean> = {},
) {
const replacements = controls.flatMap((field, index) =>
(field.spans ?? []).map(([start, end]) => {
const value = Object.hasOwn(edits, index) ? edits[index] : field.value;
return { start, end, text: typeof value === "boolean" ? (value ? "True" : "False") : JSON.stringify(value) };
}),
);
for (const replacement of replacements.sort((a, b) => b.start - a.start))
source = source.slice(0, replacement.start) + replacement.text + source.slice(replacement.end);
return source;
}
/** Quoted heredocs preserve prompts literally, including shell substitutions. */
export function configAndLaunch(yaml: string, command: string) {
return fileAndLaunch("config.yaml", yaml, command);
}
export function fileAndLaunch(filename: string, content: string, command: string) {
let delimiter = "FASTVIDEO_CONFIG";
while (content.split("\n").includes(delimiter)) delimiter += "_";
return `(\nset -e\ncat > ${filename} <<'${delimiter}'\n${content.trimEnd()}\n${delimiter}\n${command}\n)`;
}
export const sdkCommand = `python - <<'FASTVIDEO_SDK'
import yaml
from fastvideo import VideoGenerator
with open("config.yaml") as file:
config = yaml.safe_load(file)
generator = VideoGenerator.from_config(config["generator"])
try:
generator.generate(config["request"])
finally:
generator.shutdown()
FASTVIDEO_SDK`;
/** The CUDA CLI uses the same generator with request instead of server defaults. */
export function inferenceOutput(result: Result, imagePath?: string) {
const defaults = (result.config.default_request ?? {}) as Record<string, Value>;
const output = (defaults.output ?? {}) as Record<string, Value>;
const generator = result.config.generator as Record<string, Value>;
const pipeline = generator.pipeline as Record<string, Value> | undefined;
const inputs = { ...((defaults.inputs ?? {}) as Record<string, Value>) };
const components = pipeline?.components as Record<string, Value> | undefined;
// Ref2VA pipelines read ordered references, not a first-frame image.
const ref2va = /ref2va/i.test(String(components?.override_pipeline_cls_name ?? ""));
if (imagePath !== undefined && imagePath.trim()) {
if (ref2va) inputs.references = [{ source: imagePath.trim(), media_type: "image" }];
else inputs.image_path = imagePath.trim();
} else if (imagePath !== undefined && !ref2va) inputs.image_path = imagePath.trim();
const references = Array.isArray(inputs.references) ? (inputs.references as Record<string, Value>[]) : [];
const images = ref2va
? references.map((reference) => reference.source)
: Array.isArray(inputs.image_path)
? inputs.image_path
: [inputs.image_path];
if (pipeline?.workload_type === "i2v" && !images.some((image) => typeof image === "string" && image.trim())) {
throw new Error("Enter a source image path before copying a CLI or Python SDK example.");
}
return {
yaml: stringify({
generator: result.config.generator,
request: {
...defaults,
...(Object.keys(inputs).length ? { inputs } : {}),
prompt: defaults.prompt ?? "A river flowing through a peaceful forest",
output: { ...output, save_video: true, return_frames: false, output_path: "outputs/cookbook/" },
},
}),
command: result.command.replace(
/\bfastvideo serve --config config\.yaml$/,
"fastvideo generate --config config.yaml",
),
};
}
-237
View File
@@ -1,237 +0,0 @@
import sourceCatalog from "../../docs/assets/cookbook-recipes.json" with { type: "json" };
/** Presentation metadata only. Deployments and editable fields come from the cookbook backend. */
export const recipePages = [
{
id: "fasth3-preview",
family: "MiniMax H3",
title: "FastH3 Preview V0.2",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-preview",
description: "The few-step FastH3 preview checkpoint on CUDA or native Apple Silicon MLX.",
tags: ["Text to video", "Audio", "4-step"],
},
{
id: "fasth3-8step",
family: "MiniMax H3",
title: "FastH3 8-Step V2",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-8step",
description: "Distilled MiniMax-H3 video generation with synchronized audio.",
tags: ["Text to video", "Audio", "8-step"],
},
{
id: "fasth3-trim",
family: "MiniMax H3",
title: "FastH3 Trim",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-trim",
description: "The smaller FastH3 model for eight-step video generation with synchronized audio.",
tags: ["Text to video", "Audio", "NVFP4", "8-step"],
},
{
id: "fasth3-4step",
family: "MiniMax H3",
title: "FastH3 4-Step V1",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-4step",
description: "FastH3 V1 on one DGX Spark with offloaded encoders and lazy module loading.",
tags: ["Text to video", "Audio", "DGX Spark"],
},
{
id: "fasth3-omniref",
family: "MiniMax H3",
title: "FastH3 Omni v1",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-omniref",
description: "Video and audio from a prompt plus ordered image, video, and audio references.",
tags: ["Reference to video", "Audio", "NVFP4"],
featured: true,
},
{
id: "open-h3-regenerate-2k",
family: "MiniMax H3",
title: "Open-H3-Regenerate-2K",
mark: "H3",
path: "/cookbook/minimax-h3/open-h3-regenerate-2k",
description: "Regenerate a 768P MiniMax-H3 draft at 2560x1440 with a three-step refiner.",
tags: ["Video upscale", "2K", "3-step"],
},
{
id: "fastwan21",
family: "Wan",
title: "FastWan2.1 1.3B",
mark: "Wan",
path: "/cookbook/wan/fastwan21",
description: "A compact, distilled Wan model for text-to-video generation.",
tags: ["Text to video", "1.3B", "3-step"],
},
{
id: "wan21-i2v",
family: "Wan",
title: "Wan2.1 I2V 14B",
mark: "Wan",
path: "/cookbook/wan/wan21-i2v",
description: "Turn a reference image into video with Wan2.1.",
tags: ["Image to video", "14B", "480p"],
},
{
id: "ltx2-distilled",
family: "LTX",
title: "LTX2 Distilled",
mark: "LTX",
path: "/cookbook/ltx/ltx2-distilled",
description: "Generate video with incremental delivery over WebSocket.",
tags: ["Text to video", "Streaming"],
},
];
export type RecipePage = (typeof recipePages)[number];
export function sitePath(path: string) {
return `${import.meta.env.BASE_URL.replace(/\/$/, "")}${path}`;
}
export interface SourceRecipe {
id: string;
family: string;
task: string;
label: string;
summary: string;
model: string;
source: string;
command: string;
gpu_types: string[];
hardware?: { gpu_count?: number; platform?: string; accelerator?: string };
serving?: { source: string; install: string; env?: string; prepare?: string };
knobs?: {
key: string;
label: string;
hint?: string;
flag: string;
default: string | number;
options: (string | number | { value: string | number; label: string })[];
}[];
}
export const sourceRecipes = sourceCatalog.recipes as SourceRecipe[];
const familyInfo: Record<string, { title: string; path: string; logo: string; category: string }> = {
minimax_h3: { title: "MiniMax H3", path: "minimax-h3", logo: "minimax", category: "Video" },
pika_h3_flex: { title: "MiniMax H3", path: "minimax-h3", logo: "pika", category: "Video" },
wan: { title: "Wan", path: "wan", logo: "wan-ai", category: "Video" },
ltx2: { title: "LTX", path: "ltx", logo: "ltx", category: "Video" },
hunyuan: { title: "Hunyuan", path: "hunyuan", logo: "tencent-hunyuan", category: "Video" },
kandinsky5: { title: "Kandinsky 5", path: "kandinsky5", logo: "kandinsky", category: "Video" },
kandinsky6: { title: "Kandinsky 6", path: "kandinsky6", logo: "kandinsky", category: "Video" },
flux: { title: "FLUX", path: "flux", logo: "black-forest-labs", category: "Image" },
qwen_image21: { title: "Qwen-Image-2.1", path: "qwen-image21", logo: "qwen", category: "Image" },
sd35: { title: "Stable Diffusion", path: "stable-diffusion", logo: "stabilityai", category: "Image" },
stable_audio: { title: "Stable Audio", path: "stable-audio", logo: "stabilityai", category: "Audio" },
mmaudio: { title: "MMAudio", path: "mmaudio", logo: "fastvideo", category: "Audio" },
cosmos: { title: "Cosmos", path: "cosmos", logo: "nvidia", category: "World" },
matrixgame: { title: "Matrix Game", path: "matrix-game", logo: "fastvideo", category: "World" },
};
const backendModels: Record<string, string> = {
"FastVideo/FastVideo-FastH3-8-Step-V2": "fasth3-8step",
"FastVideo/FastVideo-FastH3-Trim-8-Step-NVFP4": "fasth3-trim",
"FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2": "fasth3-preview",
"FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree": "fasth3-4step",
"FastVideo/FastH3-OmniRef-NVFP4-QAD": "fasth3-omniref",
"Efficient-Large-Model/Open-H3-Regenerate-2K": "open-h3-regenerate-2k",
"FastVideo/FastWan2.1-T2V-1.3B-Diffusers": "fastwan21",
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers": "wan21-i2v",
"FastVideo/LTX2-Distilled-Diffusers": "ltx2-distilled",
};
const hiddenBackends = new Set(["fasth3-preview"]);
const modelAddedOn: Record<string, string> = {
"Qwen/Qwen-Image-2.1": "2026-10-25",
};
export function recentlyAdded(model: string) {
const added = modelAddedOn[model];
return !!added && Date.now() - Date.parse(added) < 30 * 86_400_000;
}
const TASK_GROUPS: Array<[RegExp, string]> = [
[/world/, "Text to world"],
[/video\/text to audio|video.*to audio/, "Video to audio"],
[/text to audio/, "Text to audio"],
[/text to image/, "Text to image"],
[/image to image/, "Image to image"],
[/reference media to video/, "Reference to video"],
[/first\/last frame to video/, "Image to video"],
[/text or image to video/, "Text/image to video"],
[/image to video/, "Image to video"],
[/super-resolution|upscale/, "Video upscale"],
[/video/, "Text to video"],
];
/** A small discovery vocabulary derived from the recipe task, not a capability claim. */
export function taskLabel(value: string): string {
const normalized = value.toLowerCase();
return TASK_GROUPS.find(([pattern]) => pattern.test(normalized))?.[1] ?? "Text to video";
}
const mappedModels = [...new Set(sourceRecipes.map((recipe) => recipe.model))].map((model) => {
const recipes = sourceRecipes.filter((recipe) => recipe.model === model);
const first = recipes[0];
const family = familyInfo[first.family];
const backend = recipePages.find((page) => page.id === backendModels[model]);
const ownModel = model.startsWith("FastVideo/Fast");
const logo = ownModel ? "fastvideo" : family.logo;
const providers: Record<string, string> = {
fastvideo: "FastVideo",
minimax: "MiniMax",
pika: "Pika",
"wan-ai": "Wan AI",
ltx: "Lightricks",
"tencent-hunyuan": "Tencent",
kandinsky: "Kandinsky Lab",
"black-forest-labs": "Black Forest Labs",
qwen: "Qwen",
stabilityai: "Stability AI",
nvidia: "NVIDIA",
};
return {
id: backend?.id ?? first.id,
model,
recipes,
title:
backend?.title ??
(model.includes("FastH3-4-step")
? "FastH3 4-Step V1"
: model.includes("FastH3-Preview-v0.2")
? "FastH3 Preview V0.2"
: model === "MiniMaxAI/MiniMax-H3"
? "MiniMax H3"
: first.label.split(" on ")[0]),
family: backend?.family ?? family.title,
familyPath: `/cookbook/${family.path}`,
path: backend?.path ?? `/cookbook/${family.path}/models/${first.id}`,
logo,
provider: providers[logo],
category: family.category,
description: backend?.description ?? first.summary,
tags: backend?.tags ?? [...new Set(recipes.map((recipe) => recipe.task))],
task: taskLabel(backend?.tags[0] ?? first.task),
platforms: [...new Set(recipes.flatMap((recipe) => recipe.gpu_types))],
configurable: !!backend,
hidden: hiddenBackends.has(backend?.id ?? ""),
featured: backend?.featured ?? false,
};
});
const byPage = new Map<string, (typeof mappedModels)[number]>();
for (const entry of mappedModels) {
const existing = byPage.get(entry.id);
if (!existing) {
byPage.set(entry.id, entry);
continue;
}
existing.recipes.push(...entry.recipes.filter((recipe) => !existing.recipes.some((seen) => seen.id === recipe.id)));
existing.platforms = [...new Set([...existing.platforms, ...entry.platforms])];
}
export const modelDirectory = [...byPage.values()].sort(
(a, b) =>
Number(b.featured) - Number(a.featured) ||
Number(b.family === "MiniMax H3") - Number(a.family === "MiniMax H3") ||
Number(b.configurable) - Number(a.configurable),
);
export type DirectoryModel = (typeof modelDirectory)[number];
-107
View File
@@ -1,107 +0,0 @@
// The public JavaScript contract lives in docs/cookbook/design.md.
// @ts-expect-error The shared ES module has no TypeScript declarations.
import * as shared from "../../docs/js/cookbook-config.mjs";
export type Value = null | boolean | number | string | Value[] | { [key: string]: Value };
export interface Schema {
title?: string;
description?: string;
type?: string | string[];
enum?: Value[];
anyOf?: Schema[];
minimum?: number;
maximum?: number;
multipleOf?: number;
}
export interface Field {
path: string;
schema: Schema;
value?: Value;
}
export interface Index {
url: string;
models: {
id: string;
title: string;
model_id: string;
deployments: {
id: string;
label: string;
runtime: string;
workload: string;
}[];
}[];
}
export interface Catalog {
id: string;
title: string;
summary?: string;
workload: string;
env: Record<string, string>;
base_config: Record<string, Value>;
runtime: {
label: string;
backend: string;
interface: string;
install_url?: string;
client_guide_url?: string;
};
requirements?: string[];
guide?: { url: string; label?: string };
}
export interface Result {
config: Record<string, Value>;
yaml: string;
command: string;
clientCommand: string;
websocketUrl?: string;
}
interface Api {
sampleRequest(
catalog: {
runtime: { interface: string; server_defaults: { host: string; port: number } };
model: { id: string };
workload: string;
},
config: Record<string, Value>,
): { command: string };
loadIndex(url: string, options?: { signal?: AbortSignal }): Promise<Index>;
loadDeployment(index: Index, id: string, options?: { signal?: AbortSignal }): Promise<Catalog>;
getOptions(catalog: Catalog, config?: Catalog["base_config"], edits?: Record<string, Value>): Field[];
resolveConfig(catalog: Catalog, edits?: Record<string, Value>): Result;
}
export const api = shared as Api;
export function types(schema: Schema): string[] {
return [
...new Set([
...(Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : []),
...(schema.anyOf?.flatMap(types) ?? []),
]),
];
}
export function parseField(field: Field, raw: string): Value {
const kind = types(field.schema);
if (field.schema.enum || kind.includes("boolean") || kind.includes("object") || kind.includes("array")) {
return JSON.parse(raw) as Value;
}
if (kind.includes("number") || kind.includes("integer")) {
if (!raw.trim() || !Number.isFinite(Number(raw))) throw new Error("Enter a number.");
return Number(raw);
}
return raw;
}
/** Keep catalog guide links on the current site and its deployment prefix. */
export function guideUrl(path: string): string {
const url = new URL(
path,
new URL(`${import.meta.env.BASE_URL.replace(/\/$/, "")}/assets/cookbook-config/index.json`, window.location.origin),
);
if (url.origin === "https://haoailab.com" && url.pathname.startsWith("/FastVideo/")) {
url.host = window.location.host;
url.protocol = window.location.protocol;
}
return url.href;
}
-3
View File
@@ -1,3 +0,0 @@
interface ImportMeta {
readonly env: { readonly BASE_URL: string };
}
-116
View File
@@ -1,116 +0,0 @@
export const families = [
{
name: "FastH3",
mark: "H3",
summary: "Distilled MiniMax-H3 video with synchronized audio, from text or ordered references.",
collection: "https://huggingface.co/collections/FastVideo/fasth3-v2",
guide: "/cookbook/minimax-h3",
detail: "OmniRef \u00b7 8-step V2 \u00b7 QAD v2 \u00b7 FP8 and NVFP4 exports \u00b7 NVFP4 VAE decoder",
models: [
{
id: "FastH3-OmniRef-NVFP4-QAD",
url: "https://huggingface.co/FastVideo/FastH3-OmniRef-NVFP4-QAD",
},
{
id: "FastH3-QAD-v2",
url: "https://huggingface.co/FastVideo/FastH3-QAD-v2",
},
{
id: "FastH3-NVFP4-VAE-Decoder",
url: "https://huggingface.co/FastVideo/FastH3-NVFP4-VAE-Decoder",
},
{
id: "FastVideo-FastH3-8-Step-V2",
url: "https://huggingface.co/FastVideo/FastVideo-FastH3-8-Step-V2",
},
{
id: "FastVideo-FastH3-8-Step-V2-NVFP4",
url: "https://huggingface.co/FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4",
},
{
id: "FastVideo-FastH3-8-Step-V2-FP8",
url: "https://huggingface.co/FastVideo/FastVideo-FastH3-8-Step-V2-FP8",
},
{
id: "FastVideo-FastH3-8-Step-V2-NVFP4-Consumer",
url: "https://huggingface.co/FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer",
},
{
id: "FastVideo-FastH3-Trim-8-Step",
url: "https://huggingface.co/FastVideo/FastVideo-FastH3-Trim-8-Step",
},
{
id: "FastVideo-FastH3-Trim-8-Step-NVFP4",
url: "https://huggingface.co/FastVideo/FastVideo-FastH3-Trim-8-Step-NVFP4",
},
{
id: "FastVideo-FastH3-Trim-8-Step-FP8",
url: "https://huggingface.co/FastVideo/FastVideo-FastH3-Trim-8-Step-FP8",
},
{
id: "FastVideo-FastH3-8-Step-V2-NVFP4-Comfy",
url: "https://huggingface.co/FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Comfy",
},
{
id: "FastVideo-FastH3-Trim-Comfy",
url: "https://huggingface.co/FastVideo/FastVideo-FastH3-Trim-Comfy",
},
{
id: "FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree",
url: "https://huggingface.co/FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree",
},
],
},
{
name: "FastWan",
mark: "Wan",
summary: "Distilled Wan models for text-to-video and image-to-video.",
collection: "https://huggingface.co/collections/FastVideo/fastwan",
guide: "/cookbook/wan",
detail: "1.3B, 5B, and 14B releases",
models: [
{
id: "FastWan2.1-T2V-1.3B-Diffusers",
url: "https://huggingface.co/FastVideo/FastWan2.1-T2V-1.3B-Diffusers",
},
{
id: "FastWan2.1-T2V-14B-Diffusers",
url: "https://huggingface.co/FastVideo/FastWan2.1-T2V-14B-Diffusers",
},
{
id: "FastWan2.2-TI2V-5B-FullAttn-Diffusers",
url: "https://huggingface.co/FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers",
},
{
id: "FastWan-QAD-FP8-1.3B",
url: "https://huggingface.co/FastVideo/FastWan-QAD-FP8-1.3B",
},
{
id: "CausalWan2.2-I2V-A14B-Preview-Diffusers",
url: "https://huggingface.co/FastVideo/CausalWan2.2-I2V-A14B-Preview-Diffusers",
},
],
},
{
name: "FastMetal",
mark: "Metal",
summary: "Quantized Wan checkpoints for Apple Silicon and MLX.",
collection: "https://huggingface.co/collections/FastVideo/fastmetal",
guide: "/getting_started/installation/mlx",
detail: "INT8 QAD \u00b7 1.3B, 5B, and 14B",
models: [
{
id: "FastMetal-1.3B-QAD",
url: "https://huggingface.co/FastVideo/FastMetal-1.3B-QAD",
},
{
id: "FastMetal-5B-QAD",
url: "https://huggingface.co/FastVideo/FastMetal-5B-QAD",
},
{
id: "FastMetal-14B-QAD",
url: "https://huggingface.co/FastVideo/FastMetal-14B-QAD",
},
],
},
];
-352
View File
@@ -1,352 +0,0 @@
import { useEffect, useState } from "react";
export type MemoryType = "dedicated" | "unified" | "system";
export type MemoryUnit = "GB" | "GiB";
/** One capacity variant from the shared hardware catalog. */
export interface HardwareRecord {
id: string;
name: string;
memory_type: MemoryType;
memory_gb: number;
memory_unit: MemoryUnit;
backend: string;
supported_dtypes?: string[];
sources?: string[];
notes?: string;
ui_id?: string;
aliases?: string[];
vendor?: string;
kind?: string;
family?: string;
variant?: string;
form_factor?: string;
gpu_count?: number;
host_ram_gb?: number;
usable_budget_gb?: number;
}
export interface CustomHardware {
name: string;
backend: string;
memory_type: MemoryType;
memory_gb: number;
memory_unit: MemoryUnit;
usable_budget_gb?: number;
host_ram_gb?: number;
}
export interface HardwareSelection {
id: string;
custom?: CustomHardware;
}
export interface HardwareCatalogState {
status: "loading" | "ready" | "unavailable";
records: HardwareRecord[];
error?: string;
reload: () => void;
}
const HARDWARE_PATH = "/assets/cookbook-config/capacity/hardware.json";
const CUSTOM_PARAMS = ["hwName", "hwBackend", "hwType", "hwMemory", "hwUnit", "hwBudget", "hwRam"] as const;
const MEMORY_TYPES: MemoryType[] = ["dedicated", "unified", "system"];
function text(value: unknown, max = 200): string | undefined {
return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : undefined;
}
function positive(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 4096 ? value : undefined;
}
function numberText(value: string | null): number | undefined {
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 4096 ? parsed : undefined;
}
function memoryType(value: unknown): MemoryType | undefined {
return MEMORY_TYPES.includes(value as MemoryType) ? (value as MemoryType) : undefined;
}
function memoryUnit(value: unknown): MemoryUnit {
return value === "GiB" ? "GiB" : "GB";
}
function backend(value: unknown, fallback = "cuda"): string {
if (value === "mlx" || value === "metal") return "mlx";
if (value === "cuda" || value === "rocm") return value;
return fallback;
}
/** Catalog records keep backends the cookbook cannot run, so they never pose as CUDA targets. */
function catalogBackend(value: unknown): string {
if (value === "sycl" || value === "unknown") return value;
return backend(value, "unknown");
}
/** Accepts the generated `{ version, devices: [] }` asset and the older mapping forms. */
function recordsFrom(payload: unknown): HardwareRecord[] {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return [];
const container = payload as Record<string, unknown>;
let entries: Array<[string, unknown]> = [];
if (Array.isArray(container.devices)) {
entries = container.devices.map((entry) => {
const item = entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
return [text(item.id, 120) ?? "", entry];
});
} else {
const source =
container.hardware && typeof container.hardware === "object" && !Array.isArray(container.hardware)
? (container.hardware as Record<string, unknown>)
: container;
entries = Object.entries(source);
}
const records: HardwareRecord[] = [];
for (const [fallbackId, value] of entries) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const item = value as Record<string, unknown>;
const id = text(item.id, 120) ?? text(fallbackId, 120);
const name = text(item.name, 120);
const memory =
item.memory && typeof item.memory === "object" ? (item.memory as Record<string, unknown>) : undefined;
const nominal =
memory?.nominal && typeof memory.nominal === "object" ? (memory.nominal as Record<string, unknown>) : undefined;
const capacity = positive(nominal?.value) ?? positive(item.memory_gb);
const type = memoryType(memory?.type) ?? memoryType(item.memory_type);
if (!id || !name || !capacity || !type) continue;
const usable =
memory?.usable && typeof memory.usable === "object" ? (memory.usable as Record<string, unknown>) : undefined;
const host =
item.host_memory && typeof item.host_memory === "object"
? (item.host_memory as Record<string, unknown>)
: undefined;
const aliases = Array.isArray(item.aliases)
? item.aliases.map((alias) => text(alias, 80)).filter((alias): alias is string => !!alias)
: undefined;
const sources = Array.isArray(item.sources)
? item.sources
.map((source) =>
typeof source === "string"
? text(source, 500)
: source && typeof source === "object"
? text((source as Record<string, unknown>).url, 500)
: undefined,
)
.filter((url): url is string => !!url)
: undefined;
records.push({
id,
name,
memory_type: type,
memory_gb: capacity,
memory_unit: memoryUnit(nominal?.unit ?? item.memory_unit),
backend: catalogBackend(item.backend),
supported_dtypes: Array.isArray(item.supported_dtypes)
? item.supported_dtypes.filter((dtype): dtype is string => typeof dtype === "string")
: undefined,
sources,
notes: text(item.notes, 600),
ui_id: text(item.ui_id, 40),
aliases,
vendor: text(item.vendor, 80),
kind: text(item.kind, 40),
family: text(item.family, 80),
variant: text(item.variant, 80),
form_factor: text(item.form_factor, 40),
gpu_count: positive(item.gpu_count),
host_ram_gb: positive(host?.value) ?? positive(item.host_memory_gb) ?? positive(item.host_ram_gb),
usable_budget_gb: positive(usable?.value) ?? positive(item.usable_memory_gb) ?? positive(item.usable_budget_gb),
});
}
return records.sort((a, b) => a.name.localeCompare(b.name));
}
/** Loads only the shared lookup data. It does not estimate fit or inspect the browser's hardware. */
export function useHardwareCatalog(): HardwareCatalogState {
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<Omit<HardwareCatalogState, "reload">>({
status: "loading",
records: [],
});
useEffect(() => {
if (typeof window === "undefined") return;
const controller = new AbortController();
const url = new URL(`${import.meta.env.BASE_URL.replace(/\/$/, "")}${HARDWARE_PATH}`, window.location.origin);
setState({ status: "loading", records: [] });
fetch(url.href, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return recordsFrom(await response.json());
})
.then((records) => {
if (controller.signal.aborted) return;
setState(
records.length
? { status: "ready", records }
: { status: "unavailable", records: [], error: "The hardware catalog is empty." },
);
})
.catch((error) => {
if (!controller.signal.aborted)
setState({
status: "unavailable",
records: [],
error: error instanceof Error ? error.message : "The hardware catalog could not be loaded.",
});
});
return () => controller.abort();
}, [attempt]);
return { ...state, reload: () => setAttempt((current) => current + 1) };
}
export function readHardwareSelection(search: URLSearchParams): HardwareSelection {
const requested = search.get("hardware")?.trim() ?? "";
if (requested.startsWith("custom:")) {
return {
id: "custom",
custom: {
name: requested.slice(7).slice(0, 80),
backend: "cuda",
memory_type: "dedicated",
memory_gb: 0,
memory_unit: "GB",
},
};
}
if (requested !== "custom") return { id: requested };
return {
id: "custom",
custom: {
name: text(search.get("hwName"), 80) ?? "",
backend: backend(search.get("hwBackend")),
memory_type: memoryType(search.get("hwType")) ?? "dedicated",
memory_gb: numberText(search.get("hwMemory")) ?? 0,
memory_unit: memoryUnit(search.get("hwUnit")),
usable_budget_gb: numberText(search.get("hwBudget")),
host_ram_gb: numberText(search.get("hwRam")),
},
};
}
export function writeHardwareSelection(target: URL | URLSearchParams, selection: HardwareSelection) {
const search = target instanceof URL ? target.searchParams : target;
for (const key of CUSTOM_PARAMS) search.delete(key);
if (!selection.id) {
search.delete("hardware");
return;
}
search.set("hardware", selection.id);
if (selection.id !== "custom" || !selection.custom) return;
const custom = selection.custom;
if (custom.name) search.set("hwName", custom.name.slice(0, 80));
search.set("hwBackend", custom.backend);
search.set("hwType", custom.memory_type);
if (custom.memory_gb > 0) search.set("hwMemory", String(custom.memory_gb));
search.set("hwUnit", custom.memory_unit);
if (custom.usable_budget_gb) search.set("hwBudget", String(custom.usable_budget_gb));
if (custom.host_ram_gb) search.set("hwRam", String(custom.host_ram_gb));
}
export function resolveHardware(selection: HardwareSelection, records: HardwareRecord[]): HardwareRecord | undefined {
if (selection.id === "custom") {
const custom = selection.custom;
if (!custom?.name.trim() || !custom.memory_gb) return undefined;
return {
id: "custom",
name: custom.name.trim(),
memory_type: custom.memory_type,
memory_gb: custom.memory_gb,
memory_unit: custom.memory_unit,
backend: custom.backend,
usable_budget_gb:
custom.usable_budget_gb && custom.usable_budget_gb <= custom.memory_gb ? custom.usable_budget_gb : undefined,
host_ram_gb: custom.host_ram_gb,
};
}
return (
records.find((record) => record.id === selection.id) ??
records.find((record) => record.aliases?.includes(selection.id))
);
}
/** A query-only selector shared by the hardware picker and tests. */
export function searchHardware(records: HardwareRecord[], query: string): HardwareRecord[] {
const tokens = query
.toLowerCase()
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean);
if (!tokens.length) return records;
return records.filter((record) => {
const haystack = [
record.name,
record.id,
record.ui_id,
record.vendor,
record.kind,
record.family,
record.variant,
record.form_factor,
record.gpu_count ? `${record.gpu_count}gpu` : undefined,
record.backend,
record.memory_type,
`${record.memory_gb}gb`,
`${record.memory_gb}gib`,
...(record.aliases ?? []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return tokens.every((token) => haystack.includes(token));
});
}
export function memoryText(record: HardwareRecord, includeUnit = true): string {
const value = `${record.memory_gb} ${record.memory_unit}`;
if (!includeUnit) return value;
if (record.memory_type === "dedicated") return `${value} VRAM`;
if (record.memory_type === "unified") return `${value} unified memory`;
return `${value} system memory`;
}
export function memoryContext(record: HardwareRecord): string {
if (record.memory_type === "dedicated") return "Dedicated GPU memory. It is per GPU and is not shared with host RAM.";
if (record.memory_type === "unified")
return "Unified memory is shared by the CPU and GPU; the full amount is not available to one process.";
return "System memory is host RAM used by the CPU and any offload.";
}
export function backendLabel(value: string): string {
if (value === "mlx") return "Apple MLX";
if (value === "rocm") return "ROCm";
if (value === "sycl") return "Intel SYCL";
if (value === "unknown") return "Unknown backend";
return "CUDA";
}
/** GPU count never multiplies a catalog capacity into a pool. */
export function gpuScopeNote(record: HardwareRecord | undefined, gpuCount: number): string | undefined {
if (!record || gpuCount <= 1) return undefined;
if (record.memory_type === "unified")
return `${gpuCount} GPUs share unified memory. This catalog entry describes one system; its ${record.memory_gb} ${record.memory_unit} is not multiplied.`;
if (record.memory_type === "system")
return `${gpuCount} GPUs use host system memory. This entry describes one host; the total is not multiplied.`;
return `${gpuCount} GPUs × ${record.memory_gb} ${record.memory_unit} VRAM. Memory is per device, not a combined pool.`;
}
export function gpuCountFrom(config: Record<string, unknown> | undefined): number {
const generator = config?.generator;
if (!generator || typeof generator !== "object" || Array.isArray(generator)) return 1;
const root = generator as Record<string, unknown>;
const engine =
root.engine && typeof root.engine === "object" && !Array.isArray(root.engine)
? (root.engine as Record<string, unknown>)
: undefined;
for (const value of [root.num_gpus, engine?.num_gpus]) {
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
}
return 1;
}
-171
View File
@@ -1,171 +0,0 @@
// @ts-expect-error The shared ES module has no TypeScript declarations.
import { assessCapacity, normalizeHardware } from "../../docs/js/cookbook-memory.mjs";
import weightsData from "../../docs/assets/cookbook-weights.json" with { type: "json" };
export type MemoryStatus = "fits" | "tight" | "exceeds" | "unsupported" | "unknown";
/** The subset of an `assessCapacity` result the UI shows. */
export interface MemoryAssessment {
status: MemoryStatus;
reason?: string;
requiredGiB?: number;
capacityGiB?: number;
basis?: string;
parts?: Record<string, number>;
checkpointGiB?: number;
notes?: string[];
}
export interface WeightsModel {
parts: Record<string, number>;
alternates?: Record<string, number>;
sources?: string[];
}
const WEIGHTS = weightsData as { models: Record<string, WeightsModel> };
/** Measured checkpoint sizes for recipes that have no full capacity model yet. */
export function weightsFor(modelId: string | undefined): WeightsModel | undefined {
return modelId ? WEIGHTS.models[modelId] : undefined;
}
export interface AssessmentHardware {
id?: string;
backend?: string;
name?: string;
memory_gb?: number;
memory_unit?: string;
memory_type?: string;
usable_budget_gb?: number;
host_ram_gb?: number;
}
const GIB = 1024 ** 3;
/** Decimal GB for a host-memory amount entered in the device's memory unit. */
export function hostMemoryGb(hardware: AssessmentHardware, amount: number | undefined): number | undefined {
if (!amount) return undefined;
return hardware.memory_unit === "GiB" ? (amount * GIB) / 1e9 : amount;
}
export function capacityHardware(hardware: AssessmentHardware) {
return {
...hardware,
memory: {
type: hardware.memory_type,
nominal: { value: hardware.memory_gb, unit: hardware.memory_unit ?? "GB" },
...(hardware.usable_budget_gb
? { usable: { value: hardware.usable_budget_gb, unit: hardware.memory_unit ?? "GB" } }
: {}),
},
host_memory_gb: hostMemoryGb(hardware, hardware.host_ram_gb),
};
}
/** Checkpoint payload is not peak residency: offload, sharding and packing change placement. */
export function assessWeightsRecipe({
weights,
config,
hardware,
budgetGb,
}: {
weights: WeightsModel;
config: Record<string, unknown>;
hardware: AssessmentHardware;
budgetGb?: number;
}): MemoryAssessment {
const generator = (config.generator ?? {}) as {
engine?: { quantization?: { transformer_quant?: string | null }; offload?: Record<string, boolean> };
pipeline?: { components?: Record<string, string | null>; experimental?: Record<string, unknown> };
};
const device = normalizeHardware(capacityHardware(hardware), {
memoryBudgetGiB: budgetGb ? (hardware.memory_unit === "GiB" ? budgetGb : (budgetGb * 1e9) / GIB) : undefined,
});
if (!device)
return {
status: "unknown",
reason:
"Select dedicated GPU memory or unified memory with a valid budget. Host-only system RAM is not GPU capacity.",
};
const parts = { ...weights.parts };
const missing: string[] = [];
const experimental = generator.pipeline?.experimental ?? {};
if (generator.pipeline?.components?.text_encoder_weights) {
delete parts.text_encoder;
missing.push("replacement text encoder");
}
if (generator.engine?.quantization?.transformer_quant) {
delete parts.transformer;
missing.push("transformer at the selected quantization and layer profile");
}
if (experimental.video_decode_backend === "taeh3" || experimental.h3_vae_nvfp4_decoder) {
delete parts.vae;
missing.push(
experimental.video_decode_backend === "taeh3"
? "TAEH3 decoder"
: "VAE with the NVFP4 decoder and retained encoder",
);
}
const checkpointGiB = Object.values(parts).reduce((sum, value) => sum + value, 0);
return {
status: "unknown",
basis: "checkpoint",
checkpointGiB,
capacityGiB: device.capacityGiB,
parts,
reason: missing.length
? `Partial checkpoint data. Missing sizes for ${missing.join("; ")}. Runtime peak memory is not modeled.`
: "Checkpoint sizes are known, but runtime peak memory is not modeled. CPU offload and GPU count change residency, not checkpoint file sizes.",
notes: [
"Activations, temporary buffers, loading peaks and per-rank placement are not included. Frame count and resolution affect these unmodeled allocations.",
...(hardware.memory_type === "unified"
? ["Unified memory is shared by the CPU and GPU. Offloading to the CPU does not create a second memory pool."]
: []),
...(device.budgetAssumed
? ["The budget uses 75% of nominal unified memory because no usable memory budget was supplied."]
: []),
],
};
}
/**
* Run the analytical estimate for the edited configuration. Returns `undefined`
* when no capacity model covers the checkpoint, so the caller can say so.
*/
export function assessRecipe({
model,
example,
hardware,
config,
budgetGb,
hostGb,
}: {
model: Record<string, unknown>;
example: { workload?: string; env?: Record<string, string> };
hardware?: AssessmentHardware;
config?: Record<string, unknown>;
budgetGb?: number;
hostGb?: number;
}): MemoryAssessment | undefined {
if (!config || !hardware?.memory_gb) return undefined;
try {
const result = assessCapacity({
model,
example,
hardware: capacityHardware(hardware),
config,
memoryBudgetGiB: budgetGb ? (hardware.memory_unit === "GiB" ? budgetGb : (budgetGb * 1e9) / GIB) : undefined,
hostMemoryGb: hostMemoryGb(hardware, hostGb),
}) as MemoryAssessment;
return {
status: (result.status ?? "unknown") as MemoryStatus,
reason: result.reason,
requiredGiB: result.requiredGiB ?? (result as MemoryAssessment & { peakGiB?: number }).peakGiB,
capacityGiB: result.capacityGiB,
basis: result.basis,
notes: result.notes,
};
} catch (error) {
return { status: "unknown", reason: error instanceof Error ? error.message : "Memory assessment failed." };
}
}
-45
View File
@@ -1,45 +0,0 @@
const NEWS_HEADINGS = ["news", "recent updates", "what's new", "latest news", "announcements"];
const PROJECT_HEADINGS = [
"awesome work using fastvideo or our research projects",
"projects using fastvideo",
"built on fastvideo",
"awesome work",
];
function normalizeHeading(line) {
return line
.replace(/^#{2,3}\s+/, "")
.replace(/[`*_]/g, "")
.replace(/[^\p{L}\p{N}\s'’-]/gu, "")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
function sectionBody(readme, names) {
const lines = readme.split("\n");
const start = lines.findIndex((line) => /^##\s+/.test(line) && names.includes(normalizeHeading(line)));
if (start < 0) return null;
const rest = lines.slice(start + 1);
const end = rest.findIndex((line) => /^##\s+/.test(line));
return (end < 0 ? rest : rest.slice(0, end)).join("\n").trim();
}
const BULLET = /^-\s+(?:`(\d{4})[/-](\d{2})[/-](\d{2})`:|\*\*(\d{4})[/-](\d{2})[/-](\d{2}):?\*\*:?)\s*(.+)$/;
export function readReadmeSections(readme) {
const news = sectionBody(readme, NEWS_HEADINGS);
const projects = sectionBody(readme, PROJECT_HEADINGS);
if (news === null || projects === null) return { news: null, projects: null };
const items = [];
for (const line of news.split("\n")) {
const match = BULLET.exec(line.trim());
if (!match) continue;
const [, y1, m1, d1, y2, m2, d2, text] = match;
items.push({ date: `${y1 ?? y2}/${m1 ?? m2}/${d1 ?? d2}`, text: text.trim() });
}
return { news: items, projects };
}
export const README_HEADINGS = { news: NEWS_HEADINGS, projects: PROJECT_HEADINGS };
File diff suppressed because one or more lines are too long
-539
View File
@@ -1,539 +0,0 @@
[
{
"label": "Welcome",
"href": "/"
},
{
"label": "Fast Models",
"href": "/fast-models",
"icon": "boxes"
},
{
"label": "Get started",
"icon": "rocket",
"items": [
{
"label": "Quick start",
"href": "/getting_started/quick_start"
},
{
"label": "Installation",
"href": "/getting_started/installation"
},
{
"label": "Hardware and runtimes",
"href": "/getting_started/hardware"
},
{
"label": "Python API basics",
"href": "/getting_started/v1_api"
}
]
},
{
"label": "Run inference",
"items": [
{
"label": "Inference guide",
"href": "/inference/inference_quick_start"
},
{
"label": "Configuration",
"href": "/inference/configuration"
},
{
"label": "CLI",
"href": "/inference/cli"
},
{
"label": "Supported models",
"href": "/inference/support_matrix"
},
{
"label": "Memory and offloading",
"href": "/inference/offloading"
},
{
"label": "Performance and attention",
"href": "/inference/optimizations"
},
{
"label": "LoRA adapters",
"href": "/utilities/lora"
},
{
"label": "Community LoRAs for MiniMax-H3",
"href": "/inference/h3_community_lora"
},
{
"label": "Hardware",
"collapsed": true,
"items": [
{
"label": "Choose your runtime",
"href": "/getting_started/hardware"
},
{
"label": "NVIDIA GPU setup",
"href": "/getting_started/installation/gpu"
},
{
"label": "Apple Silicon / MLX",
"href": "/getting_started/installation/mlx"
},
{
"label": "NVIDIA DGX Spark",
"href": "/getting_started/installation/spark"
},
{
"label": "Two DGX Sparks",
"href": "/getting_started/installation/spark_pair"
},
{
"label": "RTX PRO 6000 / FastH3",
"href": "/inference/fasth3_rtx_pro_6000"
},
{
"label": "FastH3 OmniRef on consumer GPUs",
"href": "/inference/fasth3_omniref"
},
{
"label": "FastH3 OmniRef on Apple Silicon",
"href": "/inference/fasth3_omniref_mlx"
},
{
"label": "DGX Spark tuning",
"href": "/getting_started/installation/spark_performance"
},
{
"label": "AMD / ROCm",
"href": "/getting_started/installation/amd"
}
],
"display": "group"
},
{
"label": "Integrations",
"collapsed": true,
"items": [
{
"label": "ComfyUI",
"href": "/inference/comfyui"
},
{
"label": "Server and API clients",
"href": "/cookbook/openai-api"
}
],
"display": "group"
},
{
"label": "Examples",
"href": "/inference/examples/examples_inference_index"
}
]
},
{
"label": "Cookbook",
"collapsed": true,
"items": [
{
"label": "All recipes",
"href": "/cookbook"
},
{
"label": "MiniMax H3",
"display": "group",
"collapsed": true,
"items": [
{
"label": "MiniMax H3",
"href": "/cookbook/minimax-h3/models/minimax-h3-t2v"
},
{
"label": "FastH3 Omni v1",
"href": "/cookbook/minimax-h3/fasth3-omniref"
},
{
"label": "FastH3 8-Step V2",
"href": "/cookbook/minimax-h3/fasth3-8step"
},
{
"label": "FastH3 Trim",
"href": "/cookbook/minimax-h3/fasth3-trim"
},
{
"label": "FastH3 4-Step V1",
"href": "/cookbook/minimax-h3/fasth3-4step"
},
{
"label": "Pika H3 Flex",
"href": "/cookbook/minimax-h3/models/pika-h3-flex-t2va"
},
{
"label": "Open-H3-Regenerate-2K",
"href": "/cookbook/minimax-h3/open-h3-regenerate-2k"
},
{
"label": "Recipes and setup",
"href": "/cookbook/minimax-h3"
},
{
"label": "Checkpoint schedules",
"href": "/inference/fasth3-distilled"
}
]
},
{
"label": "Wan",
"display": "group",
"collapsed": true,
"items": [
{
"label": "FastWan2.1 1.3B",
"href": "/cookbook/wan/fastwan21"
},
{
"label": "Wan2.1 I2V 14B",
"href": "/cookbook/wan/wan21-i2v"
},
{
"label": "Family guide",
"href": "/cookbook/wan"
}
]
},
{
"label": "LTX",
"display": "group",
"collapsed": true,
"items": [
{
"label": "LTX2 Distilled",
"href": "/cookbook/ltx/ltx2-distilled"
},
{
"label": "Recipes and setup",
"href": "/cookbook/ltx"
},
{
"label": "LTX-2.5 reference",
"href": "/inference/ltx2_5"
}
]
},
{
"label": "Hunyuan",
"href": "/cookbook/hunyuan"
},
{
"label": "Kandinsky 5",
"href": "/cookbook/kandinsky5"
},
{
"label": "Kandinsky 6",
"display": "group",
"collapsed": true,
"items": [
{
"label": "Recipes and setup",
"href": "/cookbook/kandinsky6"
},
{
"label": "Generation reference",
"href": "/inference/kandinsky6"
},
{
"label": "Upscaling reference",
"href": "/inference/kandinsky6_sr"
}
]
},
{
"label": "FLUX",
"href": "/cookbook/flux"
},
{
"label": "Qwen-Image-2.1",
"href": "/cookbook/qwen-image21"
},
{
"label": "Stable Diffusion",
"href": "/cookbook/stable-diffusion"
},
{
"label": "Stable Audio",
"href": "/cookbook/stable-audio"
},
{
"label": "MMAudio",
"href": "/cookbook/mmaudio"
},
{
"label": "Cosmos",
"href": "/cookbook/cosmos"
},
{
"label": "Matrix Game",
"href": "/cookbook/matrix-game"
}
],
"display": "group",
"root": "/cookbook"
},
{
"label": "Train and fine-tune",
"display": "group",
"collapsed": true,
"items": [
{
"label": "Overview",
"href": "/training/overview"
},
{
"label": "Training Infrastructure",
"href": "/training/train_infra"
},
{
"label": "Experiment Tracking",
"href": "/training/trackers"
},
{
"label": "Data Preprocessing",
"href": "/training/data_preprocess"
},
{
"label": "Fine-tuning",
"href": "/training/finetune"
},
{
"label": "Attn-QAT Training",
"href": "/training/attn_qat"
},
{
"label": "FastH3 NVFP4 QAD",
"href": "/training/minimax_h3_fp4_qad"
},
{
"label": "Pika H3 Flex NVFP4 QAD",
"href": "/training/pika_h3_flex_qad"
},
{
"label": "HunyuanVideo 1.5 T2V",
"href": "/training/hunyuan15_overfit"
},
{
"label": "Examples",
"items": [
{
"label": "Training Index",
"href": "/training/examples/examples_training_index"
},
{
"label": "Wan T2V 1.3B",
"href": "/training/examples/wan_t2v_1.3B"
},
{
"label": "Wan I2V 14B 480p",
"href": "/training/examples/wan_i2v_14B_480p"
},
{
"label": "Wan2.1 Fun 1.3B InP",
"href": "/training/examples/Wan2.1-Fun-1.3B-InP"
},
{
"label": "Wan2.1 VSA",
"href": "/training/examples/Wan2.1-VSA"
},
{
"label": "Fine-tuning",
"href": "/training/examples/finetune"
}
],
"collapsed": true,
"display": "group"
}
]
},
{
"label": "Distill models",
"display": "group",
"collapsed": true,
"items": [
{
"label": "Data Preprocessing",
"href": "/distillation/data_preprocess"
},
{
"label": "DMD",
"href": "/distillation/dmd"
},
{
"label": "AnyFlow",
"href": "/distillation/anyflow"
}
]
},
{
"label": "Attention research",
"display": "group",
"collapsed": true,
"items": [
{
"label": "Overview",
"href": "/attention"
},
{
"label": "Video Sparse Attention",
"href": "/attention/vsa"
},
{
"label": "Sliding Tile Attention (Archived)",
"href": "/attention/sta"
},
{
"label": "Backend Development",
"href": "/contributing/attention_backend"
}
]
},
{
"label": "Contribute",
"display": "group",
"collapsed": true,
"items": [
{
"label": "Overview",
"href": "/contributing/overview"
},
{
"label": "Developer Environment",
"items": [
{
"label": "Index",
"href": "/contributing/developer_env"
},
{
"label": "Docker",
"href": "/contributing/developer_env/docker"
},
{
"label": "RunPod",
"href": "/contributing/developer_env/runpod"
}
],
"collapsed": true,
"display": "group"
},
{
"label": "Pull Requests",
"href": "/contributing/pull_requests"
},
{
"label": "CI/CD Architecture",
"href": "/contributing/ci_architecture"
},
{
"label": "Testing",
"href": "/contributing/testing"
},
{
"label": "Environment Variables",
"href": "/contributing/env_vars"
},
{
"label": "Performance Benchmarks",
"href": "/contributing/performance_benchmarks"
},
{
"label": "Profiling",
"href": "/contributing/profiling"
},
{
"label": "Activation Trace",
"href": "/contributing/activation_trace"
},
{
"label": "Coding Agents",
"href": "/contributing/coding_agents"
},
{
"label": "Dreamverse Development",
"href": "/contributing/dreamverse-development"
},
{
"label": "Attention Backend Development",
"href": "/contributing/attention_backend"
},
{
"label": "Add a pipeline",
"href": "/inference/add_pipeline"
},
{
"label": "Debugging",
"href": "/utilities/debugging"
},
{
"label": "Documenting FastVideo",
"href": "/contributing/documentation"
}
]
},
{
"label": "Architecture",
"display": "group",
"collapsed": true,
"items": [
{
"label": "Overview",
"href": "/design/overview"
},
{
"label": "Training Architecture",
"href": "/design/training_architecture"
},
{
"label": "Server Contracts",
"items": [
{
"label": "Overview",
"href": "/design/server_contracts"
},
{
"label": "OpenAI HTTP",
"href": "/design/server_contracts/openai"
},
{
"label": "Streaming WebSocket",
"href": "/design/server_contracts/streaming"
},
{
"label": "Dynamo Integration",
"href": "/design/server_contracts/dynamo"
}
],
"collapsed": true,
"display": "group"
}
]
},
{
"label": "API reference",
"collapsed": true,
"items": [
{
"label": "Overview",
"href": "/api"
},
{
"label": "Python package",
"href": "/api/fastvideo"
}
],
"display": "group"
},
{
"label": "Contact",
"href": "/contact"
}
]
-13665
View File
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +0,0 @@
{
"name": "fastvideo-docs-site",
"private": true,
"type": "module",
"scripts": {
"prepare:content": "node scripts/prepare-content.mjs",
"predev": "npm run prepare:content",
"dev": "blume dev",
"prebuild": "npm run prepare:content",
"build": "blume build",
"preview": "blume preview",
"prevalidate": "npm run prepare:content",
"validate": "blume validate --strict",
"check": "tsc --noEmit",
"test": "playwright test",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test:content": "node --test tests/content.test.mjs tests/commands.test.mjs tests/readme-sections.test.mjs tests/memory-assessment.test.mjs"
},
"dependencies": {
"blume": "2.2.3",
"class-variance-authority": "0.7.1",
"cn": "^0.4.0",
"lucide-react": "1.59.0",
"motion": "14.4.0",
"radix-ui": "^1.8.1",
"react": "19.3.1",
"react-dom": "19.3.1",
"yaml": "2.9.1"
},
"devDependencies": {
"@playwright/test": "1.64.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"prettier": "3.9.9",
"typescript": "~5.9.3"
},
"engines": {
"node": ">=22.19.0"
},
"overrides": {
"@astrojs/react": "7.0.0",
"@vitejs/plugin-react": "6.1.1"
},
"prettier": {
"printWidth": 120
}
}
-17
View File
@@ -1,17 +0,0 @@
import { defineConfig } from "@playwright/test";
const externalBase = process.env.PLAYWRIGHT_BASE_URL;
const localBase = "http://127.0.0.1:4327";
export default defineConfig({
testDir: "./tests",
testMatch: "**/*.spec.ts",
use: { baseURL: externalBase ?? localBase, trace: "retain-on-failure" },
webServer: externalBase
? undefined
: {
command: "npm run preview -- --host 127.0.0.1 --port 4327",
url: `${localBase}/FastVideo/cookbook`,
reuseExistingServer: !process.env.CI,
},
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

-38
View File
@@ -1,38 +0,0 @@
import { sourceRecipes } from "../lib/cookbook-pages.ts";
/** Prepare the docs transfer and publish the existing cookbook catalogs. */
import { execFileSync } from "node:child_process";
import { cp, mkdir, rm } from "node:fs/promises";
import { transferDocs } from "./transfer-docs.mjs";
await transferDocs();
const repo = new URL("../../", import.meta.url);
execFileSync("npm", ["run", "build:catalog", "--prefix", "docs"], {
cwd: repo,
stdio: "inherit",
});
const publicDir = new URL("../public/", import.meta.url);
await mkdir(publicDir, { recursive: true });
const destination = new URL("assets/cookbook-config/", publicDir);
await rm(destination, { recursive: true, force: true });
await cp(new URL("docs/assets/cookbook-config/", repo), destination, {
recursive: true,
});
await cp(new URL("docs/assets/logos/icon_simple.svg", repo), new URL("icon.svg", publicDir));
await cp(new URL("../assets/hao-ai-lab.png", import.meta.url), new URL("assets/hao-ai-lab.png", publicDir));
await cp(new URL("../.generated/source-examples/", import.meta.url), new URL("assets/cookbook-examples/", publicDir), {
recursive: true,
});
// Publish exact checked-in YAML for inference-only model pages. No settings are synthesized.
const sourceDir = new URL("assets/cookbook-sources/", publicDir);
await rm(sourceDir, { recursive: true, force: true });
await mkdir(sourceDir, { recursive: true });
for (const recipe of sourceRecipes) {
if (/\.ya?ml$/.test(recipe.source))
await cp(new URL(recipe.source, repo), new URL(`${recipe.id}-inference.yaml`, sourceDir));
if (recipe.serving?.source)
await cp(new URL(recipe.serving.source, repo), new URL(`${recipe.id}-serving.yaml`, sourceDir));
}
-291
View File
@@ -1,291 +0,0 @@
"""Build source-only Python reference pages with Griffe, without importing FastVideo."""
from __future__ import annotations
import html
import ast
import json
import re
import runpy
import shutil
import shlex
from pathlib import Path
from urllib.parse import urljoin, urlsplit, urlunsplit
import griffe
import markdown
import yaml
ROOT = Path(__file__).resolve().parents[2]
SITE = ROOT / "docs-site"
GENERATED = SITE / ".generated"
def source_controls(source: str) -> list[dict]:
"""Expose literal settings in maintained examples without importing them."""
labels = {
"prompt": "Prompt", "num_gpus": "GPU count", "height": "Height", "width": "Width",
"num_frames": "Frames", "seed": "Seed", "num_inference_steps": "Inference steps",
"guidance_scale": "Guidance scale", "fps": "FPS", "audio_end_in_s": "Audio duration",
"text_encoder_cpu_offload": "Offload text encoder", "vae_cpu_offload": "Offload VAE",
"dit_cpu_offload": "Offload transformer", "inference_torch_compile": "Compile transformer",
"video_decode_backend": "Video decoder",
}
lines = source.splitlines(keepends=True)
offsets = [0]
for line in lines:
offsets.append(offsets[-1] + len(line))
def position(line, column):
prefix = source[:offsets[line - 1]] + lines[line - 1].encode()[:column].decode()
return len(prefix.encode("utf-16-le")) // 2
settings = {}
def add(name, value):
if name not in labels or not isinstance(value, ast.Constant) or type(value.value) not in (str, int, float, bool):
return
key = json.dumps([name, value.value])
entry = settings.setdefault(key, {"name": name, "label": labels[name], "value": value.value,
"integer": type(value.value) is int, "spans": []})
entry["spans"].append([position(value.lineno, value.col_offset), position(value.end_lineno, value.end_col_offset)])
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
if node.func.attr in {"from_pretrained", "generate_video"}:
for keyword in node.keywords:
add(keyword.arg, keyword.value)
elif node.func.attr == "add_argument":
flags = [arg.value for arg in node.args if isinstance(arg, ast.Constant) and isinstance(arg.value, str)]
flag = next((flag for flag in flags if flag.startswith("--")), "")
for keyword in node.keywords:
if keyword.arg == "default":
add(flag.removeprefix("--").replace("-", "_"), keyword.value)
key = json.dumps([flag.removeprefix("--").replace("-", "_"), getattr(keyword.value, "value", None)])
if key in settings:
settings[key]["flag"] = flag
if any(item.arg == "type" and isinstance(item.value, ast.Name) and item.value.id == "float"
for item in node.keywords):
settings[key]["integer"] = False
settings[key]["booleanOptional"] = any(
item.arg == "action" and isinstance(item.value, ast.Attribute) and item.value.attr == "BooleanOptionalAction"
for item in node.keywords)
elif isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id.isupper():
add(target.id.lower(), node.value)
result = list(settings.values())
seen = {}
for field in result:
seen[field["label"]] = seen.get(field["label"], 0) + 1
if seen[field["label"]] > 1:
field["label"] += f' ({seen[field["label"]]})'
return result
def signature(obj) -> str:
"""Preserve parameter kinds, annotations, defaults, and return annotations."""
if obj.is_attribute:
annotation = f": {obj.annotation}" if obj.annotation else ""
value = f" = {obj.value}" if obj.value is not None else ""
return f"{obj.name}{annotation}{value}"
parameters = list(getattr(obj, "parameters", []))
parts = []
keyword_separator = False
for i, param in enumerate(parameters):
if param.name in {"self", "cls"}:
continue
kind = param.kind.value
if kind == "keyword-only" and not keyword_separator:
parts.append("*")
keyword_separator = True
prefix = "**" if kind == "variadic keyword" else "*" if kind == "variadic positional" else ""
if prefix == "*":
keyword_separator = True
text = prefix + param.name
if param.annotation is not None:
text += f": {param.annotation}"
if param.default is not None and not prefix:
text += f" = {param.default}"
parts.append(text)
if kind == "positional-only" and (i + 1 == len(parameters) or parameters[i + 1].kind.value != kind):
parts.append("/")
result = f"{obj.name}({', '.join(parts)})"
if obj.is_function and obj.returns is not None:
result += f" -> {obj.returns}"
return result
def code_html(text: str) -> str:
return html.escape(text).replace("[", "&#91;").replace("]", "&#93;")
def prose(text: str) -> str:
# Docstrings are trusted repository source, rendered as Markdown rather than executed.
return markdown.markdown(text, extensions=["fenced_code", "tables", "sane_lists"])
def api_overview(text: str, package) -> str:
def reference(match):
label, target = match.groups()
path = (target or label).strip("`")
obj = package if path == package.path else package[path.removeprefix(package.path + ".")]
module = obj
while not module.is_module:
module = module.parent
route = "/api/" + module.path.replace(".", "/")
return f"[{label}]({route}#{path})"
text = re.sub(r"\[([^\]\n]+)\]\[([^\]\n]*)\]", reference, text)
def guide(match):
label, target = match.groups()
url = urlsplit(target)
if url.scheme or url.netloc or not re.search(r"\.mdx?$", url.path):
return match[0]
url = urlsplit(urljoin("/api/index.md", target))
route = re.sub(r"\.mdx?$", "", url.path)
route = re.sub(r"/index$", "", route) or "/"
return f"[{label}]({urlunsplit(('', '', route, url.query, url.fragment))})"
return prose(re.sub(r"\[([^\]\n]+)\]\(([^\s)]+)\)", guide, text))
def docstring(obj, level: int) -> str:
if not obj.docstring:
return ""
output = []
for section in obj.docstring.parsed:
kind = section.kind.value
if kind == "text":
output.append(prose(section.value))
continue
title = section.title or kind.replace("_", " ").capitalize()
output.append(f"<h{level}>{html.escape(title)}</h{level}>")
if kind == "examples":
for example_kind, value in section.value:
output.append(f"<pre><code>{html.escape(value)}</code></pre>" if example_kind.value == "examples" else prose(value))
elif isinstance(section.value, list):
output.append("<dl>")
for item in section.value:
name = getattr(item, "name", "") or ""
annotation = getattr(item, "annotation", "") or ""
label = f"{name}: {annotation}" if name and annotation else str(name or annotation)
output.append(f"<dt><code>{html.escape(label)}</code></dt><dd>{prose(getattr(item, 'description', ''))}</dd>")
output.append("</dl>")
else:
output.append(prose(getattr(section.value, "description", str(section.value))))
return "\n".join(output)
def symbol(obj, path: str, level: int = 2, members: list[str] | None = None, source: bool = True) -> str:
level = min(level, 6)
output = [f'<h{level} id="{html.escape(path)}"><code>{html.escape(path)}</code></h{level}>']
output.append(f'<pre class="doc-signature"><code>{code_html(signature(obj))}</code></pre>')
output.append(docstring(obj, min(level + 1, 6)))
if obj.is_class:
constructor = obj.members.get("__init__")
if constructor is not None:
output.append(docstring(constructor, min(level + 1, 6)))
if source and not obj.is_alias:
try:
code = obj.source
except (ValueError, KeyError):
code = ""
if code:
output.append(f'<details><summary>Source</summary><pre><code>{code_html(code)}</code></pre></details>')
if obj.is_class:
for name, member in obj.members.items():
if members is not None and name not in members:
continue
if name.startswith("_") or member.is_module:
continue
if member.is_attribute and not member.docstring:
continue
try:
output.append(symbol(member, f"{path}.{name}", min(level + 1, 6), source=source))
except griffe.AliasResolutionError:
continue
return "\n".join(output)
def write_page(route: str, title: str, body: str, anchors: dict) -> None:
# Preserve incoming fragments, including historical category labels whose layout changed.
present = set(re.findall(r'id="([^"]+)"', body))
aliases = "\n".join(f'<a id="{html.escape(h["id"])}"></a>' for h in anchors.get(route, []) if h["id"] not in present)
target = GENERATED / "python-reference" / route / "index.md"
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("---\n" + yaml.safe_dump({"title": title}, sort_keys=False) +
f'---\n\n<div class="python-reference">\n{aliases}\n{body}\n</div>\n')
def prepare() -> None:
GENERATED.mkdir(exist_ok=True)
examples = runpy.run_path(str(ROOT / "docs/generate_examples.py"))
examples["validate_cookbook"]()
examples["generate_examples"](generate_main_index=True)
source_dir = GENERATED / "source-examples"
shutil.rmtree(source_dir, ignore_errors=True)
source_dir.mkdir()
for recipe in json.loads((ROOT / "docs/assets/cookbook-recipes.json").read_text())["recipes"]:
path = ROOT / recipe["source"]
if path.suffix == ".py":
source = path.read_text()
controls = source_controls(source)
argv = shlex.split(recipe["command"])
for field in controls:
flag = field.get("flag")
positions = [i for i, argument in enumerate(argv[:-1]) if argument == flag]
if positions and type(field["value"]) is not bool:
converter = float if isinstance(field["value"], (int, float)) and not field["integer"] else type(field["value"])
field["value"] = converter(argv[positions[-1] + 1])
elif field.get("booleanOptional"):
if flag in argv:
field["value"] = True
elif flag.replace("--", "--no-", 1) in argv:
field["value"] = False
(source_dir / f'{recipe["id"]}.json').write_text(json.dumps({"source": source, "controls": controls}))
anchors = json.loads((SITE / "migration-anchors.json").read_text())
shutil.rmtree(GENERATED / "python-reference", ignore_errors=True)
package = griffe.load("fastvideo", search_paths=[ROOT], allow_inspection=False, resolve_aliases=True,
resolve_external=False, docstring_parser="google", docstring_options={"warnings": False})
modules = []
def visit(module):
if module.is_alias or any(part.startswith("_") or part in {"tests", "third_party"} for part in module.path.split(".")):
return
modules.append(module)
for child in module.modules.values():
visit(child)
visit(package)
for module in sorted(modules, key=lambda item: item.path):
body = [f'<a id="{module.path}"></a>', docstring(module, 2)]
for name, member in module.members.items():
if name.startswith("_"):
continue
try:
# External imports belong to their upstream reference, not FastVideo's API.
if member.is_alias and not member.target_path.startswith("fastvideo."):
continue
if member.is_module or member.is_attribute and not member.docstring:
continue
body.append(symbol(member, f"{module.path}.{name}"))
except griffe.AliasResolutionError:
continue
write_page("api/" + module.path.replace(".", "/"), module.name, "\n".join(body), anchors)
write_page("api", "Python API reference", api_overview((ROOT / "docs/api/index.md").read_text(), package), anchors)
v1 = (ROOT / "docs/getting_started/v1_api.md").read_text()
def reference(match):
path, level, members = match.groups()
return "\n" + symbol(package[path.removeprefix("fastvideo.")], path,
int(level), members.split(","), source=False) + "\n"
v1 = re.sub(r'<api-reference path="([^"]+)" heading="(\d+)" members="([^"]+)" />', reference, v1)
write_page("getting_started/v1_api", "V1 API", prose(v1.removeprefix("# V1 API\n")), anchors)
(GENERATED / "old-headings.json").write_text(json.dumps(anchors))
print(f"Rendered {len(modules) + 1} Python reference pages directly from source, without MkDocs or runtime imports.")
if __name__ == "__main__":
prepare()
-378
View File
@@ -1,378 +0,0 @@
import { modelDirectory } from "../lib/cookbook-pages.ts";
/** Rebuild the Blume view from canonical Markdown and the existing Python reference renderer. */
import { execFileSync } from "node:child_process";
import { existsSync, statSync } from "node:fs";
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { parse, stringify } from "yaml";
import { readReadmeSections, README_HEADINGS } from "../lib/readme.mjs";
const site = fileURLToPath(new URL("../", import.meta.url));
const repo = resolve(site, "..");
const source = join(repo, "docs");
const generated = join(site, ".generated");
const content = join(generated, "content");
const publicDir = join(site, "public");
const attachments = new Set();
const routeFor = (file) =>
"/" +
file
.replace(/\\/g, "/")
.replace(/\.(md|mdx)$/, "")
.replace(/(^|\/)index$/, "")
.replace(/\/$/, "");
async function files(root) {
const result = [];
for (const entry of await readdir(root, { withFileTypes: true })) {
const path = join(root, entry.name);
if (entry.isDirectory()) result.push(...(await files(path)));
else result.push(path);
}
return result;
}
function splitPage(text) {
const match = /^---\n([\s\S]*?)\n---\n/.exec(text);
return match ? { data: parse(match[1]), body: text.slice(match[0].length) } : { data: {}, body: text };
}
function localTarget(target, from, pretty = false) {
if (/^(?:#|mailto:|data:|tel:)/.test(target)) return target;
let url;
try {
url = new URL(target, `https://transfer.invalid${pretty ? `${routeFor(from)}/` : `/${from}`}`);
} catch {
return target;
}
if (url.host !== "transfer.invalid") {
if (url.origin !== "https://haoailab.com" || !url.pathname.startsWith("/FastVideo/")) return target;
url = new URL(url.pathname.replace(/^\/FastVideo/, "") + url.search + url.hash, "https://transfer.invalid");
}
const path = decodeURIComponent(url.pathname).replace(/^\//, "");
// Links leaving docs/ name repository files, not files served by the docs host.
if (!pretty && !target.startsWith("/") && !/^[a-z]+:/i.test(target)) {
const actual = resolve(source, dirname(from), decodeURIComponent(target.split(/[?#]/)[0]));
if (!actual.startsWith(source + sep) && existsSync(actual)) {
return `https://github.com/hao-ai-lab/FastVideo/blob/main/${relative(repo, actual).replaceAll(sep, "/")}${url.hash}`;
}
}
if (existsSync(join(source, path)) && statSync(join(source, path)).isFile() && !/\.mdx?$/.test(path))
attachments.add(path);
if (/\.mdx?$/.test(path)) return `${routeFor(path)}${url.search}${url.hash}`;
return `/${path}${url.search}${url.hash}`;
}
function rewriteLinks(body, from) {
let fence;
return body
.split("\n")
.map((line) => {
const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
if (marker) {
if (!fence) fence = marker;
else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
return line;
}
if (fence) return line;
return line
.replace(/<(?=[0-9=])/g, "&lt;")
.replace(/(\]\()([^\s)]+)(\))/g, (_, before, url, after) => before + localTarget(url, from) + after)
.replace(
/((?:href|src)=["'])([^"']+)(["'])/g,
(_, before, url, after) => before + localTarget(url, from, from.startsWith("cookbook/")) + after,
);
})
.join("\n");
}
function pinHeadings(body, old, mdx) {
const normalize = (text) =>
text
.replace(/<[^>]*>/g, "")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/[^\p{L}\p{N}]/gu, "")
.toLowerCase();
const candidates = [...(old ?? [])];
let fence;
let text = body
.split("\n")
.map((line) => {
const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
if (marker) {
if (!fence) fence = marker;
else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
return line;
}
if (fence) return line;
const heading = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
if (!heading) return line;
const clean = heading[2].replace(/\s*(?:\[#.*?\]|\{#.*?\})\s*$/, "");
const index = candidates.findIndex(
(item) => item.level === heading[1].length && normalize(item.text.replace(/¶$/, "")) === normalize(clean),
);
if (index < 0) return line;
const [original] = candidates.splice(index, 1);
if (original.id.includes("__")) return `${heading[1]} <a id="${original.id}"></a>${clean}`;
return `${heading[1]} ${clean} ${mdx ? `[#${original.id}]` : `{#${original.id}}`}`;
})
.join("\n");
const first = candidates.find((item) => item.level === 1);
if (first && !text.includes(`id="${first.id}"`)) text = `<a id="${first.id}"></a>\n\n${text}`;
return text;
}
/** Blume owns the page title, including examples whose Source link precedes their H1. */
function extractPageTitle(body, data, deduplicate = false) {
const normalize = (text) => text.replace(/[^\p{L}\p{N}]/gu, "").toLowerCase();
let fence;
let found = false;
return body
.split("\n")
.map((line) => {
const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
if (marker) {
if (!fence) fence = marker;
else if (marker[0] === fence[0] && marker.length >= fence.length) fence = undefined;
return line;
}
if (fence || (found && !deduplicate)) return line;
const heading = (deduplicate ? /^(#{1,2})\s+(.+)$/ : /^(#)\s+(.+)$/).exec(line);
if (!heading) return line;
if (heading[1].length === 2 && !data.title) return line;
const anchor = /(?:\[#([^\]]+)\]|\{#([^}]+)\})\s*$/.exec(heading[2]);
const embedded = /<a\s+id="[^"]*"><\/a>/g;
const anchors = heading[2].match(embedded) ?? [];
const title = heading[2]
.replace(/\s*(?:\[#[^\]]+\]|\{#[^}]+\})\s*$/, "")
.replace(embedded, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[`*_]/g, "")
.trim();
if (data.title && normalize(data.title) !== normalize(title)) return line;
if (heading[1].length === 1) {
data.title ??= title;
found = true;
}
if (anchor) anchors.push(`<a id="${anchor[1] ?? anchor[2]}"></a>`);
return anchors.join("\n");
})
.join("\n");
}
export async function transferDocs() {
const localPython = join(site, ".venv/bin/python");
const python = process.env.DOCS_PYTHON || (existsSync(localPython) ? localPython : "python3");
execFileSync(python, [join(site, "scripts/prepare-python-docs.py")], { cwd: repo, stdio: "inherit" });
await rm(content, { recursive: true, force: true });
await rm(publicDir, { recursive: true, force: true });
await mkdir(content, { recursive: true });
const ignored = new Set(["js", "tests", "node_modules", "__pycache__"]);
await cp(source, content, {
recursive: true,
filter: async (path) => {
const rel = relative(source, path);
if (ignored.has(rel.split(sep)[0]) || rel === "README.md") return false;
return (await stat(path)).isDirectory() || /\.mdx?$/.test(path);
},
});
for (const file of await files(content)) {
if (!file.endsWith(".md")) continue;
const text = await readFile(file, "utf8");
if (/^:::(?:note|tip|warning|danger|info|success)\b/m.test(text) || text.includes("<RecipeFamily ")) {
await writeFile(file.replace(/\.md$/, ".mdx"), text.replace(/\{#([^}]+)\}/g, "[#$1]"));
await rm(file);
}
}
const oldHeadings = JSON.parse(await readFile(join(generated, "old-headings.json"), "utf8"));
for (const path of await files(content)) {
if (!/\.mdx?$/.test(path)) continue;
const rel = relative(content, path).replaceAll(sep, "/");
const route = routeFor(rel);
const { data, body } = splitPage(await readFile(path, "utf8"));
delete data.noindex;
data.slug = route.replace(/^\//, "") || "/";
const pinned = extractPageTitle(
pinHeadings(body, oldHeadings[route.slice(1) || "."], path.endsWith(".mdx")),
data,
rel.includes("/examples/") || rel.startsWith("examples/"),
);
// GitHub's buttons remain ordinary links; no third-party script is needed in the new shell.
let converted = rewriteLinks(pinned, rel).replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, "");
converted = converted.replace(/^(\s*`{3,})slurm\b/gm, "$1bash");
if (path.endsWith(".mdx"))
converted = converted.replace(
/<style>([\s\S]*?)<\/style>/g,
(_, css) => `<style>{${JSON.stringify(css)}}</style>`,
);
{
const aliases = (oldHeadings[route.slice(1)] ?? []).filter(
(heading) =>
!converted.includes(`id="${heading.id}"`) &&
!converted.includes(`[#${heading.id}]`) &&
!converted.includes(`{#${heading.id}}`),
);
converted += "\n" + aliases.map((heading) => `<a id="${heading.id}"></a>`).join("\n");
}
await writeFile(path, `---\n${stringify(data)}---\n${converted}`);
}
// API HTML is extracted directly from Python source with Griffe.
await rm(join(content, "getting_started/v1_api.md"), { force: true });
await rm(join(content, "getting_started/v1_api.mdx"), { force: true });
await cp(join(generated, "python-reference"), content, { recursive: true });
const originalHome = splitPage(await readFile(join(content, "index.md"), "utf8")).body;
const section = originalHome.indexOf("## Key Features");
if (section < 0) throw new Error("The homepage source needs an updated section boundary.");
await mkdir(join(content, "_snippets"), { recursive: true });
await mkdir(join(content, "_snippets/clients"), { recursive: true });
for (const [file, language] of [
["video.py", "python"],
["video.mjs", "javascript"],
["video.sh", "bash"],
]) {
const code = await readFile(join(repo, "examples/serving/clients", file), "utf8");
await writeFile(join(content, "_snippets/clients", `${file}.md`), `\`\`\`${language}\n${code}\n\`\`\`\n`);
}
const asMdx = (body) =>
body
.replace(/\{#([^}]+)\}/g, "[#$1]")
.replace(/<img\b([^>]*?)\/?\s*>/g, "<img$1 />")
.replace(/ style="[^"]*"/g, "");
const description = originalHome
.slice(0, section)
.split(/\n\s*\n/)
.find((paragraph) => paragraph.trim().startsWith("FastVideo is an inference"));
if (!description) throw new Error("The homepage description paragraph needs updating.");
await writeFile(join(content, "_snippets/home-description.mdx"), asMdx(description.trim()));
const documentation = originalHome.indexOf("## Documentation", section);
if (documentation < 0) throw new Error("The homepage documentation section boundary needs updating.");
await writeFile(join(content, "_snippets/home-details.mdx"), asMdx(originalHome.slice(section, documentation)));
// The README owns announcements and the community list; the welcome page renders a short news digest.
const readme = await readFile(join(repo, "README.md"), "utf8");
const { news, projects } = readReadmeSections(readme);
if (!news || !projects) {
const accepted = (names) => names.map((name) => `"## ${name.replace(/\b\w/g, (c) => c.toUpperCase())}"`).join(", ");
throw new Error(
`README section not found. Accepted news headings: ${accepted(README_HEADINGS.news)}. ` +
`Accepted project headings: ${accepted(README_HEADINGS.projects)}.`,
);
}
const recent = news.slice(0, 2).map(({ date, text }) => {
const links = [...text.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)];
const headline = text
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.split(/:\s|\.\s/)[0]
.replace(/\.$/, "");
const href = links.at(-1)?.[2] ?? "https://github.com/hao-ai-lab/FastVideo#news";
const iso = date.replaceAll("/", "-");
const label = new Intl.DateTimeFormat("en", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(new Date(iso));
return `<li><time dateTime="${iso}">${label}</time><a href={${JSON.stringify(href)}}>{${JSON.stringify(headline)}}</a></li>`;
});
await writeFile(
join(content, "_snippets/home-news.mdx"),
`<section className="home-news not-prose" aria-labelledby="recent-news">\n<div className="home-news-heading"><h2 id="recent-news">Recent news</h2><a className="home-news-more" href="https://github.com/hao-ai-lab/FastVideo#news">All announcements →</a></div>\n<ul>\n${recent.join("\n")}\n</ul>\n</section>\n`,
);
await writeFile(join(content, "_snippets/home-projects.mdx"), asMdx(projects.trim()));
await rm(join(content, "index.md"));
await rm(join(content, "cookbook/index.md"), { force: true });
await rm(join(content, "cookbook/index.mdx"), { force: true });
await cp(join(site, "content"), content, { recursive: true });
// Model-specific inference pages come from the maintained recipe inventory.
for (const model of modelDirectory.filter((model) => !model.configurable)) {
const file = join(content, `${model.path.slice(1)}.mdx`);
await mkdir(dirname(file), { recursive: true });
await writeFile(
file,
`---\ntitle: ${JSON.stringify(model.title)}\nmode: frame\n---\n\n<Cookbook modelId="${model.id}" />\n`,
);
}
await cp(join(source, "assets"), join(publicDir, "assets"), {
recursive: true,
filter: (path) => !path.endsWith(".md"),
});
for (const path of attachments) {
await mkdir(dirname(join(publicDir, path)), { recursive: true });
await cp(join(source, path), join(publicDir, path));
}
const routes = new Set(
(await files(content))
.filter(
(path) =>
/\.mdx?$/.test(path) &&
!relative(content, path)
.split(sep)
.some((part) => /^[_.]/.test(part)),
)
.map((path) => routeFor(relative(content, path))),
);
const navigation = JSON.parse(await readFile(join(site, "navigation.json"), "utf8"));
function checkNavigation(items) {
for (const item of items) {
if (typeof item === "string") {
if (!routes.has(item)) throw new Error(`Navigation target is missing: ${item}`);
} else {
if (item.href && !routes.has(item.href)) throw new Error(`Navigation target is missing: ${item.href}`);
if (item.items) checkNavigation(item.items);
}
}
}
checkNavigation(navigation);
// Keep generated modules discoverable without putting all 880 entries in every page's sidebar.
for (const path of await files(join(content, "api"))) {
if (!path.endsWith(".md")) continue;
const { data, body } = splitPage(await readFile(path, "utf8"));
const folder = dirname(path);
const children = (await readdir(folder, { withFileTypes: true })).filter((item) => item.isDirectory());
const links = children
.map(
(item) =>
`<li><a href="${routeFor(relative(content, join(folder, item.name, "index.md")))}">${item.name}</a></li>`,
)
.join("\n");
await writeFile(
path,
`---\n${stringify(data)}---\n${body.replaceAll("{", "&#123;").replaceAll("}", "&#125;")}${links ? `\n<h2>Submodules</h2>\n<ul>\n${links}\n</ul>\n` : ""}`,
);
}
function addModelPages(items) {
for (const item of items) {
const entries = modelDirectory.filter((model) => !model.configurable && model.familyPath === item.href);
if (entries.length && !/Family guide|Recipes and setup/.test(item.label)) {
const guide = { label: "Family guide", href: item.href };
delete item.href;
item.display = "group";
item.collapsed = true;
item.items = [...entries.map((model) => ({ label: model.title, href: model.path })), guide];
} else if (item.items) {
const guide = item.items.find((child) => /Family guide|Recipes and setup/.test(child.label));
if (guide) {
const listed = new Set(item.items.map((child) => child.href));
const extras = modelDirectory.filter(
(model) => !model.configurable && model.familyPath === guide.href && !listed.has(model.path),
);
item.items.splice(
item.items.indexOf(guide),
0,
...extras.map((model) => ({ label: model.title, href: model.path })),
);
}
addModelPages(item.items);
}
}
}
addModelPages(navigation);
await writeFile(join(generated, "navigation.json"), JSON.stringify(navigation));
await writeFile(join(generated, "routes.json"), JSON.stringify([...routes].sort()));
console.log(
`Transferred ${routes.size} documentation routes. Canonical guide routes and cookbook runbooks are published together.`,
);
}
-112
View File
@@ -1,112 +0,0 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { parse } from "yaml";
import { configAndLaunch, inferenceOutput, pythonSource, sdkCommand } from "../lib/cookbook-commands.ts";
test("every generated Python example remains valid after its source-backed settings are applied", () => {
const directory = new URL("../.generated/source-examples/", import.meta.url);
const examples = readdirSync(directory)
.filter((name) => name.endsWith(".json"))
.map((name) => {
const data = JSON.parse(readFileSync(new URL(name, directory), "utf8"));
const edits = Object.fromEntries(
data.controls.map((field, i) => [
i,
typeof field.value === "number"
? 2
: typeof field.value === "boolean"
? false
: "A bowl 🥣 with 'quotes' and $(literal text)",
]),
);
return {
name,
baseline: pythonSource(data.source, data.controls),
edited: pythonSource(data.source, data.controls, edits),
};
});
assert.ok(examples.length > 25);
execFileSync(
"python3",
[
"-c",
"import ast,json,sys\nfor example in json.load(sys.stdin):\n for key in ('baseline', 'edited'):\n ast.parse(example[key], filename=example['name'])",
],
{ input: JSON.stringify(examples) },
);
});
test("SDK launch commands have valid shell syntax without executing model code", () => {
execFileSync("bash", ["-n"], {
input: configAndLaunch("generator: {}\nrequest: {}\n", `FASTVIDEO_FA4=0 ${sdkCommand}`),
});
});
test("copy-and-launch writes literal YAML before invoking the command", () => {
const root = mkdtempSync(join(tmpdir(), "fastvideo-copy-command-"));
try {
const yaml = 'prompt: "$(touch injected) `touch injected2`"\nFASTVIDEO_CONFIG\n';
execFileSync("bash", ["-c", configAndLaunch(yaml, "cp config.yaml received.yaml")], { cwd: root });
assert.equal(readFileSync(join(root, "received.yaml"), "utf8"), yaml);
assert.throws(() => readFileSync(join(root, "injected")));
assert.throws(() => readFileSync(join(root, "injected2")));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("inference output preserves edited settings and hidden generator fields", () => {
const config = {
generator: {
model_path: "FastVideo/Trim",
engine: { num_gpus: 2 },
pipeline: { experimental: { VSA_sparsity: 0.8 } },
},
server: { port: 9000 },
default_request: { prompt: "A ceramic bowl", sampling: { num_frames: 243, seed: 0 } },
};
const result = inferenceOutput({ config, command: "FASTVIDEO_FA4=0 fastvideo serve --config config.yaml" });
const saved = parse(result.yaml);
assert.deepEqual(saved.generator, config.generator);
assert.deepEqual(saved.request.sampling, config.default_request.sampling);
assert.equal(saved.request.prompt, "A ceramic bowl");
assert.equal(saved.request.output.save_video, true);
assert.equal(saved.server, undefined);
assert.equal(result.command, "FASTVIDEO_FA4=0 fastvideo generate --config config.yaml");
assert.ok(config.server);
});
test("Wan I2V inference requires a source image and preserves the serving baseline", () => {
const config = parse(
readFileSync(new URL("../../examples/serving/openai_wan21_i2v_14b.yaml", import.meta.url), "utf8"),
);
const baseline = structuredClone(config);
const result = { config, command: "fastvideo serve --config config.yaml" };
assert.throws(() => inferenceOutput(result), /source image path/);
assert.throws(() => inferenceOutput(result, " "), /source image path/);
const saved = parse(inferenceOutput(result, " /data/first-frame.png ").yaml);
assert.equal(saved.request.inputs.image_path, "/data/first-frame.png");
assert.deepEqual(saved.request.sampling, config.default_request.sampling);
assert.deepEqual(config, baseline);
assert.equal(config.default_request.inputs, undefined);
const withDefaults = structuredClone(config);
withDefaults.default_request.inputs = { image_path: "/data/existing.png", video_path: "/data/reference.mp4" };
const existing = parse(inferenceOutput({ ...result, config: withDefaults }).yaml);
assert.deepEqual(existing.request.inputs, withDefaults.default_request.inputs);
});
test("OmniRef inference writes the source image as an ordered Ref2VA reference", () => {
const config = parse(
readFileSync(new URL("../../examples/serving/openai_fasth3_omniref_rtx5090.yaml", import.meta.url), "utf8"),
);
const result = { config, command: "fastvideo serve --config config.yaml" };
assert.throws(() => inferenceOutput(result), /source image path/);
const saved = parse(inferenceOutput(result, " /data/reference.png ").yaml);
assert.deepEqual(saved.request.inputs.references, [{ source: "/data/reference.png", media_type: "image" }]);
assert.equal(saved.request.inputs.image_path, undefined);
assert.equal(saved.generator.pipeline.components.override_pipeline_cls_name, "MiniMaxH3Ref2VAModularPipeline");
});
-76
View File
@@ -1,76 +0,0 @@
import assert from "node:assert/strict";
import { readFile, access } from "node:fs/promises";
import { test } from "node:test";
const site = new URL("../", import.meta.url);
const routes = JSON.parse(await readFile(new URL(".generated/routes.json", site), "utf8"));
const old = JSON.parse(await readFile(new URL("migration-anchors.json", site), "utf8"));
test("every migrated route and heading anchor survives the cutover", async () => {
const failures = [];
for (const [path, headings] of Object.entries(old)) {
const route = path === "." ? "/" : `/${path}`;
if (!routes.includes(route)) {
failures.push(`Missing route: ${route}`);
continue;
}
const html = await readFile(new URL(`dist/${path === "." ? "" : `${path}/`}index.html`, site), "utf8");
for (const heading of headings) {
const escaped = heading.id.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
if (!html.includes(`id="${escaped}"`) && !html.includes(`id='${escaped}'`))
failures.push(`${route}#${heading.id}`);
}
}
assert.deepEqual(failures, []);
});
test("converted guides render admonitions, preserve content, and publish local images", async () => {
const quickStart = await readFile(new URL("dist/getting_started/quick_start/index.html", site), "utf8");
assert.ok(quickStart.includes("Need more control?"));
assert.ok(!quickStart.includes("!!! tip"));
const home = await readFile(new URL("dist/index.html", site), "utf8");
assert.ok(home.includes("Welcome to FastVideo"));
assert.ok(home.includes("/FastVideo/assets/logos/logo.svg"));
assert.ok(home.includes("FastVideo is an inference and post-training framework for video diffusion models."));
assert.ok(!home.includes("About FastVideo and performance overview"));
await readFile(new URL("dist/assets/logos/logo.svg", site));
});
test("Python API and generated examples contain the actual rendered source documentation", async () => {
const api = await readFile(new URL("dist/api/fastvideo/configs/pipelines/base/index.html", site), "utf8");
assert.ok(api.includes('id="fastvideo.configs.pipelines.base.PipelineConfig"'));
assert.ok(api.includes("from_pretrained"));
assert.ok(api.includes("python-reference"));
const examples = routes.filter((route) => route.startsWith("/inference/examples/"));
assert.ok(examples.length > 5);
const example = await readFile(new URL("dist/inference/examples/basic/index.html", site), "utf8");
assert.ok(example.includes("VideoGenerator"));
});
test("generated examples show their page title only once", async () => {
const failures = [];
for (const route of routes.filter((item) => item.includes("/examples/") || item.startsWith("/examples/"))) {
const html = await readFile(new URL(`dist${route}/index.html`, site), "utf8");
const headings = [...html.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/g)].map((match) => ({
level: Number(match[1]),
text: match[2]
.replace(/<[^>]+>/g, "")
.replace(/[^\p{L}\p{N}]/gu, "")
.toLowerCase(),
}));
const title = headings.find((heading) => heading.level === 1)?.text;
if (!title || headings.filter((heading) => heading.text === title).length !== 1) failures.push(route);
}
assert.deepEqual(failures, []);
});
test("the deployment artifact contains no retired site scripts or styles", async () => {
for (const name of ["custom.js", "custom.css", "copy-page.js", "cookbook.js", "cookbook-runbooks.css"]) {
await assert.rejects(access(new URL(`dist/assets/${name}`, site)));
}
const home = await readFile(new URL("dist/index.html", site), "utf8");
assert.ok(home.includes("home-featured-model"));
const family = await readFile(new URL("dist/cookbook/wan/index.html", site), "utf8");
assert.ok(!family.includes("legacy-cookbook"));
assert.ok(family.includes("recipe-card"));
});
-877
View File
@@ -1,877 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
const route = "/FastVideo/cookbook/minimax-h3/fasth3-8step";
// The cookbook has no behavioral dependency on third-party scripts. Keep load
// deterministic when GitHub/Trendshift are slow or unreachable.
test.beforeEach(async ({ page }) => {
await page.route(/^https?:\/\/(?!127\.0\.0\.1|localhost)/, (request) => request.abort());
await page.route("**/assets/cookbook-config/capacity/hardware.json", (route) =>
route.fulfill({ path: "tests/fixtures/hardware.json", contentType: "application/json" }),
);
});
async function chooseHardware(page: Page, query: string) {
const search = page.getByLabel("Search hardware", { exact: true });
if ((await search.count()) === 0)
await page
.getByRole("button", { name: /Find or add|Choose hardware|Change hardware/ })
.first()
.click();
await search.fill(query);
await page
.getByRole("radio", { name: new RegExp(query, "i") })
.first()
.click();
}
async function settings(page: Page, section: string) {
await expect(page.locator(".recipe-matrix")).toBeVisible({ timeout: 30000 });
if (section === "Serving" || section === "Hardware") {
const advanced = page.locator(".matrix-advanced");
if ((await advanced.getAttribute("open")) === null) await advanced.locator("summary").first().click();
}
}
async function backend(page: Page, name: string) {
await page
.getByRole("group", { name: "Hardware backend", exact: true })
.getByRole("button", { name, exact: true })
.click();
}
async function chip(page: Page, group: string, name: string) {
await page.getByRole("group", { name: group, exact: true }).getByRole("button", { name, exact: true }).click();
}
async function output(page: Page, section: string) {
await page.getByRole("tablist", { name: "Output format" }).getByRole("tab", { name: section, exact: true }).click();
}
test("edits generate YAML and client instructions, invalid edits clear output, reset restores baseline", async ({
page,
}) => {
await page.goto(route);
await output(page, "config.yaml");
const yaml = page.getByLabel("Generated YAML", { exact: true });
await settings(page, "Serving");
await expect(page.getByLabel("Port", { exact: true })).toHaveValue("8000");
const baseline = await yaml.innerText();
await page.getByLabel("Port", { exact: true }).fill("9000");
await expect(yaml).toContainText('"port": 9000');
await output(page, "Commands");
await expect(page.getByLabel("Client command", { exact: true })).toContainText(":9000/");
await output(page, "config.yaml");
await settings(page, "Model");
await chip(page, "Offload VAE to CPU", "Off");
await expect(yaml).toContainText('"vae": false');
await expect(yaml).toContainText('"num_gpus": 4');
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Download YAML", exact: false }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("config.yaml");
const { readFile } = await import("node:fs/promises");
const downloaded = await readFile((await download.path())!, "utf8");
expect(downloaded).toContain('"port": 9000');
await settings(page, "Serving");
await page.getByLabel("Port", { exact: true }).fill("70000");
await expect(page.getByRole("button", { name: "Download YAML", exact: false })).toBeDisabled();
await expect(yaml).toHaveCount(0);
await expect(page.getByLabel("Port", { exact: true })).toHaveAttribute("aria-invalid", "true");
await page.getByRole("button", { name: "Reset changes", exact: true }).click();
await expect(yaml).toHaveText(baseline);
});
test("copy buttons use their own generated output", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.goto(route);
await settings(page, "Serving");
await page.getByLabel("Port", { exact: true }).fill("9001");
await output(page, "config.yaml");
const yaml = await page.getByLabel("Generated YAML", { exact: true }).innerText();
await page.getByRole("button", { name: "Copy YAML", exact: true }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(yaml);
await output(page, "Commands");
const server = await page.getByLabel("Server command", { exact: true }).innerText();
const launch = [
"(",
"set -e",
"cat > config.yaml <<'FASTVIDEO_CONFIG'",
yaml.trimEnd(),
"FASTVIDEO_CONFIG",
server,
")",
].join("\n");
await page.getByRole("button", { name: "Copy config + command", exact: true }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(launch);
await page.locator(".recipe-request-code > summary").click();
const client = await page.getByLabel("Client command", { exact: true }).innerText();
expect(client).toContain(":9001/");
await page.getByRole("button", { name: "Copy request command", exact: true }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(client);
expect(new Set([yaml, launch, client]).size).toBe(3);
});
test("deployment changes reset edits and support MLX and streaming", async ({ page }) => {
await page.goto(route);
await settings(page, "Serving");
await page.getByLabel("Port", { exact: true }).fill("9000");
await backend(page, "MLX");
await expect(page.getByLabel("Server command", { exact: true })).toContainText("mlx_server");
await settings(page, "Serving");
await expect(page.getByLabel("Port", { exact: true })).toHaveValue("8000");
await page.goto("/FastVideo/cookbook/ltx/ltx2-distilled");
await expect(page.locator(".recipe-matrix")).toBeVisible({ timeout: 30000 });
await expect(page.getByLabel("Client command", { exact: true })).toContainText("/health");
await page.locator(".recipe-request-code > summary").click();
await expect(page.getByText(/WebSocket URL:/)).toBeVisible();
await page.goto("/FastVideo/cookbook/wan/wan21-i2v");
await expect(page.locator(".recipe-matrix")).toBeVisible({ timeout: 30000 });
await expect(page.getByLabel("Client command", { exact: true })).toContainText("input_reference");
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Wan2.1 I2V 14B");
});
test("runtime and hardware choices support browser back", async ({ page }) => {
await page.goto(route);
await expect(page.locator(".recipe-matrix")).toBeVisible({ timeout: 30000 });
await backend(page, "MLX");
await expect(page).toHaveURL(/deployment=fasth3-8step%2Fmlx-rest/);
await backend(page, "CUDA");
await expect(page).toHaveURL(/deployment=fasth3-8step%2Fcuda-rest/);
await page.goBack();
await expect(page).toHaveURL(/deployment=fasth3-8step%2Fmlx-rest/);
await expect(page.getByRole("button", { name: "MLX", exact: true })).toHaveAttribute("aria-pressed", "true");
await page.goForward();
await expect(page).toHaveURL(/deployment=fasth3-8step%2Fcuda-rest/);
await chooseHardware(page, "RTX 5090");
await expect(page).toHaveURL(/hardware=geforce-rtx-5090-32gb/);
await page.goBack();
await expect(page).not.toHaveURL(/hardware=/);
await page.goForward();
await expect(page).toHaveURL(/hardware=geforce-rtx-5090-32gb/);
});
test("failed loads offer retry without keeping stale commands", async ({ page }) => {
await page.route("**/recipes/fasth3-8step/cuda-rest.json", (route) =>
route.fulfill({ status: 503, body: "unavailable" }),
);
await page.goto(route);
await expect(page.getByRole("alert")).toContainText("HTTP 503");
await expect(page.getByLabel("Generated YAML", { exact: true })).toHaveCount(0);
await page.unroute("**/recipes/fasth3-8step/cuda-rest.json");
await page.getByRole("button", { name: "Retry loading recipes" }).click();
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML", { exact: true })).toBeVisible();
});
test("an obsolete deployment response cannot replace the selected deployment", async ({ page }) => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route("**/recipes/fasth3-8step/mlx-rest.json", async (route) => {
await gate;
await route.continue().catch(() => {}); // The component may already have aborted the request.
});
await page.goto(route);
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML", { exact: true })).toBeVisible();
await backend(page, "MLX");
await backend(page, "CUDA");
release();
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("FastVideo-FastH3-8-Step-V2");
await expect(
page.getByRole("group", { name: "Hardware backend" }).getByRole("button", { name: "CUDA", exact: true }),
).toHaveAttribute("aria-pressed", "true");
});
test("mobile layout stays within the viewport and controls work by keyboard", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(route);
await settings(page, "Serving");
const port = page.getByLabel("Port", { exact: true });
await port.focus();
await port.press("ControlOrMeta+A");
await port.press("9");
await port.press("0");
await port.press("0");
await port.press("1");
await expect(page.getByLabel("Client command", { exact: true })).toContainText(":9001/");
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
test("inherited values, explicit false, zero and null retain their meanings", async ({ page }) => {
await page.goto("/FastVideo/cookbook?deployment=fastwan21%2Fcuda-rest");
await output(page, "config.yaml");
const yaml = page.getByLabel("Generated YAML", { exact: true });
await expect(yaml).toBeVisible();
await expect(yaml).not.toContainText('"text_encoder":');
await settings(page, "Model");
await chip(page, "Offload text encoder to CPU", "Off");
await expect(yaml).toContainText('"text_encoder": false');
await page
.getByRole("button", {
name: "Use baseline for Offload text encoder to CPU",
exact: true,
})
.click();
await expect(yaml).not.toContainText('"text_encoder":');
await settings(page, "Request");
await page.getByLabel("Seed", { exact: true }).fill("0");
await expect(yaml).toContainText('"seed": 0');
await settings(page, "Serving");
await page.getByRole("button", { name: "Set Served model name to null", exact: true }).click();
await expect(yaml).toContainText('"served_model_name": null');
});
test("catalog discovery opens dedicated model recipes and filters by family and task", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
const cards = page.locator(".recipe-card");
await expect(cards).toHaveCount(38);
await expect(cards.first()).toContainText("FastH3 Omni v1");
await page.getByLabel("Model family", { exact: true }).selectOption("Wan");
await page.getByLabel("Search recipes").fill("Wan2.1 I2V");
await expect(cards).toHaveCount(1);
await cards.click();
await expect(page).toHaveURL(/cookbook\/wan\/wan21-i2v\/?$/);
await expect(page.getByLabel("Base model")).toHaveCount(0);
await page.getByRole("link", { name: "← All recipes", exact: true }).click();
await page.getByLabel("Search recipes").fill("FastWan2.1");
await page.locator(".recipe-card").click();
await expect(page).toHaveURL(/cookbook\/wan\/fastwan21\/?$/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("FastWan2.1 1.3B");
});
test("hardware preferences stay distinct from schema-backed GPU count and topology", async ({ page }) => {
await page.goto(route);
await chooseHardware(page, "RTX PRO 6000");
await expect(page).toHaveURL(/hardware=rtx-pro-6000-blackwell-workstation-96gb/);
await expect(page.locator(".hardware-selected")).toContainText("96 GB VRAM");
await settings(page, "Hardware");
await page.getByLabel("Tensor parallelism", { exact: true }).fill("2");
await page.getByRole("button", { name: "Use automatic parallelism" }).click();
await expect(page.getByLabel("Tensor parallelism", { exact: true })).toHaveValue("-1");
await page.getByLabel("GPU count", { exact: true }).fill("1");
await output(page, "config.yaml");
const yaml = page.getByLabel("Generated YAML", { exact: true });
await expect(yaml).toContainText('"num_gpus": 1');
await expect(yaml).not.toContainText('"hardware"');
await expect(page.getByText("Schema checks passed", { exact: true })).toBeVisible();
await expect(page.getByText("Memory not estimated", { exact: true })).toBeVisible();
await page.getByLabel("GPU count", { exact: true }).fill("0");
await expect(yaml).toHaveCount(0);
await expect(page.getByLabel("GPU count", { exact: true })).toHaveAttribute("aria-invalid", "true");
});
test("output tabs support keyboard navigation and precision chips use the backend schema", async ({ page }) => {
await page.goto(route);
const tabs = page.getByRole("tablist", { name: "Output format" });
await tabs.getByRole("tab", { name: "Commands", exact: true }).focus();
await page.keyboard.press("ArrowRight");
await expect(tabs.getByRole("tab", { name: "config.yaml", exact: true })).toHaveAttribute("aria-selected", "true");
await backend(page, "MLX");
await chip(page, "VAE compute precision", "bf16");
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText('"vae_dtype": "bf16"');
await expect(
page.getByRole("group", { name: "Hardware backend" }).getByRole("button", { name: "AMD", exact: true }),
).toHaveCount(0);
await page.goto("/FastVideo/cookbook");
await page.getByLabel("Search recipes").fill("does not exist");
await expect(page.getByRole("heading", { name: "No matching recipes" })).toBeVisible();
await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page.locator(".recipe-card")).toHaveCount(38);
});
test("cookbook cards and controls remain readable in dark mode and reflow at 320px", async ({ page }) => {
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("/FastVideo/cookbook");
await expect(page.locator(".recipe-card").first()).toBeVisible();
await page.setViewportSize({ width: 320, height: 900 });
await expect(page.locator(".recipe-card").first()).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.goto(route);
await settings(page, "Serving");
await expect(page.getByLabel("Port", { exact: true })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.getByRole("button", { name: "Download YAML", exact: false })).toBeEnabled();
});
test("image and audio models have logos and dedicated source recipe pages", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await page.getByRole("button", { name: "Image", exact: true }).click();
await expect(page.locator(".recipe-card")).toHaveCount(5);
const card = page.locator(".recipe-card").filter({ hasText: "FLUX.2 Klein" });
await expect(card.locator("img")).toBeVisible();
await card.click();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("FLUX.2 Klein 4B");
await expect(page.getByLabel("Inference command")).toContainText("flux");
await expect(
page.getByRole("group", { name: "Run mode" }).getByRole("button", { name: "Python SDK", exact: true }),
).toHaveAttribute("aria-pressed", "true");
await expect(page.getByRole("button", { name: "Serving", exact: true })).toHaveCount(0);
await page.getByLabel("Seed", { exact: true }).fill("0");
await expect(page.getByRole("button", { name: "Copy example + command", exact: true })).toBeEnabled();
await page.goto("/FastVideo/cookbook/qwen-image21");
const qwen = page.locator(".recipe-card").filter({ hasText: "Qwen-Image-2.1" });
await expect(qwen.locator("img")).toHaveAttribute("src", /\/qwen\.webp$/);
await qwen.click();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Qwen-Image-2.1");
await expect(page.getByRole("button", { name: "Serving", exact: true })).toHaveCount(0);
await page.getByLabel("Height", { exact: true }).fill("512");
await page.getByRole("tab", { name: "config.yaml", exact: true }).click();
const qwenConfig = page.getByLabel("Source example", { exact: true });
await expect(qwenConfig).toContainText("model_path: Qwen/Qwen-Image-2.1");
await expect(qwenConfig).toContainText("height: 512");
await expect(qwenConfig).toContainText("dit_layerwise: true");
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
await page
.getByRole("group", { name: "Recipe variant", exact: true })
.getByRole("button", { name: "Qwen-Image-2.1 references and editing", exact: true })
.click();
await page.getByRole("tab", { name: "Commands", exact: true }).click();
await expect(page.getByLabel("Inference command", { exact: true })).toContainText("--reference source.png");
await expect(page.getByRole("button", { name: "Copy example + command", exact: true })).toBeEnabled();
await page.goto("/FastVideo/cookbook");
await page.getByRole("button", { name: "Audio", exact: true }).click();
await expect(page.locator(".recipe-card")).toHaveCount(3);
await page.locator(".recipe-card").first().click();
await page.getByLabel("Audio duration", { exact: true }).fill("1.5");
await expect(page.getByRole("button", { name: "Copy example + command", exact: true })).toBeEnabled();
});
test("provider labels match FastVideo logos and CompactH3 is replaced by FastH3 Trim", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
const ownedCards = page.locator(".recipe-card").filter({ has: page.locator('img[src$="/fastvideo.webp"]') });
for (const card of await ownedCards.all()) await expect(card.locator(".recipe-card-kicker")).toHaveText("FastVideo");
await page.getByLabel("Search recipes").fill("FastH3 Trim");
await expect(page.locator(".recipe-card")).toHaveCount(1);
await page.locator(".recipe-card").click();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("FastH3 Trim");
await expect(page.getByRole("link", { name: "View on Hugging Face ↗", exact: true })).toHaveAttribute(
"href",
/FastVideo-FastH3-Trim-8-Step-NVFP4/,
);
});
test("FastH3 Trim edits drive inference, serving, and complete copyable launch scripts", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: async (text: string) => {
(window as Window & { copiedCommand?: string }).copiedCommand = text;
},
},
});
});
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-trim");
await chooseHardware(page, "DGX Spark");
await expect(page).toHaveURL(/hardware=dgx-spark-gb10-128gb/);
await expect(page.getByText(/128 GB unified memory/)).toBeVisible();
await page.getByLabel("Prompt", { exact: true }).fill("A potter finishes a blue bowl.");
await page.getByLabel("Seed", { exact: true }).fill("0");
await page.getByRole("button", { name: "Copy config + command", exact: true }).click();
const script = await page.evaluate(() => (window as Window & { copiedCommand?: string }).copiedCommand);
expect(script).toContain("cat > config.yaml <<'FASTVIDEO_CONFIG'");
expect(script).toContain("FastVideo-FastH3-Trim-8-Step-NVFP4");
expect(script).toContain('"seed": 0');
expect(script).toContain("FASTVIDEO_VSA_TRITON=1");
expect(script).toContain("fastvideo serve --config config.yaml");
await page.locator(".recipe-request-code summary").click();
await expect(page.getByLabel("Client command", { exact: true })).toContainText("A potter finishes a blue bowl.");
await chip(page, "Run mode", "CLI");
await expect(page.getByLabel("Inference command", { exact: true })).toContainText("fastvideo generate");
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("request:");
await expect(page.getByLabel("Generated YAML", { exact: true })).not.toContainText("server:");
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("num_inference_steps: 9");
await chip(page, "Run mode", "Python SDK");
await output(page, "Commands");
await expect(page.getByLabel("SDK command", { exact: true })).toContainText("VideoGenerator.from_config");
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test("source-backed controls update the copied command without changing models", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/models/minimax-h3-t2v");
await chip(page, "GPU count", "2");
await expect(page.getByLabel("Inference command", { exact: true })).toContainText("--num-gpus '2'");
});
test("Pika H3 Flex knobs select the tier, bucket, duration, task, and NVFP4 decoder", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3");
const pika = page.locator(".recipe-card").filter({ hasText: "Pika H3 Flex" });
await expect(pika.locator("img")).toHaveAttribute("src", /\/pika\.webp$/);
await expect(pika).toContainText("Pika");
await pika.click();
await expect(page).toHaveURL(/cookbook\/minimax-h3\/models\/pika-h3-flex-t2va$/);
const command = page.getByLabel("Inference command", { exact: true });
await expect(command).toContainText("FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN FASTVIDEO_FA4=1");
await chip(page, "Resolution", "480p");
await chip(page, "Aspect ratio", "9:16");
await chip(page, "Duration", "10 s (243 frames)");
await chip(page, "VAE decoder", "NVFP4 36L quality");
await expect(command).toContainText("--resolution '480p'");
await expect(command).toContainText("--aspect '9:16'");
await expect(command).toContainText("--duration '10'");
await expect(command).toContainText("--nvfp4-decoder './fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors'");
await page
.getByRole("group", { name: "Recipe variant", exact: true })
.getByRole("button", { name: "Pika H3 Flex keyframes", exact: true })
.click();
await expect(command).toContainText("--task fl2va --offload resident");
await chip(page, "Task", "First frame (I2VA)");
await chip(page, "Memory", "Lazy load + offload");
await expect(command).toContainText("--task 'i2va'");
await expect(command).toContainText("--offload 'lazy'");
await expect(page.getByRole("button", { name: "Copy example + command", exact: true })).toBeEnabled();
});
test("Wan I2V requires an image for CLI and SDK examples without changing server defaults", async ({ page }) => {
await page.goto("/FastVideo/cookbook/wan/wan21-i2v");
await chip(page, "Run mode", "CLI");
await expect(page.getByRole("alert")).toContainText("Enter a source image path");
await expect(page.getByRole("button", { name: "Copy config + command", exact: true })).toBeDisabled();
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeDisabled();
await page.getByLabel("Source image path", { exact: true }).fill("/data/first-frame.png");
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("image_path: /data/first-frame.png");
await expect(page.getByRole("button", { name: "Copy config + command", exact: true })).toBeEnabled();
await chip(page, "Run mode", "Python SDK");
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("image_path: /data/first-frame.png");
await page.getByLabel("Source image path", { exact: true }).fill("");
await expect(page.getByRole("button", { name: "Copy YAML", exact: true })).toBeDisabled();
await expect(page.getByLabel("Generated YAML", { exact: true })).toHaveCount(0);
await chip(page, "Run mode", "Serving");
await expect(page.getByRole("button", { name: "Copy config + command", exact: true })).toBeEnabled();
await expect(page.getByLabel("Generated YAML", { exact: true })).not.toContainText("image_path");
});
test("source editors retain required model flags and preparation commands", async ({ page }) => {
await page.goto("/FastVideo/cookbook/wan/models/fastmetal-1-3b-mlx");
await expect(page.getByLabel("Height", { exact: true })).toHaveValue("480");
await expect(page.getByLabel("Inference command", { exact: true })).toContainText(
"hf download FastVideo/FastMetal-1.3B-QAD",
);
await expect(page.getByLabel("Inference command", { exact: true })).toContainText(
"--model-root ./FastMetal-1.3B-QAD",
);
await page.getByLabel("Seed", { exact: true }).fill("0");
await expect(page.getByLabel("Inference command", { exact: true })).toContainText("--seed '0'");
await page.getByRole("tab", { name: "Python example", exact: true }).click();
await expect(page.getByLabel("Source example", { exact: true })).toContainText("default=480");
});
test("source-backed serving pages expose their actual server settings", async ({ page }) => {
await page.goto("/FastVideo/cookbook/wan/models/wan22-t2v");
await chip(page, "Run mode", "Serving");
await page.locator(".matrix-advanced > summary").click();
await page.getByLabel("Port", { exact: true }).fill("9005");
await page.getByRole("button", { name: "Show request", exact: true }).click();
await expect(page.getByLabel("Client command", { exact: true })).toContainText(":9005/v1/videos/sync");
await page.getByRole("tab", { name: "config.yaml", exact: true }).click();
await expect(page.getByLabel("Source example", { exact: true })).toContainText("port: 9005");
await expect(page.getByLabel("Source example", { exact: true })).toContainText("Wan2.2-T2V-A14B");
await expect(page.getByRole("button", { name: "Copy config + command", exact: true })).toBeEnabled();
});
test("source model hardware preferences survive hydration and reload", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(
"/FastVideo/cookbook/flux/models/flux2-klein-t2i?hardware=custom&hwName=RTX+4090&hwType=dedicated&hwMemory=24&hwUnit=GB",
);
await expect(page.getByText("RTX 4090", { exact: true })).toBeVisible();
await expect(page.locator(".hardware-selected")).toContainText("24 GB VRAM");
await page.reload();
await expect(page.getByText("RTX 4090", { exact: true })).toBeVisible();
expect(errors).toEqual([]);
});
test("custom devices persist and no preview control can block generated YAML", async ({ page }) => {
await page.goto(route);
await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click();
await page.getByRole("button", { name: "Can't find it? Add a device", exact: true }).click();
await page.getByLabel("Device name", { exact: true }).fill("RTX 4090");
await page.getByRole("combobox", { name: "Memory type", exact: true }).selectOption("dedicated");
await page.getByRole("spinbutton", { name: "Total memory", exact: true }).fill("24");
await page.getByRole("spinbutton", { name: "Usable memory (optional)", exact: true }).fill("32");
await expect(page.getByText("Usable memory cannot exceed total memory.", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Save device", exact: true })).toBeDisabled();
await page.getByRole("spinbutton", { name: "Usable memory (optional)", exact: true }).fill("22");
await page.getByRole("spinbutton", { name: "Host RAM (optional)", exact: true }).fill("64");
await page.getByRole("button", { name: "Save device", exact: true }).click();
await expect(page.getByText("RTX 4090", { exact: true })).toBeVisible();
await expect(page.locator(".hardware-selected")).toContainText("24 GB VRAM");
await expect(page.locator(".hardware-selected")).toContainText("Usable memory: 22 GB");
await expect(page.locator(".hardware-selected")).toContainText("Host RAM: 64 GB");
await expect(page).toHaveURL(/hardware=custom/);
await expect(page).toHaveURL(/hwName=RTX\+4090/);
await page.reload();
await expect(page.getByText("RTX 4090", { exact: true })).toBeVisible();
await expect(page.locator(".hardware-selected")).toContainText("24 GB VRAM");
await expect(page.locator(".component-plan")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
await expect(page.getByText("RTX 4090", { exact: true })).toHaveCount(0);
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
});
test("hardware is never preselected and a desktop card keeps the recipe GPU count", async ({ page }) => {
await page.goto(route);
await expect(page.locator(".hardware-picker")).toBeVisible({ timeout: 30000 });
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
await output(page, "config.yaml");
const yaml = page.getByLabel("Generated YAML", { exact: true });
await expect(yaml).toContainText('"num_gpus": 4');
await chooseHardware(page, "RTX 5090");
await expect(page).toHaveURL(/hardware=geforce-rtx-5090-32gb/);
await expect(page.getByText(/GeForce RTX 5090 .*32 GB VRAM/)).toBeVisible();
await expect(yaml).toContainText('"num_gpus": 4');
await expect(yaml).not.toContainText('"hardware"');
await expect(page.getByText("Schema checks passed", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
});
test("lowering the GPU count resets parallelism that no longer fits", async ({ page }) => {
await page.goto(route);
await output(page, "config.yaml");
const yaml = page.getByLabel("Generated YAML", { exact: true });
await page.getByLabel("GPU count", { exact: true }).fill("2");
await expect(page.getByText("Parallelism set to automatic for 2 GPUs.")).toBeVisible();
await expect(yaml).toContainText('"num_gpus": 2');
await expect(yaml).toContainText('"sp_size": -1');
await expect(page.getByText("Schema checks passed", { exact: true })).toBeVisible();
});
test("browser back after a hardware-only change keeps the editor loaded", async ({ page }) => {
await page.goto(route);
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
await chooseHardware(page, "RTX 5090");
await expect(page).toHaveURL(/hardware=geforce-rtx-5090-32gb/);
await page.goBack();
await expect(page).not.toHaveURL(/hardware=/);
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
await expect(page.getByText("Loading deployment settings…")).toHaveCount(0);
});
test("hardware links resolve catalog aliases and non-CUDA devices are not CUDA targets", async ({ page }) => {
await page.goto(`${route}?hardware=5090`);
await expect(page.locator(".hardware-selected")).toContainText("RTX 5090");
await page.getByRole("button", { name: "Change hardware", exact: true }).click();
await page.getByLabel("Search hardware", { exact: true }).fill("Arc B580");
await expect(page.getByRole("radio", { name: /Arc B580/ })).toHaveCount(0);
});
test("recipe facts state validity and why memory has no estimate", async ({ page }) => {
await page.goto(route);
const facts = page.locator(".recipe-facts");
await expect(facts.getByText("Schema checks passed", { exact: true })).toBeVisible();
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details", exact: true }).click();
await expect(page.getByText(/Select a device to estimate memory/)).toBeVisible();
});
test("component defaults are selected and decoder choices stay consistent with copied YAML", async ({ page }) => {
await page.goto(route);
const decoder = page.getByRole("group", { name: "NVFP4 video decoder", exact: true });
await expect(decoder.getByRole("button", { name: "Original", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(
page
.getByRole("group", { name: "Text encoder", exact: true })
.getByRole("button", { name: "Bundled", exact: true }),
).toHaveAttribute("aria-pressed", "true");
await chip(page, "Video decoder", "taeh3");
await decoder.getByRole("button", { name: /8L/ }).click();
await expect(
page
.getByRole("group", { name: "Video decoder", exact: true })
.getByRole("button", { name: "h3-vae", exact: true }),
).toHaveAttribute("aria-pressed", "true");
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML")).toContainText('"video_decode_backend": "h3-vae"');
await expect(page.getByLabel("Generated YAML")).toContainText("fast-8L/decoder-nvfp4.safetensors");
await chip(page, "Video decoder", "taeh3");
await expect(page.getByLabel("Generated YAML")).toContainText('"h3_vae_nvfp4_decoder": null');
await expect(decoder.getByRole("button", { name: "Original", exact: true })).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: "Reset changes", exact: true }).click();
await expect(page.getByLabel("Generated YAML")).not.toContainText("h3_vae_nvfp4_decoder");
});
test("failed memory metadata is visible and retry restores checkpoint data", async ({ page }) => {
await page.route("**/capacity/index.json", (route) => route.fulfill({ status: 503, body: "unavailable" }));
await page.goto(`${route}?hardware=custom&hwName=GPU&hwType=dedicated&hwMemory=80&hwUnit=GiB&hwBackend=cuda`);
await expect(page.getByText(/Memory data could not load/)).toBeVisible();
await expect(page.getByText(/Known checkpoint data/)).toHaveCount(0);
await page.unroute("**/capacity/index.json");
await page.getByRole("button", { name: "Retry memory data", exact: true }).click();
await expect(page.getByText(/Known checkpoint data: 137\.7 GiB. Device budget: 80\.0 GiB/)).toBeVisible();
await expect(page.getByText(/Memory data could not load/)).toHaveCount(0);
await expect(page.getByText("Weights fit", { exact: true })).toHaveCount(0);
});
test("host-only system RAM cannot produce a GPU weights fit verdict", async ({ page }) => {
await page.goto(`${route}?hardware=custom&hwName=Host&hwType=system&hwMemory=128&hwUnit=GB&hwBackend=cuda`);
const facts = page.locator(".recipe-facts");
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details", exact: true }).click();
await expect(facts.getByText(/Host-only system RAM is not GPU capacity/)).toBeVisible();
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
await expect(page.getByRole("button", { name: "Download YAML", exact: false })).toBeEnabled();
});
test("TAEH3 stops using the bundled VAE weights in memory facts", async ({ page }) => {
await page.goto(`${route}?hardware=custom&hwName=GPU&hwType=dedicated&hwMemory=80&hwUnit=GB&hwBackend=cuda`);
const facts = page.locator(".recipe-facts");
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await chip(page, "Video decoder", "taeh3");
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details", exact: true }).click();
await expect(facts.getByText(/Missing sizes for TAEH3 decoder/)).toBeVisible();
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML")).toContainText('"video_decode_backend": "taeh3"');
await chip(page, "Video decoder", "h3-vae");
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
});
test("FastH3 keeps unmodeled quantization and decoder residency unknown", async ({ page }) => {
await page.goto(route);
await chooseHardware(page, "RTX 5090");
const facts = page.locator(".recipe-facts");
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details", exact: true }).click();
await expect(page.getByText(/Known checkpoint data: 137\.7 GiB. Device budget: 29\.8 GiB/)).toBeVisible();
await page
.getByRole("group", { name: "Transformer quantization", exact: true })
.getByRole("button", { name: "NVFP4", exact: true })
.click();
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await expect(facts.getByText(/selected quantization and layer profile/)).toBeVisible();
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
await page
.getByRole("group", { name: "NVFP4 video decoder", exact: true })
.getByRole("button", { name: /8L · fast/ })
.click();
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
await expect(facts.getByText(/VAE with the NVFP4 decoder and retained encoder/)).toBeVisible();
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML")).toContainText('"transformer_quant": "NVFP4"');
await expect(page.getByLabel("Generated YAML")).toContainText("fast-8L/decoder-nvfp4.safetensors");
});
test("4-Step quantization and full-depth decoder swaps also stay unestimated", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-4step");
await chooseHardware(page, "RTX 5090");
const facts = page.locator(".recipe-facts");
await page.getByRole("button", { name: "Details", exact: true }).click();
await chip(page, "Transformer quantization", "FP8");
await expect(facts.getByText(/selected quantization and layer profile/)).toBeVisible();
await chip(page, "Transformer quantization", "Default");
await page
.getByRole("group", { name: "NVFP4 video decoder", exact: true })
.getByRole("button", { name: /36L/ })
.click();
await expect(facts.getByText(/VAE with the NVFP4 decoder and retained encoder/)).toBeVisible();
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
});
test("the retired Preview route and deployment links keep their checkpoint", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-preview");
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML")).toContainText("FastVideo-Minimax-FastH3-Preview-v0.2");
await page.goto("/FastVideo/cookbook?deployment=fasth3-preview%2Fcuda-rest");
await expect(page).toHaveURL(/minimax-h3\/fasth3-preview/);
await output(page, "config.yaml");
await expect(page.getByLabel("Generated YAML")).toContainText("FastVideo-Minimax-FastH3-Preview-v0.2");
});
for (const [deployment, label, sequential, tileBatch] of [
["rtx5090-rest", "RTX 5090", true, "4"],
["rtx-pro6000-rest", "RTX PRO 6000", false, "8"],
["spark-rest", "DGX Spark", false, "12"],
["cuda-rest", "RTX 5090", true, "4"],
] as const) {
test(`Omni deployment link ${deployment} preserves its hardware baseline`, async ({ page }) => {
await page.goto(`/FastVideo/cookbook?deployment=fasth3-omniref%2F${deployment}`);
await expect(page).toHaveURL(/minimax-h3\/fasth3-omniref/);
await expect(page.getByRole("button", { name: label, exact: true })).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: "Serving", exact: true }).click();
await output(page, "config.yaml");
const yaml = page.getByLabel("Generated YAML");
await expect(yaml).toContainText(`"h3_sequential_load": ${sequential}`);
await expect(yaml).toContainText('"layer_profile": "h3_dit_vsa"');
await output(page, "Commands");
await expect(page.getByLabel("Server command")).toContainText(`FASTVIDEO_H3_VAE_TILE_BATCH=${tileBatch}`);
if (deployment === "spark-rest")
await expect(page.getByLabel("Server command")).toContainText("FASTVIDEO_NVFP4_MM_BACKEND=cudnn");
});
}
test("packed FastH3 checkpoints keep their fixed precision", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-trim");
await page.getByRole("tab", { name: "config.yaml" }).click();
await expect(page.getByLabel("Generated YAML")).toContainText('"transformer_quant": "NVFP4"');
await expect(page.getByLabel("Generated YAML")).toContainText('"layer_profile": "h3_dit_vsa"');
await expect(page.getByText("Schema checks passed", { exact: true })).toBeVisible();
await expect(page.getByRole("group", { name: "Transformer quantization", exact: true })).toHaveCount(0);
});
test("MLX FastH3 stays unestimated and a GiB device keeps its unit", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-8step?deployment=fasth3-8step%2Fmlx-rest");
await page.getByRole("button", { name: "+ Find or add a Mac", exact: true }).click();
await page.getByLabel("Search hardware", { exact: true }).fill("M4 Max");
await page.getByRole("radio", { name: /Apple M4 Max 128 GB/ }).click();
await expect(page.getByText("Memory not estimated", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details", exact: true }).click();
await expect(page.getByText(/MLX checkpoints use a different precision/)).toBeVisible();
await page.goto(
"/FastVideo/cookbook/minimax-h3/fasth3-8step?hardware=custom&hwName=Big&hwType=dedicated&hwMemory=80&hwUnit=GiB&hwBackend=cuda",
);
await expect(page.getByText(/Device budget: 80\.0 GiB/)).toBeVisible();
});
test("catalog lookup resolves aliases, variants, and unified memory without pooling", async ({ page }) => {
await page.goto(route);
await chooseHardware(page, "PRO 6000");
await expect(page).toHaveURL(/hardware=rtx-pro-6000-blackwell-workstation-96gb/);
await expect(page.locator(".hardware-selected")).toContainText("96 GB VRAM");
await expect(page.locator(".hardware-address-note")).toContainText("Per device, not a combined pool");
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click();
await page.getByLabel("Search hardware", { exact: true }).fill("GB10");
await page.getByRole("radio", { name: /DGX Spark/ }).click();
await expect(page.locator(".hardware-selected")).toContainText("128 GB unified memory");
await expect(page.locator(".hardware-selected")).toContainText("CUDA");
await expect(page.locator(".hardware-address-note")).toContainText("not dedicated VRAM");
await settings(page, "Hardware");
await page.getByLabel("GPU count", { exact: true }).fill("8");
await expect(page.getByText(/8 GPUs share unified memory/)).toBeVisible();
await expect(page.getByText(/its 128 GB is not multiplied/)).toBeVisible();
});
test("MLX lookup uses unified memory and a custom Mac stays reusable", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-8step?deployment=fasth3-8step%2Fmlx-rest");
await page.getByRole("button", { name: "+ Find or add a Mac", exact: true }).click();
await page.getByLabel("Search hardware", { exact: true }).fill("M4 Max");
await page.getByRole("radio", { name: /Apple M4 Max/ }).click();
await expect(page.locator(".hardware-selected")).toContainText("128 GB unified memory");
await expect(page.locator(".hardware-selected")).toContainText("Apple MLX");
await expect(page.locator(".hardware-selected")).toContainText("Usable memory: 96 GB");
await expect(page.locator(".hardware-address-note")).toContainText("not dedicated VRAM");
await expect(page.getByRole("button", { name: "Clear hardware", exact: true })).toBeVisible();
});
test("the directory filters by runtime without a hardware picker", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await expect(page.locator(".recipe-directory .hardware-picker")).toHaveCount(0);
await page.getByLabel("Runtime", { exact: true }).selectOption("mlx");
await expect(page).toHaveURL(/runtime=mlx/);
const card = page.locator(".recipe-card").filter({ hasText: "FastMetal 1.3B" });
await expect(card).toBeVisible();
await expect(card).toHaveAttribute("href", /\/cookbook\/wan\/models\/[^?]+$/);
await expect(page.locator(".recipe-card").filter({ hasText: "FastWan2.1 1.3B" })).toHaveCount(0);
});
test("MLX Omni discovery and its original route expose the MLX launch command", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await page.getByLabel("Runtime", { exact: true }).selectOption("mlx");
await page.getByLabel("Search recipes").fill("FastH3 Omni");
const cards = page.locator(".recipe-card");
await expect(cards).toHaveCount(1);
await expect(cards).toHaveAttribute("href", "/FastVideo/cookbook/minimax-h3/models/fasth3-omniref-mlx");
await cards.click();
await expect(page.getByLabel("Inference command")).toContainText("convert_minimax_h3_mlx.py");
await expect(page.getByLabel("Inference command")).toContainText("--mlx-checkpoint ./FastH3-OmniRef-MLX/int6");
await expect(page.getByLabel("Inference command")).not.toContainText("fastvideo serve");
await page.reload();
await expect(page.getByLabel("Inference command")).toContainText("--mlx-checkpoint ./FastH3-OmniRef-MLX/int6");
});
test("category counts match visible cards and exclude retired Preview", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await expect(page.locator(".recipe-card")).toHaveCount(38);
for (const category of ["All models", "Video", "Image", "Audio", "World"]) {
const button = page.getByRole("button", { name: category, exact: true });
const count = Number(await button.locator(".recipe-filter-count").innerText());
await button.click();
await expect(page.locator(".recipe-card")).toHaveCount(count);
await expect(page.locator(".recipe-card").filter({ hasText: "FastH3 Preview V0.2" })).toHaveCount(0);
}
});
test("task discovery filters the directory and round-trips through the URL", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await page.getByLabel("Task", { exact: true }).selectOption("Image to video");
await expect(page).toHaveURL(/task=Image\+to\+video/);
await expect(page.locator(".recipe-card").filter({ hasText: "Wan2.1 I2V 14B" })).toBeVisible();
await expect(page.locator(".recipe-card").filter({ hasText: "FastWan2.1 1.3B" })).toHaveCount(0);
await page.reload();
await expect(page.getByLabel("Task", { exact: true })).toHaveValue("Image to video");
await page.getByLabel("Task", { exact: true }).selectOption("All tasks");
await expect(page).not.toHaveURL(/task=/);
});
test("directory filters round-trip through the URL", async ({ page }) => {
await page.goto("/FastVideo/cookbook?category=Image&runtime=cuda");
await expect(page.getByRole("button", { name: "Image", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(page.getByLabel("Runtime", { exact: true })).toHaveValue("cuda");
await expect(page.locator(".recipe-card")).toHaveCount(5);
await page.reload();
await expect(page.getByRole("button", { name: "Image", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(page.getByLabel("Runtime", { exact: true })).toHaveValue("cuda");
await expect(page.locator(".recipe-card")).toHaveCount(5);
});
test("a saved target missing from the catalog stays visible and clearable", async ({ page }) => {
await page.goto(`${route}?hardware=retired-gpu`);
await expect(page.getByText(/The saved device \(retired-gpu\) is not in this catalog/)).toBeVisible();
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
await expect(page).not.toHaveURL(/hardware=/);
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
});
test("catalog search renders one plain no-results message", async ({ page }) => {
await page.goto(route);
await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click();
await page.getByLabel("Search hardware", { exact: true }).fill("no-such-device");
await expect(page.getByText("No matching devices.", { exact: true })).toHaveCount(1);
await page.getByRole("button", { name: "Can't find it? Add a device", exact: true }).click();
await expect(page.getByLabel("Device name", { exact: true })).toBeVisible();
});
test("hardware lookup closes with Escape and returns focus", async ({ page }) => {
await page.goto(route);
await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click();
await expect(page.getByLabel("Search hardware", { exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByLabel("Search hardware", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "+ Find or add a GPU", exact: true })).toBeFocused();
});
test("WebSocket deployments expose only the serving interface", async ({ page }) => {
await page.goto("/FastVideo/cookbook/ltx/ltx2-distilled");
const modes = page.getByRole("group", { name: "Run mode" });
await expect(modes.getByRole("button")).toHaveCount(1);
await expect(modes.getByRole("button", { name: "Serving", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(modes.getByRole("button", { name: "Python SDK", exact: true })).toHaveCount(0);
await expect(modes.getByRole("button", { name: "CLI", exact: true })).toHaveCount(0);
await expect(page.getByText(/Only serving is recorded for it/)).toBeVisible();
});
test("video-to-video deployments expose only the serving interface", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/open-h3-regenerate-2k");
const modes = page.getByRole("group", { name: "Run mode" });
await expect(modes.getByRole("button")).toHaveCount(1);
await expect(modes.getByRole("button", { name: "Serving", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(page.getByText(/Send it to the server as video_reference/)).toBeVisible();
});
test("Open-H3-Regenerate-2K is discoverable as a video upscale with its serving page", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await page.getByLabel("Task", { exact: true }).selectOption("Video upscale");
const card = page.locator(".recipe-card").filter({ hasText: "Open-H3-Regenerate-2K" });
await expect(card).toHaveCount(1);
await card.click();
await expect(page).toHaveURL(/cookbook\/minimax-h3\/open-h3-regenerate-2k\/?$/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Open-H3-Regenerate-2K");
});
-256
View File
@@ -1,256 +0,0 @@
import { expect, test } from "@playwright/test";
test("Omni Apple silicon links open the published guide and MLX recipe", async ({ page }) => {
for (const slash of ["", "/"]) {
await page.goto(`/FastVideo/cookbook/minimax-h3/fasth3-omniref${slash}`);
const guide = page.getByRole("link", { name: "FastH3 Omni on MLX", exact: true });
await expect(guide).toHaveAttribute("href", "/FastVideo/inference/fasth3_omniref_mlx");
await guide.click();
await expect(page).toHaveURL(/\/FastVideo\/inference\/fasth3_omniref_mlx\/?$/);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
}
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-omniref");
await page.getByRole("link", { name: "MLX recipe", exact: true }).click();
await expect(page.getByLabel("Inference command")).toContainText("--mlx-checkpoint");
});
test("GitHub stars refresh automatically and survive a failed refresh", async ({ page }) => {
await page.clock.install();
let requests = 0;
await page.route("https://api.github.com/repos/hao-ai-lab/FastVideo", async (route) => {
requests++;
if (requests === 2) return route.fulfill({ status: 503, body: "Unavailable" });
await route.fulfill({ json: { stargazers_count: requests === 1 ? 4612 : 5234 } });
});
await page.goto("/FastVideo");
const stars = page.locator(".home-github-stars");
await expect(stars).toHaveText("4.6k");
await expect(stars).toHaveAccessibleName("FastVideo on GitHub, 4,612 stars");
await page.clock.fastForward(5 * 60 * 1000);
await expect.poll(() => requests).toBe(2);
await expect(stars).toHaveText("4.6k");
await page.clock.fastForward(5 * 60 * 1000);
await expect(stars).toHaveText("5.2k");
});
test("GitHub rate limits leave a usable Star link without a fabricated count", async ({ page }) => {
await page.route("https://api.github.com/repos/hao-ai-lab/FastVideo", (route) =>
route.fulfill({ status: 403, json: { message: "API rate limit exceeded" } }),
);
await page.goto("/FastVideo");
const stars = page.getByRole("link", { name: "Star FastVideo on GitHub", exact: true });
await expect(stars).toHaveText("Star");
await expect(stars).toHaveAttribute("href", "https://github.com/hao-ai-lab/FastVideo");
});
test("documentation navigation opens transferred guides", async ({ page }) => {
await page.goto("/FastVideo");
await expect(page.getByRole("heading", { name: "Welcome to FastVideo", exact: true })).toBeVisible();
await page.locator("#blume-nav-drawer").getByRole("link", { name: "Installation", exact: true }).click();
await expect(page).toHaveURL(/getting_started\/installation/, { timeout: 30000 });
await expect(page.getByRole("heading", { level: 1 })).toBeVisible({ timeout: 30000 });
});
test("the welcome page introduces the project and opens the cookbook", async ({ page }) => {
await page.goto("/FastVideo");
const logo = page.getByRole("heading", { level: 1, name: "Welcome to FastVideo" }).getByRole("img");
await expect(logo).toBeVisible();
expect(await logo.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true);
const column = await page.locator(".home-page").boundingBox();
const image = await logo.boundingBox();
expect(Math.abs(image!.x + image!.width / 2 - (column!.x + column!.width / 2))).toBeLessThan(2);
await expect(page.getByRole("link", { name: "Hao AI Lab", exact: true })).toHaveAttribute(
"href",
"https://haoailab.com/",
);
const lab = page.locator(".home-lab-link img");
expect(await lab.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true);
const trending = page.locator(".home-trendshift a");
await expect(trending).toHaveAttribute(
"href",
"https://trendshift.io/repositories/14509?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-14509",
);
await expect(trending.locator("img")).toHaveAttribute("src", "https://trendshift.io/api/badge/repositories/14509");
await expect(page.locator(".home-news time")).toHaveCount(2);
await expect(page.locator(".home-news")).toContainText("Kandinsky 6");
await expect(page.getByRole("heading", { name: "Projects and research", exact: false })).toBeVisible();
await expect(page.getByRole("link", { name: "DanceGRPO", exact: true })).toBeVisible();
await expect(page.getByText("Use the navigation menu on the left to explore different sections:")).toHaveCount(0);
await expect(page.getByText("About FastVideo and performance overview", { exact: true })).toHaveCount(0);
await expect(page.locator(".home-page > p").first()).toContainText(
"FastVideo is an inference and post-training framework",
);
await page.getByRole("link", { name: /^Cookbook Choose a model recipe/ }).click();
await expect(page).toHaveURL(/FastVideo\/cookbook/);
await expect(page.getByRole("heading", { name: "Cookbook", exact: true })).toBeVisible();
});
test("Fast Models connects the homepage, family collections, and checkpoints", async ({ page }) => {
await page.goto("/FastVideo");
await page.locator(".home-project-links").getByRole("link", { name: "Fast Models", exact: true }).click();
await expect(page).toHaveURL(/FastVideo\/fast-models$/);
await expect(page.getByRole("heading", { level: 1, name: "Fast Models", exact: true })).toBeVisible();
for (const family of ["FastH3", "FastWan", "FastMetal"]) {
await expect(page.getByRole("heading", { level: 2, name: family, exact: false })).toBeVisible();
}
await expect(page.getByRole("link", { name: "FastMetal collection", exact: true })).toHaveAttribute(
"href",
"https://huggingface.co/collections/FastVideo/fastmetal",
);
await expect(page.locator('a[href="https://huggingface.co/FastVideo/FastWan-QAD-FP8-1.3B"]').first()).toBeVisible();
const checkpoints = await page
.locator('a[href^="https://huggingface.co/FastVideo/"]')
.evaluateAll((links) => [...new Set(links.map((link) => link.getAttribute("href")))]);
expect(checkpoints.length).toBeGreaterThanOrEqual(18);
await expect(
page.locator("#blume-nav-drawer").getByRole("link", { name: "Fast Models", exact: true }),
).toHaveAttribute("aria-current", "page");
await page.setViewportSize({ width: 390, height: 844 });
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
});
test("V1 API renders Python signatures within the Blume shell", async ({ page }) => {
await page.goto("/FastVideo/getting_started/v1_api");
await expect(page.getByRole("heading", { name: "V1 API", exact: true })).toBeVisible();
await expect(page.locator('[id="fastvideo.entrypoints.video_generator.VideoGenerator"]')).toBeVisible();
await expect(page.locator(".python-reference")).toContainText("from_pretrained");
for (const description of [
"The inference arguments",
"The executor class to use for inference",
"Whether to log statistics",
"Optional multiprocessing.Queue to forward worker logs to",
]) {
await expect(page.locator(".python-reference")).toContainText(description);
}
await expect(page.getByRole("button", { name: "Search", exact: true })).toBeVisible();
});
test("the API overview resolves Python references and guide links", async ({ page }) => {
await page.goto("/FastVideo/api");
const reference = page.locator(".python-reference");
await expect(reference.getByRole("link", { name: "API contracts and sampling", exact: true })).toHaveAttribute(
"href",
"/FastVideo/api/fastvideo/api#fastvideo.api",
);
await reference.getByRole("link", { name: "fastvideo.VideoGenerator", exact: true }).click();
await expect(page.locator('[id="fastvideo.VideoGenerator"]')).toBeVisible();
await page.goto("/FastVideo/api");
await page.locator(".python-reference").getByRole("link", { name: "V1 API", exact: true }).click();
await expect(page).toHaveURL(/\/FastVideo\/getting_started\/v1_api$/);
await expect(page.getByRole("heading", { name: "V1 API", exact: true })).toBeVisible();
});
test("generated module links stay inside the transferred Python reference", async ({ page }) => {
await page.goto("/FastVideo/api/fastvideo");
await page.getByRole("link", { name: "configs", exact: true }).click();
await expect(page).toHaveURL(/FastVideo\/api\/fastvideo\/configs/);
});
test("search finds a transferred guide and opens it by keyboard", async ({ page }) => {
test.setTimeout(180000);
await page.goto("/FastVideo");
await page.getByRole("button", { name: "Search", exact: true }).click();
const query = page.getByRole("combobox", { name: "Search docs", exact: true });
await query.fill("offloading");
await expect(page.getByRole("option", { name: /^Offloading/ }).first()).toBeVisible({ timeout: 120000 });
await query.press("Enter");
await expect(page).toHaveURL(/FastVideo\/inference\/offloading/, { timeout: 30000 });
});
test("the welcome cards reflow on mobile without horizontal scrolling", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/FastVideo");
await expect(page.getByRole("heading", { name: "Welcome to FastVideo", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: /Quick start Generate/ })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
});
test("the model catalog filters by family and checkpoint name", async ({ page }) => {
await page.goto("/FastVideo/fast-models");
const catalog = page.locator(".model-catalog");
await expect(catalog.getByRole("status")).toHaveText("21 checkpoints");
await page.getByRole("button", { name: "FastMetal", exact: true }).click();
await expect(catalog.getByRole("status")).toHaveText("3 checkpoints");
await page.getByRole("searchbox", { name: "Search checkpoints", exact: true }).fill("14B");
await expect(catalog.getByRole("status")).toHaveText("1 checkpoint");
await page.getByRole("button", { name: "All models", exact: true }).click();
await page.getByRole("searchbox").fill("NVFP4");
const codes = await catalog.locator(".model-checkpoints code").allInnerTexts();
expect(codes.length).toBeGreaterThan(0);
expect(codes.every((text) => text.includes("NVFP4"))).toBe(true);
await expect(catalog.getByText("FastVideo/FastVideo-FastH3-8-Step-V2-FP8", { exact: true })).toHaveCount(0);
await page.getByRole("searchbox").fill("no-such-model");
await expect(catalog.getByRole("status")).toHaveText("0 checkpoints");
await page.getByRole("button", { name: "Clear filters", exact: true }).click();
await expect(catalog.getByRole("status")).toHaveText("21 checkpoints");
});
test("hardware guides are grouped below general inference tasks", async ({ page }) => {
await page.goto("/FastVideo");
const nav = page.locator("#blume-nav-drawer");
await expect(nav.getByRole("link", { name: "Two DGX Sparks", exact: true })).not.toBeVisible();
await nav.getByText("Hardware", { exact: true }).click();
await nav.getByRole("link", { name: "Two DGX Sparks", exact: true }).click();
await expect(page).toHaveURL(/getting_started\/installation\/spark_pair/);
});
test("family runbooks use React model cards without the old cookbook runtime", async ({ page }) => {
await page.goto("/FastVideo/cookbook/wan/");
await expect(page.locator(".recipe-card").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Search", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "← All recipes", exact: true })).toHaveAttribute(
"href",
/\/FastVideo\/cookbook$/,
);
await expect(page.locator('script[src*="/assets/cookbook.js"],link[href*="cookbook-runbooks.css"]')).toHaveCount(0);
});
test("generated example titles render once and retain their original fragment", async ({ page }) => {
await page.goto("/FastVideo/inference/examples/basic");
await expect(page.getByRole("heading", { name: "Basic Video Generation Tutorial", exact: true })).toHaveCount(1);
await expect(page.locator('[id="basic-video-generation-tutorial"]')).toHaveCount(1);
await expect(page.locator("article")).toContainText("examples/inference/basic");
});
test("hardware overview distinguishes runtime support and AMD documentation limits", async ({ page }) => {
await page.goto("/FastVideo/getting_started/hardware");
const table = page.getByRole("table");
for (const platform of ["RTX", "data center", "DGX Spark", "Apple Silicon", "AMD"]) {
await expect(table).toContainText(platform);
}
await page.getByRole("link", { name: "ROCm support and current limits", exact: true }).click();
await expect(page.getByRole("heading", { level: 1 })).toHaveText("AMD and ROCm");
await expect(page.locator("article")).toContainText("not currently documented");
});
test("homepage project buttons have visible borders in dark mode", async ({ page }) => {
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("/FastVideo");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
const colors = await page.locator(".home-project-links a").evaluateAll((links) =>
links.map((link) => {
const style = getComputedStyle(link);
return {
border: style.borderTopColor,
width: style.borderTopWidth,
background: getComputedStyle(document.body).backgroundColor,
};
}),
);
expect(colors).toHaveLength(3);
for (const color of colors) {
expect(color.width).toBe("1px");
const luminance = (value: string) => {
const channels = value
.match(/[\d.]+/g)!
.slice(0, 3)
.map(Number)
.map((channel) => (value.startsWith("color(srgb") ? channel : channel / 255))
.map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
};
const border = luminance(color.border);
const background = luminance(color.background);
expect((Math.max(border, background) + 0.05) / (Math.min(border, background) + 0.05)).toBeGreaterThanOrEqual(3);
}
});
@@ -1,17 +0,0 @@
# Hardware catalog picker evidence
Captured 2026-10-19 against `blume build` + `blume preview` on Chromium 1248.
Third-party requests were aborted. The test fixture mirrors the generated
`capacity/hardware.json` shape (`{version, devices:[...]}`) with representative
NVIDIA and Apple entries; production data comes from Lane A's catalog.
| State | Capture |
| -------------------------------------------------------------------- | ---------------------------- |
| Directory filtered to Apple MLX with a selected M4 Max target | `after/directory-m4-max.png` |
| Model page with a selected unified-memory device and its lookup open | `after/model-unified.png` |
| Lookup with no matching devices | `after/no-results.png` |
| Narrow lookup | `after/narrow-lookup.png` |
| Directory task filter (image to video) | `after/directory-task.png` |
The captures show one selected target, separate dedicated/unified wording, and
one plain empty-state line. They are not memory-fit or performance evidence.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

@@ -1,15 +0,0 @@
# Cookbook UI evidence
Captured 2026-10-19 against the local production build (`blume build`) served by
`blume preview --host 127.0.0.1 --port 4327`, Playwright Chromium headless shell
1248, viewport 1440×1000. Third-party requests were aborted; no product behavior
depends on them.
| Before | After | What changed |
| ------------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `before/directory-desktop.png` | `after/directory-desktop.png` | Command labels and source/model entry points; no preselected hardware. |
| `before/fasth3-desktop.png` | `after/fasth3-desktop.png` | Hardware is explicitly unset, the component preview is gone, and fit/evidence facts sit at the hardware decision point instead of above the command. |
Narrow, dark, and MLX states were checked locally but are not committed. The
interaction tests cover those states, copied YAML/commands, reload persistence,
and the unselected/default behavior.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Some files were not shown because too many files have changed in this diff Show More