Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61ebcbb635 |
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 -- \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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:
|
||||
|
||||
# ============================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}')"
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() : {};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 . .
|
||||
|
||||
|
||||
@@ -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__)"
|
||||
@@ -1,9 +0,0 @@
|
||||
node_modules/
|
||||
.blume/
|
||||
dist/
|
||||
.env.local
|
||||
public/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.generated/
|
||||
.venv/
|
||||
@@ -1,10 +0,0 @@
|
||||
node_modules
|
||||
.blume
|
||||
dist
|
||||
public
|
||||
package-lock.json
|
||||
test-results
|
||||
playwright-report
|
||||
.generated
|
||||
.venv
|
||||
migration-anchors.json
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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`.
|
||||
|
Before Width: | Height: | Size: 7.4 KiB |
@@ -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 } },
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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" />
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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&utm_medium=badge&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>
|
||||
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 62 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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",
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
interface ImportMeta {
|
||||
readonly env: { readonly BASE_URL: string };
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
Before Width: | Height: | Size: 58 KiB |
@@ -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));
|
||||
}
|
||||
@@ -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("[", "[").replace("]", "]")
|
||||
|
||||
|
||||
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()
|
||||
@@ -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, "<")
|
||||
.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("{", "{").replaceAll("}", "}")}${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.`,
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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("&", "&").replaceAll('"', """);
|
||||
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"));
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
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.
|
||||
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 142 KiB |