Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
910ee7d6bb | ||
|
|
7490986ec8 | ||
|
|
b466e3cde4 | ||
|
|
cf3ea82e74 | ||
|
|
8304915b8f | ||
|
|
6743f4b4b7 | ||
|
|
465654d900 | ||
|
|
69b3829293 | ||
|
|
a0e6cf1f50 | ||
|
|
0d05b09c57 | ||
|
|
a92c5c1578 | ||
|
|
dfc5550c35 | ||
|
|
8db2d71ef2 | ||
|
|
1a1e7216d2 | ||
|
|
33bdd2528b | ||
|
|
9bbdd18548 | ||
|
|
7c2b447264 | ||
|
|
e136c2b7f8 | ||
|
|
fa2d1324c6 | ||
|
|
cdda1b7535 | ||
|
|
089a5a55a0 | ||
|
|
137d9110ca | ||
|
|
d4f144581d | ||
|
|
5f96664abf | ||
|
|
376e445f0e | ||
|
|
2099dd5533 | ||
|
|
0033a9b11d | ||
|
|
91a1c50909 | ||
|
|
3658b120da | ||
|
|
81b73f6b88 | ||
|
|
d4ed61cc7c | ||
|
|
377c79fa7e | ||
|
|
25940a7e86 | ||
|
|
3f45f2da21 | ||
|
|
6212e7262e | ||
|
|
ba371e33ed | ||
|
|
06c8c5f4b8 |
@@ -1,15 +1,15 @@
|
||||
---
|
||||
name: ci-runner
|
||||
description: Work on FastVideo's Slurm-only, change-aware GPU CI lanes, static Buildkite graph, trusted ci-runner policy, lane scripts, and GB200 validation.
|
||||
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.
|
||||
---
|
||||
|
||||
# Slinky Slurm CI lanes
|
||||
# GPU CI lanes
|
||||
|
||||
FastVideo's `ci-runner` Buildkite queue is the control plane for all active
|
||||
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.
|
||||
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.
|
||||
|
||||
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,7 +26,21 @@ 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"`.
|
||||
`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.
|
||||
- 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
|
||||
@@ -45,8 +59,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 schedule run the complete
|
||||
SSIM matrix.
|
||||
explicit `/test full`, and the weekly main-branch Full Suite
|
||||
(`ci-scheduled-full-suite.yml`) 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
|
||||
@@ -58,7 +72,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 ARM64 runner image includes the pinned FA4 CuTe overlay validated on
|
||||
- The Slinky 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,6 +23,8 @@ 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.
|
||||
@@ -47,6 +49,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":microscope: Unit Tests"
|
||||
key: "unit"
|
||||
@@ -70,6 +73,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":microscope: Kernel Tests"
|
||||
key: "kernel-tests"
|
||||
@@ -93,6 +97,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":microscope: DreamVerse App Tests"
|
||||
key: "dreamverse"
|
||||
@@ -116,6 +121,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":microscope: Encoder Tests"
|
||||
key: "encoder"
|
||||
@@ -139,6 +145,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":microscope: VAE Tests"
|
||||
key: "vae"
|
||||
@@ -162,6 +169,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":microscope: Transformer Tests"
|
||||
key: "transformer"
|
||||
@@ -185,6 +193,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":bar_chart: SSIM Tests"
|
||||
key: "ssim"
|
||||
@@ -212,6 +221,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "slurm"
|
||||
|
||||
- label: ":test_tube: LoRA Inference Tests"
|
||||
key: "lora-inference"
|
||||
@@ -234,6 +244,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":test_tube: LoRA Extraction Tests"
|
||||
key: "lora-extraction"
|
||||
@@ -256,6 +267,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":test_tube: Training Tests"
|
||||
key: "training"
|
||||
@@ -280,6 +292,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "slurm"
|
||||
|
||||
- label: ":test_tube: Distillation DMD Tests"
|
||||
key: "distillation"
|
||||
@@ -302,6 +315,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "slurm"
|
||||
|
||||
- label: ":test_tube: Self-Forcing Tests"
|
||||
key: "self-forcing"
|
||||
@@ -324,6 +338,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "slurm"
|
||||
|
||||
- label: ":test_tube: LoRA Training Tests"
|
||||
key: "lora-training"
|
||||
@@ -348,6 +363,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "slurm"
|
||||
|
||||
- label: ":test_tube: Training Tests VSA"
|
||||
key: "training-vsa"
|
||||
@@ -372,6 +388,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "slurm"
|
||||
|
||||
- label: ":test_tube: Inference Tests VMoBA"
|
||||
key: "inference-vmoba"
|
||||
@@ -394,6 +411,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":test_tube: Performance Tests"
|
||||
key: "performance"
|
||||
@@ -416,6 +434,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "slurm"
|
||||
|
||||
- label: ":test_tube: API Server Tests"
|
||||
key: "api-server"
|
||||
@@ -438,6 +457,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":test_tube: Train Framework Tests"
|
||||
key: "train-framework"
|
||||
@@ -460,6 +480,7 @@ steps:
|
||||
limit: 2
|
||||
agents:
|
||||
queue: "ci-runner"
|
||||
backend: "${FV_SPARK_BACKEND:-slurm}"
|
||||
|
||||
- label: ":test_tube: Eval Metrics Tests"
|
||||
key: "eval"
|
||||
@@ -482,3 +503,4 @@ 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'
|
||||
--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'
|
||||
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 python fastvideo/tests/inference/vmoba/test_vmoba_inference.py
|
||||
exec pytest ./fastvideo/tests/inference/vmoba/test_vmoba_inference.py -vs
|
||||
|
||||
@@ -9,9 +9,12 @@ mkdir -p "$PERF_REPORTS_DIR"
|
||||
|
||||
if [[ ${BUILDKITE_PULL_REQUEST:-false} =~ ^[1-9][0-9]*$ ]]; then
|
||||
export PERF_RUN_SOURCE=pr
|
||||
export PERF_UPLOAD_POLICY=pass
|
||||
export PERF_UPLOAD_POLICY=never
|
||||
elif [ "${BUILDKITE_BRANCH:-}" = main ] \
|
||||
&& { [ "${BUILDKITE_SOURCE:-}" = schedule ] || [ "${TEST_SCOPE:-}" = full ]; }; then
|
||||
&& { [ "${BUILDKITE_SOURCE:-}" = schedule ] || [ "${TEST_SCOPE:-}" = full ] \
|
||||
|| { [ "${BUILDKITE_SOURCE:-}" = api ] \
|
||||
&& [ "${BUILDKITE_PIPELINE_SLUG:-}" = fastvideo-performance-lane ] \
|
||||
&& [ "${TEST_SCOPE:-}" = direct ]; }; }; then
|
||||
export PERF_RUN_SOURCE=scheduled_main
|
||||
export PERF_UPLOAD_POLICY=always
|
||||
elif [ "${TEST_SCOPE:-}" = direct ]; then
|
||||
|
||||
@@ -31,6 +31,7 @@ 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,6 +9,28 @@ 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/**",
|
||||
"mkdocs.yml",
|
||||
"requirements-mkdocs.in",
|
||||
"requirements-mkdocs.txt",
|
||||
"docs-site/**",
|
||||
"requirements-docs.in",
|
||||
"requirements-docs.txt",
|
||||
"scripts/**",
|
||||
"tests/__init__.py",
|
||||
"tests/local_tests/**",
|
||||
@@ -131,6 +131,11 @@ 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", ),
|
||||
@@ -321,7 +326,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-mkdocs.in", "requirements-mkdocs.txt"}:
|
||||
if path in {"requirements-docs.in", "requirements-docs.txt"}:
|
||||
plan.reasons.append(f"documentation dependencies need no GPU integration: {path}")
|
||||
continue
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ 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"
|
||||
@@ -85,9 +88,19 @@ 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 \
|
||||
@@ -150,9 +163,19 @@ 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,4 +1,4 @@
|
||||
name: Scheduled Full SSIM
|
||||
name: Scheduled Full Suite
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
if: github.repository == 'hao-ai-lab/FastVideo'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger weekly full SSIM on Slinky Slurm
|
||||
- name: Trigger weekly full suite 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 SSIM on Slinky Slurm",
|
||||
message: "Weekly full suite on main",
|
||||
ignore_pipeline_branch_filters: true,
|
||||
env: {
|
||||
TEST_SCOPE: "scheduled",
|
||||
FULL_SUITE: "false",
|
||||
TEST_TYPE: "ssim",
|
||||
TEST_SCOPE: "full",
|
||||
FULL_SUITE: "true",
|
||||
TEST_TYPE: "all",
|
||||
PR_NUMBER: "false",
|
||||
PR_TITLE: "Scheduled full SSIM"
|
||||
PR_TITLE: "Scheduled full suite"
|
||||
}
|
||||
}')"
|
||||
@@ -0,0 +1,73 @@
|
||||
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,34 +0,0 @@
|
||||
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,7 +56,9 @@ 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 (sm_121).
|
||||
# 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.
|
||||
# 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.
|
||||
@@ -78,6 +80,12 @@ 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'
|
||||
@@ -85,7 +93,17 @@ jobs:
|
||||
cmake_build_parallel_level: '1'
|
||||
torch_cuda_arch_list:
|
||||
amd64: '9.0a'
|
||||
arm64: '12.1'
|
||||
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'
|
||||
architecture:
|
||||
- name: amd64
|
||||
runner: ubuntu-latest
|
||||
@@ -105,6 +123,8 @@ 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,9 +7,11 @@ on:
|
||||
- 'docs/**'
|
||||
- 'examples/**'
|
||||
- 'scripts/inference/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-mkdocs.in'
|
||||
- 'requirements-mkdocs.txt'
|
||||
- 'docs-site/**'
|
||||
- 'fastvideo/**'
|
||||
- 'README.md'
|
||||
- 'requirements-docs.in'
|
||||
- 'requirements-docs.txt'
|
||||
- 'scripts/check_docs_links.py'
|
||||
- '.github/workflows/infra-docs.yml'
|
||||
pull_request:
|
||||
@@ -18,9 +20,11 @@ on:
|
||||
- 'docs/**'
|
||||
- 'examples/**'
|
||||
- 'scripts/inference/**'
|
||||
- 'mkdocs.yml'
|
||||
- 'requirements-mkdocs.in'
|
||||
- 'requirements-mkdocs.txt'
|
||||
- 'docs-site/**'
|
||||
- 'fastvideo/**'
|
||||
- 'README.md'
|
||||
- 'requirements-docs.in'
|
||||
- 'requirements-docs.txt'
|
||||
- 'scripts/check_docs_links.py'
|
||||
- '.github/workflows/infra-docs.yml'
|
||||
|
||||
@@ -35,6 +39,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -51,7 +56,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@v3
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv pip install --system -r requirements-mkdocs.txt
|
||||
run: uv pip install --system -r requirements-docs.txt
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
@@ -59,10 +64,16 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
docs/package-lock.json
|
||||
docs-site/package-lock.json
|
||||
|
||||
- name: Install cookbook dependencies
|
||||
run: npm ci --prefix docs
|
||||
run: |
|
||||
npm ci --prefix docs
|
||||
npm ci --prefix docs-site
|
||||
|
||||
- name: Validate and generate cookbook catalogs
|
||||
run: |
|
||||
@@ -71,8 +82,25 @@ jobs:
|
||||
python docs/tests/test_cookbook_assets.py
|
||||
npm run build:catalog --prefix docs
|
||||
|
||||
- name: Build documentation
|
||||
run: mkdocs build
|
||||
- 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: Check docs links
|
||||
run: python scripts/check_docs_links.py
|
||||
@@ -80,7 +108,15 @@ jobs:
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./site
|
||||
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
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
|
||||
@@ -52,9 +52,8 @@ dist/
|
||||
eggs/
|
||||
.eggs/
|
||||
|
||||
# MkDocs documentation
|
||||
# Generated documentation
|
||||
site/
|
||||
docs/assets/cookbook-serving.json
|
||||
docs/assets/cookbook-config/
|
||||
examples/serving/clients/node_modules/
|
||||
docs/node_modules/
|
||||
@@ -63,7 +62,6 @@ docs/examples/
|
||||
docs/inference/examples/
|
||||
docs/training/examples/
|
||||
docs/distillation/examples/
|
||||
!requirements-mkdocs.txt
|
||||
|
||||
# VSCode
|
||||
.vscode/
|
||||
|
||||
@@ -21,6 +21,7 @@ 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: `docs/` (MkDocs source), with contributor docs in `docs/contributing/`.
|
||||
- Docs and guides: canonical Markdown in `docs/`, the Blume app in `docs-site/`, and 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_SM100A": "0",
|
||||
"FASTVIDEO_VSA_PTX_BLACKWELL": "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_SM100A", "1")
|
||||
monkeypatch.setenv("FASTVIDEO_VSA_PTX_BLACKWELL", "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_SM100A"] == "0"
|
||||
assert os.environ["FASTVIDEO_VSA_PTX_BLACKWELL"] == "0"
|
||||
assert "FASTVIDEO_INFERENCE_TORCH_COMPILE" not in os.environ
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,37 @@ 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 flex flex-1 min-h-0 flex-col px-4 pb-2 sm:px-6 sm:pb-12",
|
||||
"relative isolate 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,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -19,6 +20,7 @@ export default function LeaveSessionModal({
|
||||
onConfirmLeave = () => {},
|
||||
}: LeaveSessionModalProps) {
|
||||
const [suppress, setSuppress] = useState(false);
|
||||
const previousFocus = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSuppress(false);
|
||||
@@ -36,43 +38,58 @@ export default function LeaveSessionModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</DialogPrimitive.Overlay>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 sm100a route needs fastvideo-kernel built with the Blackwell VSA
|
||||
**`FastH3's ptx_blackwell 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,6 +193,14 @@ 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.
|
||||
|
||||
@@ -201,11 +209,11 @@ class FastH3Backend:
|
||||
experiment settings cannot silently change what gets served.
|
||||
"""
|
||||
cfg = self._config.inference
|
||||
vsa_kernel = str(cfg.get("vsa_kernel", "sm100a"))
|
||||
vsa_kernel = self._vsa_kernel()
|
||||
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_SM100A": "1" if vsa_kernel == "sm100a" else "0",
|
||||
"FASTVIDEO_VSA_PTX_BLACKWELL": "1" if vsa_kernel == "ptx_blackwell" else "0",
|
||||
"FASTVIDEO_VSA_CUTEDSL": "0",
|
||||
# A non-empty path enables the diagnostic probe; it must stay unset.
|
||||
"FASTVIDEO_H3_VSA_PROBE": None,
|
||||
@@ -241,15 +249,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 str(cfg.get("vsa_kernel", "sm100a")) == "sm100a":
|
||||
if self._vsa_kernel() == "ptx_blackwell":
|
||||
try:
|
||||
from fastvideo_kernel import block_sparse_attn_sm100a
|
||||
from fastvideo_kernel import block_sparse_attn_ptx_blackwell
|
||||
except ImportError:
|
||||
present = False
|
||||
else:
|
||||
present = bool(getattr(block_sparse_attn_sm100a, "_HAS_VSA_SM100A", False))
|
||||
present = bool(getattr(block_sparse_attn_ptx_blackwell, "_HAS_VSA_PTX_BLACKWELL", False))
|
||||
if not present:
|
||||
raise RuntimeError("FastH3's sm100a route needs fastvideo-kernel built with the Blackwell VSA "
|
||||
raise RuntimeError("FastH3's ptx_blackwell 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
|
||||
# sm100a is the Blackwell VSA kernel; `triton` is the ~2.5x slower fallback.
|
||||
vsa_kernel: sm100a
|
||||
# ptx_blackwell is the Blackwell VSA kernel; `triton` is the ~2.5x slower fallback.
|
||||
vsa_kernel: ptx_blackwell
|
||||
fa4: true
|
||||
h3_fusions: true
|
||||
inference_torch_compile: true
|
||||
|
||||
@@ -20,10 +20,12 @@
|
||||
# --build-arg FLASH_ATTN_WHEEL_TAG=cu130torch2.12 \
|
||||
# -t fastvideo-dev:py3.12-cuda13.0.0 .
|
||||
#
|
||||
# # DGX Spark / GB10 (aarch64, Blackwell sm_121): the matching arm64
|
||||
# # DGX Spark / GB10 (aarch64, Blackwell sm_121a): 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.1 -t fastvideo-dev:spark .
|
||||
# --build-arg TORCH_CUDA_ARCH_LIST=12.1a \
|
||||
# --build-arg INSTALL_FLASH_ATTN=0 --build-arg INSTALL_FA4_CUTE=0 \
|
||||
# -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).
|
||||
@@ -45,7 +47,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.1
|
||||
# matching the released kernel wheel; override e.g. TORCH_CUDA_ARCH_LIST=12.1a
|
||||
# for GB10/Blackwell (DGX Spark).
|
||||
ARG TORCH_CUDA_ARCH_LIST=9.0a
|
||||
|
||||
@@ -60,6 +62,9 @@ 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
|
||||
@@ -69,6 +74,11 @@ 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
|
||||
@@ -172,7 +182,9 @@ RUN --mount=type=cache,target=/opt/uv/cache \
|
||||
exit 1 \
|
||||
;; \
|
||||
esac && \
|
||||
if [ -n "${FLASH_ATTN_WHEEL_TAG}" ]; then \
|
||||
if [ "${INSTALL_FLASH_ATTN}" != "1" ]; then \
|
||||
echo "Skipping flash-attn (INSTALL_FLASH_ATTN=${INSTALL_FLASH_ATTN})"; \
|
||||
elif [ -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 \
|
||||
@@ -191,12 +203,24 @@ 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')]" && \
|
||||
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')"
|
||||
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
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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__)"
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
.blume/
|
||||
dist/
|
||||
.env.local
|
||||
public/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.generated/
|
||||
.venv/
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.blume
|
||||
dist
|
||||
public
|
||||
package-lock.json
|
||||
test-results
|
||||
playwright-report
|
||||
.generated
|
||||
.venv
|
||||
migration-anchors.json
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,134 @@
|
||||
# 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`.
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,30 @@
|
||||
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 } },
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
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,
|
||||
purpose = "target",
|
||||
reload,
|
||||
}: {
|
||||
backend: string;
|
||||
catalog: HardwareRecord[];
|
||||
status: HardwareCatalogState["status"];
|
||||
error?: string;
|
||||
value: HardwareSelection;
|
||||
onChange: (selection: HardwareSelection) => void;
|
||||
gpuCount?: number;
|
||||
purpose?: "target" | "filter";
|
||||
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 = !!value.id && value.id !== "custom" && !record;
|
||||
const candidates =
|
||||
purpose === "filter" ? catalog : 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">
|
||||
{value.id === "custom" ? "Your budget" : "Usable budget"}: {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 {purpose}
|
||||
</button>
|
||||
</div>
|
||||
) : value.id === "custom" ? (
|
||||
<p className="hardware-current" role="status">
|
||||
Add this device's memory to finish selecting it.
|
||||
</p>
|
||||
) : unknown ? (
|
||||
<p className="hardware-current" role="status">
|
||||
Saved hardware target ({value.id}) is not in this catalog.
|
||||
</p>
|
||||
) : (
|
||||
<p className="hardware-current" role="status">
|
||||
No hardware {purpose} 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 {purpose}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="hardware-add"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => {
|
||||
setOpen(!open);
|
||||
setAdding(false);
|
||||
}}
|
||||
>
|
||||
{record
|
||||
? "Change hardware"
|
||||
: purpose === "filter"
|
||||
? "Choose hardware"
|
||||
: backend === "mlx"
|
||||
? "+ Find or add a Mac"
|
||||
: "+ Find or add a GPU"}
|
||||
</button>
|
||||
</div>
|
||||
{record || open ? (
|
||||
<p className="hardware-picker-caption">
|
||||
{purpose === "filter"
|
||||
? "Filters by runtime. Memory fit is not estimated."
|
||||
: "Single-GPU devices set GPU count to 1. Nothing else in the configuration changes."}
|
||||
</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">Dedicated GPU memory (VRAM)</option>
|
||||
<option value="unified">Unified memory (CPU + GPU)</option>
|
||||
<option value="system">System memory (host RAM)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="hardware-form-field">
|
||||
<span>Nominal 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 budget (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">The usable budget cannot exceed the nominal memory.</p>
|
||||
) : null}
|
||||
<p className="hardware-form-note">
|
||||
Nominal memory is device capacity, not what FastVideo can use. A budget is your own limit and is not
|
||||
verified. Unified memory is shared with the CPU; it is not dedicated VRAM.
|
||||
</p>
|
||||
<div className="hardware-form-actions">
|
||||
<button type="submit" disabled={!validDraft}>
|
||||
Use this device
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAdding(false);
|
||||
setDraft(draftFrom(value.custom, backend));
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export type Validity = "valid" | "invalid" | "loading";
|
||||
export type Fit = "fits" | "tight" | "exceeds" | "unsupported" | "unknown";
|
||||
export type Evidence = "recorded" | "none" | "stale";
|
||||
|
||||
const validityLabel: Record<Validity, string> = {
|
||||
valid: "Configuration valid",
|
||||
invalid: "Configuration invalid",
|
||||
loading: "Checking configuration",
|
||||
};
|
||||
const fitLabel: Record<Fit, string> = {
|
||||
fits: "Fits estimate",
|
||||
tight: "Tight fit",
|
||||
exceeds: "Likely exceeds memory",
|
||||
unsupported: "Unsupported combination",
|
||||
unknown: "Fit unknown",
|
||||
};
|
||||
const evidenceLabel: Record<Evidence, string> = {
|
||||
recorded: "Run evidence available",
|
||||
none: "No recorded run",
|
||||
stale: "Run evidence does not match these settings",
|
||||
};
|
||||
|
||||
/**
|
||||
* Three separate facts: the recipe schema accepts the values, a memory estimate
|
||||
* covers the configuration, and a recorded run matches it. No positive claim is
|
||||
* made until the shared API supplies the corresponding fact.
|
||||
*/
|
||||
export default function RecipeFacts({
|
||||
validity,
|
||||
fit = "unknown",
|
||||
evidence = "none",
|
||||
subject = "configuration",
|
||||
detail,
|
||||
validityText,
|
||||
validityDetail,
|
||||
}: {
|
||||
validity: Validity;
|
||||
fit?: Fit;
|
||||
evidence?: Evidence;
|
||||
subject?: "configuration" | "example";
|
||||
detail?: string;
|
||||
validityText?: string;
|
||||
validityDetail?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const id = `recipe-facts-${validity}-${fit}-${evidence}`;
|
||||
return (
|
||||
<div className="recipe-facts">
|
||||
<div className="recipe-fact-list" aria-label="Recipe facts">
|
||||
<span data-state={validity}>
|
||||
{validity === "valid" ? (validityText ?? `Valid ${subject}`) : validityLabel[validity]}
|
||||
</span>
|
||||
<span data-state={fit}>{fitLabel[fit]}</span>
|
||||
<span data-state={evidence}>{evidenceLabel[evidence]}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="recipe-facts-toggle"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
{open && (
|
||||
<div className="recipe-facts-detail" id={id}>
|
||||
<p>
|
||||
<strong>{validityLabel[validity]}.</strong>{" "}
|
||||
{validityDetail ??
|
||||
(validity === "valid"
|
||||
? "Selected values pass the recipe schema and runtime topology checks."
|
||||
: validity === "invalid"
|
||||
? "Output is withheld until the highlighted fields are corrected."
|
||||
: "Values are still being checked.")}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{fitLabel[fit]}.</strong>{" "}
|
||||
{fit === "unknown"
|
||||
? "No memory estimate covers this deployment, device, and request yet. This is not a fit claim."
|
||||
: "The shared capacity API supplied this estimate; it is not a measured guarantee."}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{evidenceLabel[evidence]}.</strong>{" "}
|
||||
{evidence === "none"
|
||||
? "Successful runs on other checkpoints, settings, software revisions, or GPU layouts do not establish this configuration."
|
||||
: evidence === "stale"
|
||||
? "A recorded run exists, but edited settings fall outside what it covers."
|
||||
: "A recorded run matches the deployment and settings shown."}
|
||||
</p>
|
||||
{detail && <p>{detail}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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 };
|
||||
@@ -0,0 +1,61 @@
|
||||
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 };
|
||||
@@ -0,0 +1,163 @@
|
||||
"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,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
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 };
|
||||
@@ -0,0 +1,17 @@
|
||||
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 };
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: Cookbook
|
||||
description: Recipes for video, image, audio, and world generation.
|
||||
mode: wide
|
||||
---
|
||||
|
||||
<a id="inference-cookbook"></a>
|
||||
<a id="planned-families-heading"></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>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: LTX2 Distilled
|
||||
description: Configure incremental video delivery with the native WebSocket server.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="ltx2-distilled" />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: FastH3 4-Step V1
|
||||
description: Configure FastH3 4-Step V1 and copy inference or serving commands.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="fasth3-4step" />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: FastH3 8-Step V2
|
||||
description: Configure distilled video generation with synchronized audio on CUDA or Apple Silicon.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="fasth3-8step" />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: FastH3 OmniRef
|
||||
description: Configure FastH3 OmniRef reference-to-video serving on an RTX 5090, RTX PRO 6000, or DGX Spark.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="fasth3-omniref" />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: FastH3 Preview V0.2
|
||||
description: Configure FastH3 Preview V0.2 and copy inference or serving commands.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="fasth3-preview" />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: FastH3 Trim
|
||||
description: Configure FastH3 Trim NVFP4 and copy inference or serving commands.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="fasth3-trim" />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: PAN 2
|
||||
description: Configure text-to-video and first-frame image-to-video generation with PAN 2.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="pan2" />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: FastWan2.1 1.3B
|
||||
description: Configure distilled text-to-video generation on CUDA.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="fastwan21" />
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Wan2.1 I2V 14B
|
||||
description: Configure image-to-video generation with a reference image.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="wan21-i2v" />
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
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).
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
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>
|
||||
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,114 @@
|
||||
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} ${group.summary} ${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>Find the export your runtime needs.</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, precision, or runtime…"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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.recipes.some((recipe) => recipe.family === family) ||
|
||||
(family === "minimax_h3" && model.family === "FastH3"),
|
||||
);
|
||||
return (
|
||||
<div className="recipe-directory not-prose">
|
||||
<a className="recipe-back" href={sitePath("/cookbook")}>
|
||||
← All models
|
||||
</a>
|
||||
<div className="recipe-card-grid">
|
||||
{models.map((model) => (
|
||||
<a className="recipe-card" 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
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="This page checks that the YAML parses and that edited numeric fields are finite and supported by the example. It does not run the full recipe schema or runtime topology validation."
|
||||
fit="unknown"
|
||||
evidence="none"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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",
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
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-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-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-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 OmniRef",
|
||||
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: "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: "pan2",
|
||||
family: "PAN 2",
|
||||
title: "PAN 2",
|
||||
mark: "PAN",
|
||||
path: "/cookbook/pan2/pan2",
|
||||
description: "Text-to-video and first-frame image-to-video from the 29B PAN 2 model.",
|
||||
tags: ["Text or image to video", "29B"],
|
||||
},
|
||||
{
|
||||
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" },
|
||||
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" },
|
||||
pan2: { title: "PAN 2", path: "pan2", logo: "ifm", 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",
|
||||
"FastVideo/FastWan2.1-T2V-1.3B-Diffusers": "fastwan21",
|
||||
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers": "wan21-i2v",
|
||||
"FastVideo/LTX2-Distilled-Diffusers": "ltx2-distilled",
|
||||
"IFM/PAN2": "pan2",
|
||||
};
|
||||
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/, "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";
|
||||
}
|
||||
|
||||
export const modelDirectory = [...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",
|
||||
"wan-ai": "Wan AI",
|
||||
ltx: "Lightricks",
|
||||
"tencent-hunyuan": "Tencent",
|
||||
kandinsky: "Kandinsky Lab",
|
||||
"black-forest-labs": "Black Forest Labs",
|
||||
qwen: "Qwen",
|
||||
ifm: "IFM",
|
||||
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,
|
||||
featured: backend?.featured ?? false,
|
||||
};
|
||||
})
|
||||
.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];
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
interface ImportMeta {
|
||||
readonly env: { readonly BASE_URL: string };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,352 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
[
|
||||
{
|
||||
"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": "PAN 2 text/image to video",
|
||||
"href": "/inference/pan2"
|
||||
},
|
||||
{
|
||||
"label": "Examples",
|
||||
"href": "/inference/examples/examples_inference_index"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Cookbook",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{
|
||||
"label": "All recipes",
|
||||
"href": "/cookbook"
|
||||
},
|
||||
{
|
||||
"label": "H3 server and playground",
|
||||
"href": "/cookbook/openai-api"
|
||||
},
|
||||
{
|
||||
"label": "MiniMax H3",
|
||||
"display": "group",
|
||||
"collapsed": false,
|
||||
"items": [
|
||||
{
|
||||
"label": "MiniMax H3",
|
||||
"href": "/cookbook/minimax-h3/models/minimax-h3-t2v"
|
||||
},
|
||||
{
|
||||
"label": "FastH3 OmniRef",
|
||||
"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 Preview V0.2",
|
||||
"href": "/cookbook/minimax-h3/fasth3-preview"
|
||||
},
|
||||
{
|
||||
"label": "FastH3 4-Step V1",
|
||||
"href": "/cookbook/minimax-h3/fasth3-4step"
|
||||
},
|
||||
{
|
||||
"label": "Recipes and setup",
|
||||
"href": "/cookbook/minimax-h3"
|
||||
},
|
||||
{
|
||||
"label": "Checkpoint schedules",
|
||||
"href": "/inference/fasth3-distilled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Wan",
|
||||
"display": "group",
|
||||
"collapsed": false,
|
||||
"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": "PAN 2",
|
||||
"display": "group",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{
|
||||
"label": "PAN 2",
|
||||
"href": "/cookbook/pan2/pan2"
|
||||
},
|
||||
{
|
||||
"label": "Family guide",
|
||||
"href": "/cookbook/pan2"
|
||||
},
|
||||
{
|
||||
"label": "Model reference",
|
||||
"href": "/inference/pan2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "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"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,38 @@
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,373 @@
|
||||
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";
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
// Native callout directives and islands compile as MDX, while source links keep .md filenames.
|
||||
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);
|
||||
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 = readme.match(/^## NEWS\n([\s\S]*?)(?=^##|^###)/m)?.[1];
|
||||
const projects = readme.match(/^## Awesome work using FastVideo or our research projects\n([\s\S]*?)(?=^## )/m)?.[1];
|
||||
if (!news || !projects) throw new Error("The README news or project section boundary needs updating.");
|
||||
const recent = [...news.matchAll(/^- `([\d/]+)`: (.+)$/gm)].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.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
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 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"));
|
||||
});
|
||||
@@ -0,0 +1,668 @@
|
||||
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 OmniRef");
|
||||
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("Valid configuration", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Fit unknown", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("No recorded run", { 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("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("PAN 2 has a family card with the IFM logo and an editable serving deployment", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook");
|
||||
await page.getByLabel("Search recipes").fill("PAN 2");
|
||||
const pan2 = page.locator(".recipe-card");
|
||||
await expect(pan2).toHaveCount(1);
|
||||
await expect(pan2.locator("img")).toHaveAttribute("src", /\/ifm\.webp$/);
|
||||
await expect(pan2.locator(".recipe-card-kicker")).toHaveText("IFM");
|
||||
await pan2.click();
|
||||
await expect(page).toHaveURL(/cookbook\/pan2\/pan2$/);
|
||||
await expect(page.locator(".recipe-matrix")).toBeVisible({ timeout: 30000 });
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("PAN 2");
|
||||
await settings(page, "Serving");
|
||||
await page.getByLabel("Port", { exact: true }).fill("9010");
|
||||
await output(page, "config.yaml");
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText(/"port": 9010/);
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText(/"model_path": "IFM\/PAN2"/);
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText(/"dit_layerwise": false/);
|
||||
await expect(page.getByRole("button", { name: "Download YAML", exact: false })).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: "Nominal memory", exact: true }).fill("24");
|
||||
await page.getByRole("spinbutton", { name: "Usable budget (optional)", exact: true }).fill("32");
|
||||
await expect(page.getByText("The usable budget cannot exceed the nominal memory.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Use this device", exact: true })).toBeDisabled();
|
||||
await page.getByRole("spinbutton", { name: "Usable budget (optional)", exact: true }).fill("22");
|
||||
await page.getByRole("spinbutton", { name: "Host RAM (optional)", exact: true }).fill("64");
|
||||
await page.getByRole("button", { name: "Use this 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("Your budget: 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 target", exact: true }).click();
|
||||
await expect(page.getByText("RTX 4090", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("hardware is never preselected; a single-GPU device sets one GPU with valid parallelism", async ({ page }) => {
|
||||
await page.goto(route);
|
||||
await expect(page.locator(".hardware-picker")).toBeVisible({ timeout: 30000 });
|
||||
await expect(page.getByText("No hardware target 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(page.getByText(/Set to 1 GPU for .*RTX 5090/)).toBeVisible();
|
||||
await expect(yaml).toContainText('"num_gpus": 1');
|
||||
await expect(yaml).toContainText('"sp_size": -1');
|
||||
await expect(yaml).not.toContainText('"hardware"');
|
||||
await expect(page.getByText("Valid configuration", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
|
||||
await expect(page.getByText("No hardware target 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("Valid configuration", { 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 target 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 separate validity, fit, and run evidence with inspectable assumptions", async ({ page }) => {
|
||||
await page.goto(route);
|
||||
const facts = page.getByLabel("Recipe facts");
|
||||
await expect(facts.getByText("Valid configuration", { exact: true })).toBeVisible();
|
||||
await expect(facts.getByText("Fit unknown", { exact: true })).toBeVisible();
|
||||
await expect(facts.getByText("No recorded run", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Details", exact: true }).click();
|
||||
await expect(page.getByText(/This is not a fit claim/)).toBeVisible();
|
||||
await expect(page.getByText(/do not establish this configuration/)).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 target", 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 budget: 96 GB");
|
||||
await expect(page.locator(".hardware-address-note")).toContainText("not dedicated VRAM");
|
||||
await expect(page.getByRole("button", { name: "Clear hardware target", 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("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(/Saved hardware target \(retired-gpu\) is not in this catalog/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
|
||||
await expect(page).not.toHaveURL(/hardware=/);
|
||||
await expect(page.getByText("No hardware target 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();
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
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("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 models", 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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
# 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.
|
||||
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 136 KiB |
@@ -0,0 +1,15 @@
|
||||
# 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.
|
||||
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 142 KiB |
@@ -0,0 +1,214 @@
|
||||
{
|
||||
"version": 1,
|
||||
"devices": [
|
||||
{
|
||||
"id": "h100-sxm-80gb",
|
||||
"name": "NVIDIA H100 SXM 80 GB",
|
||||
"vendor": "nvidia",
|
||||
"kind": "gpu",
|
||||
"family": "nvidia-h100",
|
||||
"variant": "SXM 80 GB",
|
||||
"form_factor": "sxm",
|
||||
"memory": {
|
||||
"type": "dedicated",
|
||||
"nominal": {
|
||||
"value": 80,
|
||||
"unit": "GB"
|
||||
}
|
||||
},
|
||||
"backend": "cuda",
|
||||
"gpu_count": 1,
|
||||
"partitionable": true,
|
||||
"partitions": [
|
||||
{
|
||||
"id": "1g.10gb",
|
||||
"label": "1g.10gb",
|
||||
"memory": {
|
||||
"value": 10,
|
||||
"unit": "GB"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "3g.40gb",
|
||||
"label": "3g.40gb",
|
||||
"memory": {
|
||||
"value": 40,
|
||||
"unit": "GB"
|
||||
}
|
||||
}
|
||||
],
|
||||
"aliases": ["h100"],
|
||||
"supported_dtypes": ["fp32", "fp16", "bf16", "fp8"],
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.nvidia.com/en-us/data-center/h100/",
|
||||
"checked": "2026-10-19"
|
||||
}
|
||||
],
|
||||
"memory_gb": 80
|
||||
},
|
||||
{
|
||||
"id": "geforce-rtx-5090-32gb",
|
||||
"name": "NVIDIA GeForce RTX 5090 32 GB (desktop)",
|
||||
"vendor": "nvidia",
|
||||
"kind": "gpu",
|
||||
"family": "geforce-rtx-50",
|
||||
"variant": "5090 desktop",
|
||||
"form_factor": "desktop",
|
||||
"memory": {
|
||||
"type": "dedicated",
|
||||
"nominal": {
|
||||
"value": 32,
|
||||
"unit": "GB"
|
||||
}
|
||||
},
|
||||
"backend": "cuda",
|
||||
"gpu_count": 1,
|
||||
"aliases": ["rtx 5090", "5090"],
|
||||
"supported_dtypes": ["fp32", "fp16", "bf16"],
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.nvidia.com/en-us/geforce/graphics-cards/50-series/rtx-5090/",
|
||||
"checked": "2026-10-19"
|
||||
}
|
||||
],
|
||||
"memory_gb": 32
|
||||
},
|
||||
{
|
||||
"id": "rtx-pro-6000-blackwell-workstation-96gb",
|
||||
"name": "NVIDIA RTX PRO 6000 Blackwell Workstation Edition 96 GB",
|
||||
"vendor": "nvidia",
|
||||
"kind": "gpu",
|
||||
"family": "rtx-pro-6000",
|
||||
"variant": "Blackwell Workstation Edition",
|
||||
"form_factor": "workstation",
|
||||
"memory": {
|
||||
"type": "dedicated",
|
||||
"nominal": {
|
||||
"value": 96,
|
||||
"unit": "GB"
|
||||
}
|
||||
},
|
||||
"backend": "cuda",
|
||||
"gpu_count": 1,
|
||||
"aliases": ["pro 6000", "rtx pro 6000"],
|
||||
"supported_dtypes": ["fp32", "fp16", "bf16"],
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.nvidia.com/en-us/design-visualization/desktop-graphics/",
|
||||
"checked": "2026-10-19"
|
||||
}
|
||||
],
|
||||
"memory_gb": 96
|
||||
},
|
||||
{
|
||||
"id": "dgx-spark-gb10-128gb",
|
||||
"name": "NVIDIA DGX Spark GB10 128 GB",
|
||||
"vendor": "nvidia",
|
||||
"kind": "system",
|
||||
"family": "dgx-spark",
|
||||
"variant": "GB10",
|
||||
"form_factor": "desktop",
|
||||
"memory": {
|
||||
"type": "unified",
|
||||
"nominal": {
|
||||
"value": 128,
|
||||
"unit": "GB"
|
||||
}
|
||||
},
|
||||
"backend": "cuda",
|
||||
"gpu_count": 1,
|
||||
"aliases": ["spark", "gb10"],
|
||||
"supported_dtypes": ["fp32", "fp16", "bf16"],
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.nvidia.com/en-us/products/workstations/dgx-spark/",
|
||||
"checked": "2026-10-19"
|
||||
}
|
||||
],
|
||||
"memory_gb": 128
|
||||
},
|
||||
{
|
||||
"id": "apple-m4-max-128gb",
|
||||
"name": "Apple M4 Max 128 GB",
|
||||
"vendor": "apple",
|
||||
"kind": "soc",
|
||||
"family": "apple-m4",
|
||||
"variant": "M4 Max",
|
||||
"form_factor": "laptop or desktop",
|
||||
"memory": {
|
||||
"type": "unified",
|
||||
"nominal": {
|
||||
"value": 128,
|
||||
"unit": "GB"
|
||||
}
|
||||
},
|
||||
"backend": "metal",
|
||||
"gpu_count": 1,
|
||||
"aliases": ["m4 max", "macbook m4 max"],
|
||||
"supported_dtypes": ["fp32", "fp16", "bf16"],
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.apple.com/macbook-pro/specs/",
|
||||
"checked": "2026-10-19"
|
||||
}
|
||||
],
|
||||
"memory_gb": 128,
|
||||
"usable_memory_gb": 96
|
||||
},
|
||||
{
|
||||
"id": "apple-m3-ultra-192gb",
|
||||
"name": "Apple M3 Ultra 192 GB",
|
||||
"vendor": "apple",
|
||||
"kind": "soc",
|
||||
"family": "apple-m3",
|
||||
"variant": "M3 Ultra",
|
||||
"form_factor": "desktop",
|
||||
"memory": {
|
||||
"type": "unified",
|
||||
"nominal": {
|
||||
"value": 192,
|
||||
"unit": "GB"
|
||||
}
|
||||
},
|
||||
"backend": "metal",
|
||||
"gpu_count": 1,
|
||||
"aliases": ["m3 ultra", "mac studio m3 ultra"],
|
||||
"supported_dtypes": ["fp32", "fp16", "bf16"],
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.apple.com/mac-studio/specs/",
|
||||
"checked": "2026-10-19"
|
||||
}
|
||||
],
|
||||
"memory_gb": 192
|
||||
},
|
||||
{
|
||||
"id": "intel-arc-b580-12gb",
|
||||
"name": "Intel Arc B580 12 GB",
|
||||
"vendor": "intel",
|
||||
"kind": "gpu",
|
||||
"family": "arc-b",
|
||||
"variant": "B580",
|
||||
"form_factor": "desktop",
|
||||
"memory": {
|
||||
"type": "dedicated",
|
||||
"nominal": {
|
||||
"value": 12,
|
||||
"unit": "GB"
|
||||
}
|
||||
},
|
||||
"backend": "sycl",
|
||||
"gpu_count": 1,
|
||||
"aliases": ["arc b580", "b580"],
|
||||
"supported_dtypes": ["fp32", "fp16", "bf16"],
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://www.intel.com/content/www/us/en/products/sku/241598/intel-arc-b580-graphics/specifications.html",
|
||||
"checked": "2026-10-19"
|
||||
}
|
||||
],
|
||||
"memory_gb": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Regression checks for source-only API rendering, without importing model code."""
|
||||
import ast
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import griffe
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location("python_reference", Path(__file__).parents[1] / "scripts/prepare-python-docs.py")
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
renderer = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(renderer)
|
||||
|
||||
|
||||
class PythonReferenceTests(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
root = Path(self.temporary.name)
|
||||
(root / "sample.py").write_text('''raise RuntimeError("This module must never be imported")
|
||||
|
||||
def generate(prompt: str, /, count: int = 0, *, audio: bool = False, **kwargs) -> str:
|
||||
"""Generate an output.
|
||||
|
||||
Args:
|
||||
prompt: Text describing the video.
|
||||
count: Number of outputs.
|
||||
audio: Include audio.
|
||||
"""
|
||||
return {"outputs": []}["outputs"](prompt)
|
||||
|
||||
class Generator:
|
||||
"""A configurable generator."""
|
||||
|
||||
def __init__(self, count: int = 0):
|
||||
"""Initialize the generator.
|
||||
|
||||
Args:
|
||||
count: Number of videos to generate.
|
||||
"""
|
||||
self.count = count
|
||||
|
||||
def run(self):
|
||||
"""Generate the videos."""
|
||||
|
||||
class Empty:
|
||||
"""A class without an explicit constructor."""
|
||||
''')
|
||||
(root / "public.py").write_text("from sample import Generator\n")
|
||||
loader = griffe.GriffeLoader(search_paths=[root], allow_inspection=False, docstring_parser="google")
|
||||
self.module = loader.load("sample")
|
||||
self.public = loader.load("public")
|
||||
loader.resolve_aliases(implicit=True, external=False)
|
||||
|
||||
def test_source_is_read_without_executing_module(self):
|
||||
self.assertIn("generate", self.module.members)
|
||||
rendered = renderer.symbol(self.module["generate"], "sample.generate")
|
||||
self.assertIn('id="sample.generate"', rendered)
|
||||
self.assertIn("Text describing the video", rendered)
|
||||
self.assertIn("<summary>Source</summary>", rendered)
|
||||
|
||||
def test_signature_preserves_parameter_kinds_zero_and_false(self):
|
||||
self.assertEqual(renderer.signature(self.module["generate"]),
|
||||
"generate(prompt: str, /, count: int = 0, *, audio: bool = False, **kwargs) -> str")
|
||||
|
||||
def test_source_brackets_cannot_be_misread_as_markdown_links(self):
|
||||
rendered = renderer.symbol(self.module["generate"], "sample.generate")
|
||||
self.assertNotIn('[]', rendered)
|
||||
self.assertIn("[", rendered)
|
||||
|
||||
def test_example_controls_edit_literals_without_imports_and_use_javascript_offsets(self):
|
||||
source = '''raise RuntimeError("Never execute this example 🥣")
|
||||
PROMPT = "A bowl"
|
||||
generator = VideoGenerator.from_pretrained("Example/Model", num_gpus=1)
|
||||
parser.add_argument("--num-frames", type=int, default=124)
|
||||
parser.add_argument("--fps", type=float, default=24)
|
||||
generator.generate_video(height=480, prompt=PROMPT)
|
||||
'''
|
||||
controls = renderer.source_controls(source)
|
||||
self.assertEqual({item["name"] for item in controls}, {"prompt", "num_gpus", "num_frames", "height", "fps"})
|
||||
self.assertFalse(next(item for item in controls if item["name"] == "fps")["integer"])
|
||||
frames = next(item for item in controls if item["name"] == "num_frames")
|
||||
self.assertEqual(frames["flag"], "--num-frames")
|
||||
for item in controls:
|
||||
for start, end in item["spans"]:
|
||||
literal = source.encode("utf-16-le")[start * 2:end * 2].decode("utf-16-le")
|
||||
self.assertEqual(ast.literal_eval(literal), item["value"])
|
||||
|
||||
def test_class_includes_constructor_documentation_without_a_separate_method(self):
|
||||
for obj, path in [(self.module["Generator"], "sample.Generator"),
|
||||
(self.public["Generator"], "public.Generator")]:
|
||||
with self.subTest(path=path):
|
||||
rendered = renderer.symbol(obj, path, source=False)
|
||||
self.assertIn("A configurable generator.", rendered)
|
||||
self.assertEqual(rendered.count("Number of videos to generate."), 1)
|
||||
self.assertIn('<dt><code>count: int</code></dt>', rendered)
|
||||
self.assertNotIn('__init__', rendered)
|
||||
self.assertIn(f'id="{path}.run"', rendered)
|
||||
self.assertIn("A class without an explicit constructor.",
|
||||
renderer.symbol(self.module["Empty"], "sample.Empty", source=False))
|
||||
|
||||
def test_overview_resolves_module_symbol_and_public_alias_references(self):
|
||||
rendered = renderer.api_overview("[Package][sample]\n\n[`sample.Generator`][]", self.module)
|
||||
self.assertIn('href="/api/sample#sample"', rendered)
|
||||
self.assertIn('href="/api/sample#sample.Generator"', rendered)
|
||||
rendered = renderer.api_overview("[`public.Generator`][]", self.public)
|
||||
self.assertIn('href="/api/public#public.Generator"', rendered)
|
||||
|
||||
def test_overview_converts_guide_paths_and_preserves_external_links(self):
|
||||
rendered = renderer.api_overview(
|
||||
"[Guide](../getting_started/v1_api.md?mode=full#configuration)\n\n"
|
||||
"[Overview](../training/index.md)\n\n[External](https://example.com/guide.md)", self.module)
|
||||
self.assertIn('href="/getting_started/v1_api?mode=full#configuration"', rendered)
|
||||
self.assertIn('href="/training"', rendered)
|
||||
self.assertIn('href="https://example.com/guide.md"', rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"blume.config.ts",
|
||||
"islands/**/*.tsx",
|
||||
"components/**/*.tsx",
|
||||
"lib/**/*.ts",
|
||||
"tests/**/*.ts",
|
||||
"playwright.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,113 +1,32 @@
|
||||
# FastVideo Documentation
|
||||
# FastVideo documentation
|
||||
|
||||
This directory contains the FastVideo documentation built with MkDocs.
|
||||
Canonical guides live in this directory. The Blume application in
|
||||
[docs-site](../docs-site/README.md) builds and publishes them at
|
||||
[haoailab.com/FastVideo](https://haoailab.com/FastVideo/).
|
||||
|
||||
## Build the docs locally
|
||||
See [Documenting FastVideo](contributing/documentation.md) for local setup,
|
||||
source ownership, page structure, validation, and deployment. The existing
|
||||
GitHub Pages workflow publishes Blume from `main` with the same domain and base
|
||||
path. MkDocs is no longer part of the build.
|
||||
|
||||
```bash
|
||||
# Install docs dependencies
|
||||
uv pip install -r requirements-mkdocs.txt
|
||||
npm ci --prefix docs
|
||||
## Cookbook sources
|
||||
|
||||
# Build the browser API/validator bundle and generate cookbook catalogs
|
||||
npm run build:catalog --prefix docs
|
||||
|
||||
# Serve docs with live reload (recommended for development)
|
||||
mkdocs serve
|
||||
|
||||
# Or build static site
|
||||
mkdocs build
|
||||
```
|
||||
|
||||
Run these commands from the repository root with Node 22.13+ (22.x) or Node 24+. Catalog generation uses authored
|
||||
YAML and Node dependencies; it does not import FastVideo or install model runtime
|
||||
dependencies. `mkdocs build` and `mkdocs serve` use the generated files without
|
||||
regenerating them. A small MkDocs hook checks required assets before and after
|
||||
the build and explains how to prepare them if missing. Rerun `build:catalog`
|
||||
after changing cookbook sources. The npm `prebuild:catalog` and `pretest:cookbook`
|
||||
hooks automatically run `build:assets`, including in CI. The combined
|
||||
`docs/assets/cookbook-config/cookbook-config.js`, its
|
||||
`cookbook-config.LICENSE.txt`, index and catalogs are Git-ignored and published
|
||||
together; do not hand-edit or commit them. Handwritten modules live in `docs/js/`
|
||||
and tests in `docs/tests/`.
|
||||
|
||||
## View the docs
|
||||
|
||||
### Development server (with live reload)
|
||||
|
||||
```bash
|
||||
mkdocs serve
|
||||
```
|
||||
|
||||
Then open your browser to: http://127.0.0.1:8000
|
||||
|
||||
### Static build
|
||||
|
||||
```bash
|
||||
mkdocs build
|
||||
python -m http.server -d site/
|
||||
```
|
||||
|
||||
Then open your browser to: http://localhost:8000
|
||||
|
||||
## Automatic Deployment
|
||||
|
||||
Documentation is automatically built and deployed to GitHub Pages when relevant
|
||||
changes are pushed to the `main` branch via `.github/workflows/infra-docs.yml`.
|
||||
Pull requests run the build without deploying. The normal docs job installs
|
||||
`requirements-mkdocs.txt` and the Node dependencies with `npm ci --prefix docs`,
|
||||
runs scoped lint/format checks, cookbook tests and asset-check tests, generates catalogs with `npm run build:catalog --prefix docs`,
|
||||
and runs `mkdocs build`. Invalid authored
|
||||
metadata stops the build. The generated JSON remains Git-ignored but is copied
|
||||
to `site/assets/cookbook-config/` for publication. The source-only
|
||||
`docs/cookbook/options.yaml` and recipe manifests are excluded from the site.
|
||||
No separate metadata job or runtime server is required.
|
||||
|
||||
## Update documentation dependencies
|
||||
|
||||
Edit `requirements-mkdocs.in`, then regenerate the pinned Linux/Python 3.12 lock file:
|
||||
|
||||
```bash
|
||||
uv pip compile requirements-mkdocs.in \
|
||||
-o requirements-mkdocs.txt \
|
||||
--python-platform x86_64-manylinux_2_28 \
|
||||
--python-version 3.12
|
||||
```
|
||||
|
||||
## Local cookbook catalogs
|
||||
|
||||
Generate catalogs from the repository root:
|
||||
Recipe metadata, shared JavaScript, and tests remain under `docs/`. From the
|
||||
repository root:
|
||||
|
||||
```bash
|
||||
npm ci --prefix docs
|
||||
npm run build:catalog --prefix docs
|
||||
```
|
||||
|
||||
This command builds the combined API/validator bundle first. Rerun it whenever
|
||||
shared options, recipe manifests or baseline YAML change. Then `mkdocs build`
|
||||
or `mkdocs serve` includes the generated assets. Generation loads no model
|
||||
weights and performs no inference.
|
||||
|
||||
Inspect a deployment without a browser UI:
|
||||
|
||||
```bash
|
||||
node docs/js/build-cookbook-config.mjs --preview fastwan21/cuda-rest
|
||||
```
|
||||
|
||||
See [the contributor guide](contributing/cookbook_configuration.md) for recipe
|
||||
authoring and local test commands, and [the API contract](cookbook/design.md)
|
||||
for using the same catalogs from a cookbook UI.
|
||||
|
||||
## Check cookbook changes
|
||||
|
||||
```bash
|
||||
npm run test:cookbook --prefix docs
|
||||
npm run check:cookbook --prefix docs
|
||||
npm run test:cookbook --prefix docs
|
||||
python -m unittest discover -s docs/tests -p 'test_*.py'
|
||||
```
|
||||
|
||||
`npm run format:cookbook --prefix docs` formats handwritten cookbook modules,
|
||||
build scripts, tooling configuration and Node tests under `docs/`. The check
|
||||
commands do not change files. The Node tests use synthetic fixtures for reusable
|
||||
behavior and discover every production recipe for integration checks. They do
|
||||
not launch serving processes.
|
||||
The combined browser bundle, notices, index and catalogs are generated together
|
||||
under `assets/cookbook-config/`. Do not hand-edit or commit those outputs.
|
||||
The site preparation builds and copies them into Blume's public assets.
|
||||
See the [recipe authoring guide](contributing/cookbook_configuration.md) and
|
||||
[configuration API contract](cookbook/design.md) before changing the schema.
|
||||
The [capacity estimation guide](contributing/capacity_estimation.md) covers reusable
|
||||
hardware/component catalogs and optional recorded inference runs in existing
|
||||
model manifests.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 16,
|
||||
"version": 20,
|
||||
"recipes": [
|
||||
{
|
||||
"id": "fastwan21-t2v",
|
||||
@@ -381,6 +381,33 @@
|
||||
"expected_artifact": "Upscaled MP4 under outputs_video/kandinsky6_sr/",
|
||||
"related": ["kandinsky6-vsr", "kandinsky6-ti2va-piflow"]
|
||||
},
|
||||
{
|
||||
"id": "pan2-ti2v",
|
||||
"family": "pan2",
|
||||
"stage": "inference",
|
||||
"task": "Text or image to video",
|
||||
"label": "PAN 2",
|
||||
"summary": "Generate a 13-second, 704 × 1248 video at 24 fps from text, or set IMAGE_PATH in the maintained example to condition on a first frame.",
|
||||
"model": "IFM/PAN2",
|
||||
"source": "examples/inference/basic/basic_pan2.py",
|
||||
"serving": {
|
||||
"source": "examples/serving/openai_pan2.yaml",
|
||||
"install": "UV_TORCH_BACKEND=cu130 uv pip install -e .",
|
||||
"task": "Text to video",
|
||||
"limitations": [
|
||||
"This server walkthrough sends a text prompt only. The server API also accepts an image reference (image_reference or input_reference) and then runs image-to-video from that first frame, but the checked-in clients do not upload one."
|
||||
]
|
||||
},
|
||||
"command": "python examples/inference/basic/basic_pan2.py",
|
||||
"gpu_types": ["NVIDIA"],
|
||||
"hardware": {"platform": "cuda", "gpu_count": 1, "evidence": "source-configured"},
|
||||
"evidence": "Source-backed",
|
||||
"expected_artifact": "MP4 video under video_samples_pan2/",
|
||||
"limitations": [
|
||||
"The full IFM/PAN2 checkpoint is not public yet; numerical parity is checked with a public random-weight checkpoint.",
|
||||
"The example keeps the 29B DiT resident in bf16 and offloads only the text encoder. GPU memory requirements have not been established for this recipe; set PAN2_NUM_GPUS to shard video tokens with sequence parallelism, which does not shard the DiT weights."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "flux2-klein-t2i",
|
||||
"family": "flux",
|
||||
@@ -429,6 +456,44 @@
|
||||
"expected_artifact": "PNG images under outputs/flux_dev/samples/",
|
||||
"limitations": ["FLUX.1 is loadable by ID but registers no model_family in fastvideo/registry.py; it is grouped under FLUX for documentation only."]
|
||||
},
|
||||
{
|
||||
"id": "qwen-image21-t2i",
|
||||
"family": "qwen_image21",
|
||||
"stage": "inference",
|
||||
"task": "Text to image",
|
||||
"label": "Qwen-Image-2.1",
|
||||
"summary": "Generate a 1024 × 1024 image in 40 steps on one CUDA GPU, with the CPU offload settings recorded in the maintained YAML.",
|
||||
"model": "Qwen/Qwen-Image-2.1",
|
||||
"source": "examples/inference/basic/qwen_image21_t2i.yaml",
|
||||
"command": "fastvideo generate --config examples/inference/basic/qwen_image21_t2i.yaml",
|
||||
"gpu_types": ["NVIDIA"],
|
||||
"hardware": {"platform": "cuda", "gpu_count": 1, "evidence": "source-configured"},
|
||||
"evidence": "Source-backed",
|
||||
"expected_artifact": "RGBA PNG image at outputs/qwen_image21/teapot.png",
|
||||
"limitations": ["The source uses DiT layerwise offload, text-encoder and VAE offload, and lazy loading. GPU memory requirements have not been established for this recipe."],
|
||||
"related": ["qwen-image21-i2i"]
|
||||
},
|
||||
{
|
||||
"id": "qwen-image21-i2i",
|
||||
"family": "qwen_image21",
|
||||
"stage": "inference",
|
||||
"task": "Image to image",
|
||||
"label": "Qwen-Image-2.1 references and editing",
|
||||
"summary": "Generate or edit an image from a prompt and up to ten ordered reference images, using the maintained single-GPU Python example.",
|
||||
"model": "Qwen/Qwen-Image-2.1",
|
||||
"source": "examples/inference/basic/basic_qwen_image21.py",
|
||||
"command": "python examples/inference/basic/basic_qwen_image21.py --reference source.png --prompt 'Change the shirt to blue; retain the subject and composition.' --output outputs/qwen_image21/edit.png",
|
||||
"gpu_types": ["NVIDIA"],
|
||||
"hardware": {"platform": "cuda", "gpu_count": 1, "evidence": "source-configured"},
|
||||
"evidence": "Source-backed",
|
||||
"expected_artifact": "RGBA PNG image at outputs/qwen_image21/edit.png",
|
||||
"limitations": [
|
||||
"Replace source.png with an existing image. Repeat --reference for additional images and describe their order in the prompt.",
|
||||
"Mask images use the same ordered-reference path; they do not impose hard pixel-preservation constraints.",
|
||||
"The source enables CPU offload and lazy loading. This entry does not establish a minimum GPU memory requirement."
|
||||
],
|
||||
"related": ["qwen-image21-t2i"]
|
||||
},
|
||||
{
|
||||
"id": "sd35-medium-t2i",
|
||||
"family": "sd35",
|
||||
@@ -552,10 +617,10 @@
|
||||
"serving": {
|
||||
"source": "examples/serving/openai_fasth3_spark.yaml",
|
||||
"install": "UV_TORCH_BACKEND=cu130 uv pip install -e .",
|
||||
"env": "FASTVIDEO_VSA_SM100A=0 FASTVIDEO_FA4=0 FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3",
|
||||
"env": "FASTVIDEO_VSA_PTX_BLACKWELL=0 FASTVIDEO_FA4=0 FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3",
|
||||
"audio": true
|
||||
},
|
||||
"command": "FASTVIDEO_VSA_SM100A=0 FASTVIDEO_FA4=0 FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3 FASTVIDEO_STAGE_LOGGING=1 fastvideo generate --config examples/inference/basic/basic_fasth3_spark.yaml",
|
||||
"command": "FASTVIDEO_VSA_PTX_BLACKWELL=0 FASTVIDEO_FA4=0 FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3 FASTVIDEO_STAGE_LOGGING=1 fastvideo generate --config examples/inference/basic/basic_fasth3_spark.yaml",
|
||||
"gpu_types": ["NVIDIA"],
|
||||
"hardware": {
|
||||
"platform": "cuda",
|
||||
@@ -567,7 +632,7 @@
|
||||
"expected_artifact": "MP4 under outputs/fasth3_spark/",
|
||||
"modes": ["T2VA", "1-Spark"],
|
||||
"limitations": [
|
||||
"Install from the DGX Spark guide, not the generic CUDA extra. GB10 has no FA4 / sm_100a VSA kernel; keep FASTVIDEO_FA4=0 and FASTVIDEO_VSA_SM100A=0.",
|
||||
"Install from the DGX Spark guide, not the generic CUDA extra. GB10 has no FA4 / sm_100a VSA kernel; keep FASTVIDEO_FA4=0 and FASTVIDEO_VSA_PTX_BLACKWELL=0.",
|
||||
"Legal num_frames values are 17n+5, capped at 345 (15 s). Native 16:9 sizes include 832x480 and 1344x768.",
|
||||
"Lazy module load reloads Qwen3-VL and the DiT between phases of each request. Do not pass --no-lazy-module-load on this box.",
|
||||
"A 345-frame request on one Spark can OOM. Prefer 124 or 243 frames, TAEH3 decode, or two Sparks over QSFP."
|
||||
@@ -585,7 +650,7 @@
|
||||
"summary": "Run one FastH3 clip across two GB10s with Ray sequence parallel over QSFP RoCE. Sequential load and lazy module load stay on because SP replicates the DiT on each node.",
|
||||
"model": "FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree",
|
||||
"source": "examples/inference/basic/basic_fasth3_spark_pair.yaml",
|
||||
"command": "source examples/inference/optimizations/spark_pair_env.sh && FASTVIDEO_VSA_SM100A=0 FASTVIDEO_FA4=0 FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3 FASTVIDEO_VAE_PARALLEL_DECODE=1 fastvideo generate --config examples/inference/basic/basic_fasth3_spark_pair.yaml",
|
||||
"command": "source examples/inference/optimizations/spark_pair_env.sh && FASTVIDEO_VSA_PTX_BLACKWELL=0 FASTVIDEO_FA4=0 FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3 FASTVIDEO_VAE_PARALLEL_DECODE=1 fastvideo generate --config examples/inference/basic/basic_fasth3_spark_pair.yaml",
|
||||
"gpu_types": ["NVIDIA"],
|
||||
"hardware": {
|
||||
"platform": "cuda",
|
||||
@@ -603,77 +668,6 @@
|
||||
"Height, width, frames, and steps in the YAML are examples. Edit them or pass CLI flags. See docs/getting_started/installation/spark_pair.md."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "compacth3-rtx5090",
|
||||
"group": "compacth3-rtx5090",
|
||||
"group_label": "CompactH3 on RTX 5090",
|
||||
"group_task": "4-step 42-block text to video + audio",
|
||||
"family": "minimax_h3",
|
||||
"stage": "inference",
|
||||
"task": "Few-step text to video (with audio)",
|
||||
"label": "CompactH3 NVFP4 on RTX 5090",
|
||||
"summary": "Run the 42-block CompactH3 NVFP4 DiT with the NVFP4 Qwen3-VL encoder, Comfy int8-convrot VAE, and SageAttention3 FP4 on one 32 GB RTX 5090. Sequential load parks the encoder in pinned host RAM.",
|
||||
"model": "./CompactH3",
|
||||
"source": "examples/inference/basic/basic_compacth3_rtx5090.yaml",
|
||||
"serving": {
|
||||
"source": "examples/serving/openai_compacth3_rtx5090.yaml",
|
||||
"install": "UV_TORCH_BACKEND=cu128 uv pip install -e \".[fasth3]\"",
|
||||
"prepare": "hf download FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree --local-dir ./CompactH3 --include model_index.json --include \"tokenizer/**\" --include \"processor/**\" --include \"scheduler/**\" --include \"audio_scheduler/**\" --include \"audio_vae/**\" --include \"vae/**\"\nhf download aryan5v/FastH3-20B-42block-dmd2-ckpt1400-bf16 --local-dir ./CompactH3/transformer\nhf download aryan5v/FastH3-20B-42block-dmd2-ckpt1400-nvfp4 --local-dir ./CompactH3/transformer --include nvfp4_weights.safetensors\nhf download KyleNeverGivesUp/FastH3-text-encoder-nvfp4 --local-dir ./CompactH3/text_encoder\nhf download Comfy-Org/MiniMax-H3 --local-dir ./Comfy-MiniMax-H3 --include vae/minimax_h3_video_vae_int8_convrot.safetensors\ncp ./Comfy-MiniMax-H3/vae/minimax_h3_video_vae_int8_convrot.safetensors ./CompactH3/vae/",
|
||||
"env": "FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_INFER FASTVIDEO_VSA_SM100A=0 FASTVIDEO_FA4=0 FLASHINFER_CUDA_ARCH_LIST=12.0a"
|
||||
},
|
||||
"command": "FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_INFER FASTVIDEO_VSA_SM100A=0 FASTVIDEO_FA4=0 FLASHINFER_CUDA_ARCH_LIST=12.0a FASTVIDEO_STAGE_LOGGING=1 fastvideo generate --config examples/inference/basic/basic_compacth3_rtx5090.yaml",
|
||||
"gpu_types": ["NVIDIA"],
|
||||
"hardware": {
|
||||
"platform": "cuda",
|
||||
"gpu_count": 1,
|
||||
"evidence": "source-configured"
|
||||
},
|
||||
"evidence": "Source-backed",
|
||||
"expected_artifact": "MP4 under outputs/compacth3_rtx5090/",
|
||||
"modes": ["T2VA", "CompactH3 NVFP4", "RTX 5090"],
|
||||
"limitations": [
|
||||
"Assemble ./CompactH3 before running. The DiT NVFP4 export and encoder snapshot are gated; run huggingface-cli login and accept each repo license.",
|
||||
"32 GB cannot keep the NVFP4 encoder and DiT on the GPU together. Keep h3_sequential_load on and lazy_module_load off.",
|
||||
"Blackwell sm_120 needs ATTN_QAT_INFER, FLASHINFER_CUDA_ARCH_LIST=12.0a, and a CUDA 12.8 PyTorch wheel.",
|
||||
"Legal num_frames values are 17n+5, capped at 362 (15.08 s). Native 16:9 sizes include 832x480 and 1344x768; 1344x768 on 32 GB is unmeasured."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "compacth3-rtx-pro6000",
|
||||
"group": "compacth3-rtx-pro6000",
|
||||
"group_label": "CompactH3 on RTX PRO 6000",
|
||||
"group_task": "4-step 42-block text to video + audio",
|
||||
"family": "minimax_h3",
|
||||
"stage": "inference",
|
||||
"task": "Few-step text to video (with audio)",
|
||||
"label": "CompactH3 NVFP4 on RTX PRO 6000 Blackwell",
|
||||
"summary": "Run CompactH3 NVFP4 with the encoder, DiT, and int8-convrot VAE resident on one 96 GB RTX PRO 6000 Blackwell. The checked-in example is 1344x768 and 124 frames (5.17 s) with VAE torch.compile. Use 832x480 for clip-queue playground traffic.",
|
||||
"model": "./CompactH3",
|
||||
"source": "examples/inference/basic/basic_compacth3_rtx_pro6000.yaml",
|
||||
"serving": {
|
||||
"source": "examples/serving/openai_compacth3_rtx_pro6000.yaml",
|
||||
"install": "UV_TORCH_BACKEND=cu128 uv pip install -e \".[fasth3]\"",
|
||||
"prepare": "hf download FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree --local-dir ./CompactH3 --include model_index.json --include \"tokenizer/**\" --include \"processor/**\" --include \"scheduler/**\" --include \"audio_scheduler/**\" --include \"audio_vae/**\" --include \"vae/**\"\nhf download aryan5v/FastH3-20B-42block-dmd2-ckpt1400-bf16 --local-dir ./CompactH3/transformer\nhf download aryan5v/FastH3-20B-42block-dmd2-ckpt1400-nvfp4 --local-dir ./CompactH3/transformer --include nvfp4_weights.safetensors\nhf download KyleNeverGivesUp/FastH3-text-encoder-nvfp4 --local-dir ./CompactH3/text_encoder\nhf download Comfy-Org/MiniMax-H3 --local-dir ./Comfy-MiniMax-H3 --include vae/minimax_h3_video_vae_int8_convrot.safetensors\ncp ./Comfy-MiniMax-H3/vae/minimax_h3_video_vae_int8_convrot.safetensors ./CompactH3/vae/",
|
||||
"env": "FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_INFER FASTVIDEO_VSA_SM100A=0 FASTVIDEO_FA4=0 FLASHINFER_CUDA_ARCH_LIST=12.0a"
|
||||
},
|
||||
"command": "FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_INFER FASTVIDEO_VSA_SM100A=0 FASTVIDEO_FA4=0 FLASHINFER_CUDA_ARCH_LIST=12.0a FASTVIDEO_STAGE_LOGGING=1 fastvideo generate --config examples/inference/basic/basic_compacth3_rtx_pro6000.yaml",
|
||||
"gpu_types": ["NVIDIA"],
|
||||
"hardware": {
|
||||
"platform": "cuda",
|
||||
"gpu_count": 1,
|
||||
"evidence": "source-configured"
|
||||
},
|
||||
"evidence": "Source-backed",
|
||||
"expected_artifact": "MP4 under outputs/compacth3_rtx_pro6000/",
|
||||
"modes": ["T2VA", "CompactH3 NVFP4", "RTX PRO 6000"],
|
||||
"limitations": [
|
||||
"Assemble ./CompactH3 before running. The DiT NVFP4 export and encoder snapshot are gated; run huggingface-cli login and accept each repo license.",
|
||||
"96 GB keeps the encoder, DiT, and VAE on GPU. Do not enable h3_sequential_load or lazy_module_load on this box.",
|
||||
"Enable compile.vae_enabled. Leave inference_torch_compile off: FlashInfer and Sage3 custom ops cannot be compiled.",
|
||||
"Blackwell sm_120 needs ATTN_QAT_INFER, FLASHINFER_CUDA_ARCH_LIST=12.0a, and a CUDA 12.8 PyTorch wheel.",
|
||||
"Legal num_frames values are 17n+5, capped at 362 (15.08 s). Native 16:9 sizes include 832x480 and 1344x768. Dense CompactH3 has zero VSA gates; the pipeline raises if VIDEO_SPARSE_ATTN_H3 is loaded with all-zero to_gate_compress weights."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fasth3-8step-v2-cuda",
|
||||
"group": "fasth3-8step-v2",
|
||||
@@ -903,6 +897,129 @@
|
||||
"evidence": "Source-backed",
|
||||
"expected_artifact": "MP4 videos under video_samples_matrixgame3/",
|
||||
"related": ["matrix-game-2"]
|
||||
},
|
||||
{
|
||||
"id": "fasth3-trim-spark",
|
||||
"family": "minimax_h3",
|
||||
"stage": "inference",
|
||||
"task": "Text to video with audio",
|
||||
"label": "FastH3 Trim",
|
||||
"summary": "Eight-step video and synchronized audio with the released NVFP4 transformer, text encoder, and lightweight VAE.",
|
||||
"model": "FastVideo/FastVideo-FastH3-Trim-8-Step-NVFP4",
|
||||
"source": "examples/inference/basic/basic_fasth3_spark_pruned_nvfp4.yaml",
|
||||
"command": "FASTVIDEO_MINIMAX_H3_FUSIONS=all FASTVIDEO_NVFP4_MM_BACKEND=cutlass FASTVIDEO_H3_VAE_TILE_BATCH=1 FASTVIDEO_VSA_TRITON=1 FASTVIDEO_VSA_PTX_BLACKWELL=0 FASTVIDEO_FA4=0 FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3 FASTVIDEO_STAGE_LOGGING=1 fastvideo generate --config examples/inference/basic/basic_fasth3_spark_pruned_nvfp4.yaml",
|
||||
"gpu_types": [
|
||||
"NVIDIA"
|
||||
],
|
||||
"hardware": {
|
||||
"platform": "cuda",
|
||||
"gpu_count": 1,
|
||||
"device": "spark",
|
||||
"evidence": "source-configured"
|
||||
},
|
||||
"evidence": "Source-backed",
|
||||
"serving": {
|
||||
"source": "examples/serving/openai_fasth3_trim_spark.yaml",
|
||||
"install": "Install FastVideo following the DGX Spark installation guide.",
|
||||
"audio": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fasth3-omniref-rtx5090",
|
||||
"family": "minimax_h3",
|
||||
"stage": "inference",
|
||||
"task": "Reference media to video with audio",
|
||||
"label": "FastH3 OmniRef on RTX 5090",
|
||||
"summary": "Eight-forward video and audio from a prompt plus ordered image, video, and audio references, with the NVFP4 stack.",
|
||||
"model": "FastVideo/FastH3-OmniRef-NVFP4-QAD",
|
||||
"source": "examples/inference/basic/basic_fasth3_omniref_pdd.py",
|
||||
"command": "python examples/inference/basic/basic_fasth3_omniref_pdd.py --model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --profile rtx-5090 --image assets/images/couple.jpg --prompt 'A person on a sofa, lit by a warm lamp, speaking to camera.'",
|
||||
"gpu_types": [
|
||||
"NVIDIA"
|
||||
],
|
||||
"hardware": {
|
||||
"platform": "cuda",
|
||||
"gpu_count": 1,
|
||||
"minimum_memory": "32 GB VRAM",
|
||||
"evidence": "source-configured"
|
||||
},
|
||||
"evidence": "Source-backed"
|
||||
},
|
||||
{
|
||||
"id": "fasth3-omniref-rtx-pro6000",
|
||||
"family": "minimax_h3",
|
||||
"stage": "inference",
|
||||
"task": "Reference media to video with audio",
|
||||
"label": "FastH3 OmniRef on RTX PRO 6000",
|
||||
"summary": "Eight-forward video and audio from a prompt plus ordered image, video, and audio references, with the NVFP4 stack.",
|
||||
"model": "FastVideo/FastH3-OmniRef-NVFP4-QAD",
|
||||
"source": "examples/inference/basic/basic_fasth3_omniref_pdd.py",
|
||||
"command": "python examples/inference/basic/basic_fasth3_omniref_pdd.py --model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --profile rtx-pro-6000 --image assets/images/couple.jpg --prompt 'A person on a sofa, lit by a warm lamp, speaking to camera.'",
|
||||
"gpu_types": [
|
||||
"NVIDIA"
|
||||
],
|
||||
"hardware": {
|
||||
"platform": "cuda",
|
||||
"gpu_count": 1,
|
||||
"evidence": "source-configured"
|
||||
},
|
||||
"evidence": "Source-backed"
|
||||
},
|
||||
{
|
||||
"id": "fasth3-omniref-spark",
|
||||
"family": "minimax_h3",
|
||||
"stage": "inference",
|
||||
"task": "Reference media to video with audio",
|
||||
"label": "FastH3 OmniRef on DGX Spark",
|
||||
"summary": "Eight-forward video and audio from a prompt plus ordered image, video, and audio references, with the NVFP4 stack.",
|
||||
"model": "FastVideo/FastH3-OmniRef-NVFP4-QAD",
|
||||
"source": "examples/inference/basic/basic_fasth3_omniref_pdd.py",
|
||||
"command": "python examples/inference/basic/basic_fasth3_omniref_pdd.py --model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --profile spark --image assets/images/couple.jpg --prompt 'A person on a sofa, lit by a warm lamp, speaking to camera.'",
|
||||
"gpu_types": [
|
||||
"NVIDIA"
|
||||
],
|
||||
"hardware": {
|
||||
"platform": "cuda",
|
||||
"gpu_count": 1,
|
||||
"device": "spark",
|
||||
"evidence": "source-configured"
|
||||
},
|
||||
"evidence": "Source-backed"
|
||||
},
|
||||
{
|
||||
"id": "fasth3-omniref-mlx",
|
||||
"family": "minimax_h3",
|
||||
"stage": "inference",
|
||||
"task": "Reference media to video with audio",
|
||||
"label": "FastH3 Omni V1 on MLX",
|
||||
"summary": "Run the FastH3 Omni BF16 release on Apple Silicon: compose it with the NVFP4 text-encoder stack, convert the DiT to INT6 with VSA, and decode with the LightVAE encoder and the FastH3 NVFP4 decoder.",
|
||||
"model": "FastVideo/FastVideo-FastH3-Omni-8-Step-V1",
|
||||
"source": "examples/inference/basic/mlx_fasth3_omniref.py",
|
||||
"command": "hf download FastVideo/FastVideo-FastH3-Omni-8-Step-V1 --local-dir ./FastVideo-FastH3-Omni-8-Step-V1\nhf download FastVideo/FastH3-OmniRef-NVFP4-QAD --include 'text_encoder/*' 'tokenizer/*' 'processor/*' 'vae/config.json' 'audio_vae/*' --local-dir ./FastH3-OmniRef-NVFP4-QAD\nhf download lightx2v/LightVAE lightvae-lite-h3-encoder.safetensors --local-dir ./LightVAE\nhf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir ./FastH3-NVFP4-VAE-Decoder\npython examples/inference/basic/mlx_fasth3_omniref.py --compose-only --export ./FastVideo-FastH3-Omni-8-Step-V1 --base-model-path ./FastH3-OmniRef-NVFP4-QAD --composed-dir ./FastH3-OmniRef-composed\npython scripts/checkpoint_conversion/convert_minimax_h3_mlx.py --model-root ./FastH3-OmniRef-composed/transformer_ref --out ./FastH3-OmniRef-MLX --formats int6 --include-vsa --nvfp4-conditioner-root ./FastH3-OmniRef-NVFP4-QAD/text_encoder\nln -sfn \"$PWD/FastH3-OmniRef-MLX/nvfp4-encoder\" ./FastH3-OmniRef-composed/text_encoder\npython examples/inference/basic/mlx_fasth3_omniref.py --model-root ./FastH3-OmniRef-composed --mlx-checkpoint ./FastH3-OmniRef-MLX/int6 --image assets/images/couple.jpg --prompt 'A person on a sofa, lit by a warm lamp, speaking to camera.' --vae-encoder ./LightVAE/lightvae-lite-h3-encoder.safetensors --vae-decoder ./FastH3-NVFP4-VAE-Decoder/quality-36L/decoder-nvfp4.safetensors --vae-dtype bf16 --mlx-cache-limit-gib 8 --metal-wired-limit-gib 42 --height 480 --width 832 --num-frames 124 --seed 2026 --output-path ./outputs/fasth3_omniref_mlx.mp4",
|
||||
"gpu_types": [
|
||||
"Apple Silicon"
|
||||
],
|
||||
"hardware": {
|
||||
"platform": "mlx",
|
||||
"accelerator": "Apple M5 Max",
|
||||
"system_memory": "64 GB unified memory",
|
||||
"minimum_memory": "36 GB unified memory",
|
||||
"peak_memory": "27-31 GB peak memory, INT6 DiT of about 17 GB",
|
||||
"evidence": "validated",
|
||||
"evidence_url": "https://github.com/hao-ai-lab/FastVideo/pull/2000"
|
||||
},
|
||||
"evidence": "Verified",
|
||||
"expected_artifact": "MP4 with H.264 video and stereo AAC audio at outputs/fasth3_omniref_mlx.mp4",
|
||||
"modes": [
|
||||
"Ref2VA",
|
||||
"8-step FastH3 Omni",
|
||||
"trained VSA"
|
||||
],
|
||||
"limitations": [
|
||||
"Convert with --include-vsa. At least one image or video reference is required; pass references in the order the model should read them.",
|
||||
"Validated on an M4 Max with 36 GB and an M5 Max with 64 GB. Set --metal-wired-limit-gib to about two thirds of the Mac's memory.",
|
||||
"Use fast-8L/decoder-nvfp4.safetensors for the smaller, faster decoder tier. FL2VA keyframes, two-pass refinement and TAEH3 decoding are not wired for references."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,746 +0,0 @@
|
||||
(() => {
|
||||
const PATTERN_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const PATTERN_LENGTH = 900;
|
||||
const SCRAMBLE_MS = 140;
|
||||
|
||||
const loadRecipes = (url) =>
|
||||
fetch(url).then((response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
});
|
||||
|
||||
const generatePattern = (length) => {
|
||||
const chars = new Array(length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
chars[index] = PATTERN_CHARS.charAt((Math.random() * PATTERN_CHARS.length) | 0);
|
||||
}
|
||||
return chars.join("");
|
||||
};
|
||||
|
||||
const motionQuery = () =>
|
||||
window.matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)");
|
||||
|
||||
const initEvervault = (root) => {
|
||||
root.querySelectorAll("[data-evervault]").forEach((visual) => {
|
||||
if (visual.dataset.evervaultReady) return;
|
||||
visual.dataset.evervaultReady = "true";
|
||||
const noise = visual.querySelector("[data-cookbook-pattern]");
|
||||
if (!noise) return;
|
||||
|
||||
const query = motionQuery();
|
||||
if (!query.matches) return;
|
||||
|
||||
let rect = null;
|
||||
let pointerX = 0;
|
||||
let pointerY = 0;
|
||||
let frame = 0;
|
||||
let lastScramble = 0;
|
||||
let patternReady = false;
|
||||
|
||||
const paint = (scramble) => {
|
||||
visual.style.setProperty("--mouse-x", `${pointerX}px`);
|
||||
visual.style.setProperty("--mouse-y", `${pointerY}px`);
|
||||
if (scramble) noise.textContent = generatePattern(PATTERN_LENGTH);
|
||||
frame = 0;
|
||||
};
|
||||
|
||||
const onEnter = () => {
|
||||
rect = visual.getBoundingClientRect();
|
||||
if (!patternReady) {
|
||||
noise.textContent = generatePattern(PATTERN_LENGTH);
|
||||
patternReady = true;
|
||||
}
|
||||
};
|
||||
const onMove = (event) => {
|
||||
if (!rect) rect = visual.getBoundingClientRect();
|
||||
pointerX = event.clientX - rect.left;
|
||||
pointerY = event.clientY - rect.top;
|
||||
if (frame) return;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
const now = performance.now();
|
||||
const scramble = now - lastScramble >= SCRAMBLE_MS;
|
||||
if (scramble) lastScramble = now;
|
||||
paint(scramble);
|
||||
});
|
||||
};
|
||||
const onLeave = () => {
|
||||
if (frame) window.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
rect = null;
|
||||
pointerX = 0;
|
||||
pointerY = 0;
|
||||
visual.style.setProperty("--mouse-x", "50%");
|
||||
visual.style.setProperty("--mouse-y", "50%");
|
||||
};
|
||||
visual.addEventListener("mouseenter", onEnter);
|
||||
visual.addEventListener("mousemove", onMove);
|
||||
visual.addEventListener("mouseleave", onLeave);
|
||||
});
|
||||
};
|
||||
|
||||
let familyPopstate = null;
|
||||
const bindFamilyPopstate = () => {
|
||||
if (bindFamilyPopstate.bound) return;
|
||||
bindFamilyPopstate.bound = true;
|
||||
window.addEventListener("popstate", () => {
|
||||
if (typeof familyPopstate === "function") familyPopstate();
|
||||
});
|
||||
};
|
||||
|
||||
const groupIdFor = (recipe) => recipe.group || recipe.id;
|
||||
|
||||
const gpuCountLabel = (hardware, runtimeId) => {
|
||||
const count = hardware?.gpu_count;
|
||||
if (count == null) return "";
|
||||
if (runtimeId === "spark") return `${count} Spark${count === 1 ? "" : "s"}`;
|
||||
return `${count} GPU${count === 1 ? "" : "s"}`;
|
||||
};
|
||||
|
||||
const knobsFor = (recipe) => recipe.knobs || [];
|
||||
|
||||
const knobOptions = (knob) =>
|
||||
knob.options.map((option) =>
|
||||
option !== null && typeof option === "object" ? option : { value: option, label: String(option) },
|
||||
);
|
||||
|
||||
const knobDefaultLabel = (knob) => {
|
||||
const match = knobOptions(knob).find((option) => option.value === knob.default);
|
||||
return match ? match.label : String(knob.default);
|
||||
};
|
||||
|
||||
// Knob flags are always shown explicitly in the displayed command, even at
|
||||
// their default value, so the command stays copy-pasteable and precise
|
||||
// about what it runs -- not just "trust the script's own default".
|
||||
const appendKnobFlags = (commandText, knobs, knobValues) => {
|
||||
const flags = knobs
|
||||
.filter((knob) => knobValues[knob.key] !== undefined)
|
||||
.map((knob) => `${knob.flag} ${knobValues[knob.key]}`);
|
||||
if (!flags.length) return commandText;
|
||||
const lines = commandText.split("\n");
|
||||
lines[lines.length - 1] = `${lines[lines.length - 1]} ${flags.join(" ")}`;
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const runtimeFor = (recipe) => {
|
||||
const platform = recipe.hardware?.platform || "cuda";
|
||||
if (platform === "mlx") {
|
||||
return {
|
||||
id: "mlx",
|
||||
label: "Apple Silicon · MLX",
|
||||
hint:
|
||||
recipe.hardware?.minimum_memory ||
|
||||
[recipe.hardware?.accelerator, recipe.hardware?.system_memory].filter(Boolean).join(" · ") ||
|
||||
"Memory not recorded",
|
||||
};
|
||||
}
|
||||
if (platform === "mps") {
|
||||
return {
|
||||
id: "mps",
|
||||
label: "Apple Silicon · PyTorch MPS",
|
||||
hint: recipe.hardware?.minimum_memory || recipe.hardware?.system_memory || "Memory not recorded",
|
||||
};
|
||||
}
|
||||
const hardware = recipe.hardware || {};
|
||||
if (hardware.device === "spark") {
|
||||
return {
|
||||
id: "spark",
|
||||
label: "NVIDIA DGX Spark",
|
||||
hint: "GB10 · 128 GB unified memory",
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: "cuda",
|
||||
label: "NVIDIA CUDA",
|
||||
hint: hardware.accelerator
|
||||
? `${hardware.accelerator} · ${gpuCountLabel(hardware, "cuda")}`
|
||||
: `${gpuCountLabel(hardware, "cuda")} configured · GPU model not recorded`,
|
||||
};
|
||||
};
|
||||
|
||||
const runtimeSummary = (recipe) => {
|
||||
const runtime = runtimeFor(recipe);
|
||||
const hardware = recipe.hardware || {};
|
||||
if (runtime.id === "mlx") {
|
||||
return [hardware.accelerator || "Apple Silicon", hardware.system_memory || hardware.minimum_memory, "MLX"]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
if (runtime.id === "mps") {
|
||||
return [hardware.accelerator || "Apple Silicon", hardware.system_memory || hardware.minimum_memory, "PyTorch MPS"]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
if (runtime.id === "spark") {
|
||||
return [hardware.accelerator || "NVIDIA GB10", gpuCountLabel(hardware, "spark")].filter(Boolean).join(" · ");
|
||||
}
|
||||
if (hardware.accelerator) return [hardware.accelerator, gpuCountLabel(hardware, runtime.id)].join(" · ");
|
||||
return `NVIDIA CUDA · ${gpuCountLabel(hardware, "cuda")} configured · GPU model and VRAM not recorded`;
|
||||
};
|
||||
|
||||
const renderHardwareEvidence = (container, badge, recipe) => {
|
||||
const hardware = recipe.hardware || {};
|
||||
const runtime = runtimeFor(recipe);
|
||||
const isValidated = hardware.evidence === "validated";
|
||||
container.classList.toggle("cookbook-hardware-state--verified", isValidated);
|
||||
container.classList.toggle("cookbook-hardware-state--source", !isValidated);
|
||||
badge.classList.toggle("cookbook-badge--verified", isValidated);
|
||||
badge.classList.toggle("cookbook-badge--configured", !isValidated);
|
||||
badge.textContent = isValidated ? "Recorded run" : "Source config";
|
||||
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = isValidated ? "Recorded hardware" : "Source configuration";
|
||||
const details = document.createElement("span");
|
||||
|
||||
if (isValidated) {
|
||||
const recorded = [
|
||||
hardware.accelerator,
|
||||
runtime.id === "mlx" || runtime.id === "mps" ? hardware.system_memory : gpuCountLabel(hardware, runtime.id),
|
||||
].filter(Boolean);
|
||||
const statements = [`${recorded.join(" · ")}.`];
|
||||
if (hardware.minimum_memory) statements.push(`Documented minimum: ${hardware.minimum_memory}.`);
|
||||
if (hardware.peak_memory) statements.push(`Measured: ${hardware.peak_memory}.`);
|
||||
if (!hardware.minimum_memory) statements.push("This recorded device is not a minimum requirement.");
|
||||
details.textContent = ` ${statements.join(" ")}`;
|
||||
} else if (runtime.id === "spark") {
|
||||
details.textContent = ` NVIDIA DGX Spark · ${gpuCountLabel(hardware, "spark")}. GB10 has no FA4 / sm_100a VSA kernel; keep Triton VSA and FA4 off.`;
|
||||
} else if (runtime.id === "cuda") {
|
||||
details.textContent = ` NVIDIA CUDA · ${gpuCountLabel(hardware, "cuda")}. The source does not record the GPU model or VRAM.`;
|
||||
} else {
|
||||
details.textContent = ` ${runtime.label}. The source does not record a device or memory requirement.`;
|
||||
}
|
||||
|
||||
container.replaceChildren(heading, details);
|
||||
if (hardware.evidence_url) {
|
||||
const evidenceLink = document.createElement("a");
|
||||
evidenceLink.href = hardware.evidence_url;
|
||||
evidenceLink.textContent = "View run evidence";
|
||||
evidenceLink.setAttribute("aria-label", `View recorded hardware evidence for ${recipe.label}`);
|
||||
container.append(" ", evidenceLink);
|
||||
}
|
||||
};
|
||||
|
||||
const compactLifecycle = (root) => {
|
||||
const lifecycle = root.querySelector(".cookbook-lifecycle");
|
||||
if (!lifecycle || lifecycle.dataset.compact) return;
|
||||
lifecycle.dataset.compact = "true";
|
||||
const stages = [...lifecycle.querySelectorAll(".cookbook-lifecycle__stage")];
|
||||
const active = stages.find((stage) => stage.classList.contains("cookbook-lifecycle__stage--active"));
|
||||
const planned = stages
|
||||
.filter((stage) => stage !== active)
|
||||
.map((stage) => stage.childNodes[0]?.textContent?.trim())
|
||||
.filter(Boolean);
|
||||
const summary = document.createElement("span");
|
||||
summary.className = "cookbook-lifecycle__summary";
|
||||
summary.textContent = `Next: ${planned.join(", ")}`;
|
||||
lifecycle.replaceChildren(...(active ? [active] : []), summary);
|
||||
};
|
||||
|
||||
const initFamilyBuilder = async (root) => {
|
||||
const family = root.dataset.family;
|
||||
if (!family) return;
|
||||
|
||||
compactLifecycle(root);
|
||||
|
||||
const modelOptions = root.querySelector("[data-cookbook-model-options]");
|
||||
const hardwareOptions = root.querySelector("[data-cookbook-hardware-options]");
|
||||
const description = root.querySelector("[data-cookbook-description]");
|
||||
const label = root.querySelector("[data-cookbook-label]");
|
||||
const model = root.querySelector("[data-cookbook-model]");
|
||||
const task = root.querySelector("[data-cookbook-task]");
|
||||
const hardwareValue = root.querySelector("[data-cookbook-gpus]");
|
||||
const artifact = root.querySelector("[data-cookbook-artifact]");
|
||||
const evidenceCell = root.querySelector("[data-cookbook-evidence]");
|
||||
const source = root.querySelector("[data-cookbook-source]");
|
||||
const modelLink = root.querySelector("[data-cookbook-model-link]");
|
||||
const command = root.querySelector("[data-cookbook-command]");
|
||||
const status = root.querySelector("[data-cookbook-status]");
|
||||
const hardwareState = root.querySelector("[data-cookbook-hardware-state]");
|
||||
const hardwareBadge = root.querySelector("[data-cookbook-hardware-badge]");
|
||||
const count = root.querySelector("[data-cookbook-count]");
|
||||
const result = root.querySelector(".cookbook-result");
|
||||
const commandBlock = root.querySelector(".cookbook-command");
|
||||
const servingPanel = root.querySelector("[data-cookbook-serving]");
|
||||
const usage = root.querySelector("[data-cookbook-usage]");
|
||||
const servingAvailability = root.querySelector("[data-cookbook-serving-availability]");
|
||||
const knobsContainer = root.querySelector("[data-cookbook-knobs]");
|
||||
const deviceRow = root.querySelector("[data-cookbook-device-row]");
|
||||
const deviceOptions = root.querySelector("[data-cookbook-device-options]");
|
||||
const deviceCaption = root.querySelector("[data-cookbook-device-caption]");
|
||||
|
||||
modelOptions.setAttribute("aria-label", "Recipe");
|
||||
hardwareOptions.setAttribute("aria-label", "Runtime");
|
||||
|
||||
let recipes;
|
||||
try {
|
||||
({ recipes } = await loadRecipes(root.dataset.recipes));
|
||||
} catch (error) {
|
||||
if (status) status.textContent = "Recipes could not be loaded. Use the maintained examples link below.";
|
||||
console.error("Failed to load FastVideo cookbook recipes", error);
|
||||
return;
|
||||
}
|
||||
if (!root.isConnected) return;
|
||||
|
||||
const familyRecipes = recipes.filter((recipe) => recipe.family === family);
|
||||
if (!familyRecipes.length) return;
|
||||
|
||||
let servingProfiles = {};
|
||||
let servingLoadFailed = false;
|
||||
if (servingPanel && familyRecipes.some((recipe) => recipe.serving)) {
|
||||
try {
|
||||
const dataUrl = new URL(root.dataset.recipes, document.baseURI);
|
||||
servingProfiles = await loadRecipes(new URL("cookbook-serving.json", dataUrl));
|
||||
} catch (error) {
|
||||
servingLoadFailed = true;
|
||||
console.error("Failed to load FastVideo serving profiles", error);
|
||||
}
|
||||
}
|
||||
|
||||
const byId = new Map(familyRecipes.map((recipe) => [recipe.id, recipe]));
|
||||
const groups = new Map();
|
||||
familyRecipes.forEach((recipe) => {
|
||||
const groupId = groupIdFor(recipe);
|
||||
if (!groups.has(groupId)) groups.set(groupId, []);
|
||||
groups.get(groupId).push(recipe);
|
||||
});
|
||||
if (count) count.textContent = `${familyRecipes.length} maintained recipes`;
|
||||
|
||||
modelOptions.replaceChildren();
|
||||
groups.forEach((groupRecipes, groupId) => {
|
||||
const representative = groupRecipes[0];
|
||||
const option = document.createElement("button");
|
||||
option.type = "button";
|
||||
option.dataset.recipeGroup = groupId;
|
||||
option.setAttribute("aria-pressed", "false");
|
||||
const optionLabel = document.createElement("strong");
|
||||
optionLabel.textContent = representative.group_label || representative.label;
|
||||
const optionTask = document.createElement("span");
|
||||
optionTask.textContent = representative.group_task || representative.task;
|
||||
option.append(optionLabel, optionTask);
|
||||
modelOptions.append(option);
|
||||
});
|
||||
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const requestedRecipe = query.get("recipe");
|
||||
const defaultRecipeId = byId.has(root.dataset.defaultRecipe) ? root.dataset.defaultRecipe : familyRecipes[0].id;
|
||||
let selectedRecipeId = requestedRecipe && byId.has(requestedRecipe) ? requestedRecipe : defaultRecipeId;
|
||||
let selectedGroupId = groupIdFor(byId.get(selectedRecipeId));
|
||||
let renderedRuntimeGroup = null;
|
||||
// Keep previously shared local/openai links working after renaming workflows.
|
||||
const workflow = (value) => ["local", "python"].includes(value) ? "python" : "server";
|
||||
let usagePreference = workflow(query.get("use"));
|
||||
let selectedClient = ["python", "javascript", "curl"].includes(query.get("client")) ? query.get("client") : "curl";
|
||||
const clientDetails = servingPanel?.querySelector(".cookbook-serving__code");
|
||||
if (clientDetails && query.has("client")) clientDetails.open = true;
|
||||
|
||||
const knobDefs = new Map();
|
||||
familyRecipes.forEach((recipe) => knobsFor(recipe).forEach((knob) => {
|
||||
if (!knobDefs.has(knob.key)) knobDefs.set(knob.key, knob);
|
||||
}));
|
||||
const knobValues = {};
|
||||
knobDefs.forEach((knob, key) => {
|
||||
const fromQuery = query.get(key);
|
||||
const validValues = knobOptions(knob).map((option) => String(option.value));
|
||||
const useQueryValue = fromQuery !== null && validValues.includes(fromQuery);
|
||||
const raw = useQueryValue ? fromQuery : knob.default;
|
||||
knobValues[key] = typeof knob.default === "number" ? Number(raw) : raw;
|
||||
});
|
||||
|
||||
const renderKnobs = (recipe, hidden) => {
|
||||
if (!knobsContainer) return;
|
||||
const knobs = knobsFor(recipe);
|
||||
const renderedKeys = [...knobsContainer.querySelectorAll("[data-knob-row]")].map((row) => row.dataset.knobRow);
|
||||
if (renderedKeys.join(",") !== knobs.map((knob) => knob.key).join(",")) {
|
||||
knobsContainer.replaceChildren();
|
||||
knobs.forEach((knob) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "cookbook-selection-row";
|
||||
row.dataset.knobRow = knob.key;
|
||||
const labelWrap = document.createElement("div");
|
||||
labelWrap.className = "cookbook-selection-row__label";
|
||||
const strongLabel = document.createElement("strong");
|
||||
strongLabel.textContent = knob.label;
|
||||
const hintLabel = document.createElement("span");
|
||||
hintLabel.textContent = knob.hint || "";
|
||||
labelWrap.append(strongLabel, hintLabel);
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "cookbook-option-grid cookbook-option-grid--hardware";
|
||||
grid.setAttribute("role", "group");
|
||||
grid.setAttribute("aria-label", knob.label);
|
||||
knobOptions(knob).forEach((option) => {
|
||||
const optionButton = document.createElement("button");
|
||||
optionButton.type = "button";
|
||||
optionButton.dataset.knobKey = knob.key;
|
||||
optionButton.dataset.knobValue = String(option.value);
|
||||
optionButton.setAttribute("aria-pressed", "false");
|
||||
const optionLabel = document.createElement("strong");
|
||||
optionLabel.textContent = option.label;
|
||||
optionButton.append(optionLabel);
|
||||
grid.append(optionButton);
|
||||
});
|
||||
row.append(labelWrap, grid);
|
||||
knobsContainer.append(row);
|
||||
});
|
||||
}
|
||||
knobsContainer.hidden = hidden || knobs.length === 0;
|
||||
knobsContainer.querySelectorAll("button[data-knob-key]").forEach((optionButton) => {
|
||||
const selected = String(knobValues[optionButton.dataset.knobKey]) === optionButton.dataset.knobValue;
|
||||
optionButton.classList.toggle("cookbook-option--selected", selected);
|
||||
optionButton.setAttribute("aria-pressed", String(selected));
|
||||
});
|
||||
};
|
||||
|
||||
const recipesForRuntime = (runtimeId, groupRecipes) =>
|
||||
groupRecipes.filter((item) => runtimeFor(item).id === runtimeId);
|
||||
|
||||
const uniqueRuntimeIds = (groupRecipes) => {
|
||||
const ids = [];
|
||||
groupRecipes.forEach((item) => {
|
||||
const id = runtimeFor(item).id;
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
|
||||
const pickRecipeForRuntime = (runtimeId, preferredCount, groupRecipes) => {
|
||||
const siblings = recipesForRuntime(runtimeId, groupRecipes);
|
||||
if (!siblings.length) return null;
|
||||
if (preferredCount != null) {
|
||||
const match = siblings.find((item) => item.hardware?.gpu_count === preferredCount);
|
||||
if (match) return match;
|
||||
}
|
||||
return siblings.find((item) => item.hardware?.gpu_count === 1) || siblings[0];
|
||||
};
|
||||
|
||||
const renderRuntimeOptions = () => {
|
||||
const groupRecipes = groups.get(selectedGroupId) || [];
|
||||
const runtimeIds = uniqueRuntimeIds(groupRecipes);
|
||||
const renderedIds = [...hardwareOptions.querySelectorAll("[data-runtime-id]")].map((option) => option.dataset.runtimeId);
|
||||
if (renderedIds.join(",") === runtimeIds.join(",")) return;
|
||||
hardwareOptions.replaceChildren();
|
||||
runtimeIds.forEach((runtimeId) => {
|
||||
const representative = pickRecipeForRuntime(runtimeId, 1, groupRecipes);
|
||||
const runtime = runtimeFor(representative);
|
||||
const option = document.createElement("button");
|
||||
option.type = "button";
|
||||
option.dataset.runtimeId = runtime.id;
|
||||
option.setAttribute("aria-pressed", "false");
|
||||
const optionLabel = document.createElement("strong");
|
||||
optionLabel.textContent = runtime.label;
|
||||
const optionHint = document.createElement("span");
|
||||
optionHint.textContent = runtime.hint;
|
||||
option.append(optionLabel, optionHint);
|
||||
hardwareOptions.append(option);
|
||||
});
|
||||
};
|
||||
|
||||
const renderDeviceOptions = (recipe) => {
|
||||
if (!deviceRow || !deviceOptions) return;
|
||||
const groupRecipes = groups.get(selectedGroupId) || [];
|
||||
const runtime = runtimeFor(recipe);
|
||||
const siblings = recipesForRuntime(runtime.id, groupRecipes)
|
||||
.slice()
|
||||
.sort((left, right) => (left.hardware?.gpu_count || 0) - (right.hardware?.gpu_count || 0));
|
||||
const show = siblings.length > 1;
|
||||
deviceRow.hidden = !show;
|
||||
if (deviceCaption) {
|
||||
deviceCaption.textContent = runtime.id === "spark" ? "1 Spark or a QSFP pair" : "GPU count for this runtime";
|
||||
}
|
||||
if (!show) {
|
||||
deviceOptions.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const renderedIds = [...deviceOptions.querySelectorAll("[data-recipe-id]")].map((option) => option.dataset.recipeId);
|
||||
if (renderedIds.join(",") !== siblings.map((item) => item.id).join(",")) {
|
||||
deviceOptions.replaceChildren();
|
||||
siblings.forEach((candidate) => {
|
||||
const option = document.createElement("button");
|
||||
option.type = "button";
|
||||
option.dataset.recipeId = candidate.id;
|
||||
option.setAttribute("aria-pressed", "false");
|
||||
const optionLabel = document.createElement("strong");
|
||||
optionLabel.textContent = gpuCountLabel(candidate.hardware, runtime.id);
|
||||
const optionHint = document.createElement("span");
|
||||
optionHint.textContent = runtime.id === "spark" && candidate.hardware?.gpu_count === 2
|
||||
? "Ray sequence parallel over QSFP"
|
||||
: runtime.id === "spark"
|
||||
? "One GB10, local process"
|
||||
: `${gpuCountLabel(candidate.hardware, runtime.id)} configured`;
|
||||
option.append(optionLabel, optionHint);
|
||||
deviceOptions.append(option);
|
||||
});
|
||||
}
|
||||
deviceOptions.querySelectorAll("button").forEach((option) => {
|
||||
const selected = option.dataset.recipeId === recipe.id;
|
||||
option.classList.toggle("cookbook-option--selected", selected);
|
||||
option.setAttribute("aria-pressed", String(selected));
|
||||
});
|
||||
};
|
||||
|
||||
let notes = root.querySelector("[data-cookbook-notes]");
|
||||
if (!notes) {
|
||||
notes = document.createElement("aside");
|
||||
notes.className = "cookbook-recipe-notes";
|
||||
notes.dataset.cookbookNotes = "";
|
||||
notes.hidden = true;
|
||||
result.insertBefore(notes, commandBlock);
|
||||
}
|
||||
|
||||
const render = ({ groupChanged = false, historyMode = "replace" } = {}) => {
|
||||
if (!root.isConnected) return;
|
||||
if (!byId.has(selectedRecipeId)) selectedRecipeId = defaultRecipeId;
|
||||
let recipe = byId.get(selectedRecipeId);
|
||||
if (groupChanged || groupIdFor(recipe) !== selectedGroupId) {
|
||||
const currentRuntime = runtimeFor(recipe).id;
|
||||
const currentCount = recipe.hardware?.gpu_count;
|
||||
const groupRecipes = groups.get(selectedGroupId) || [];
|
||||
recipe = pickRecipeForRuntime(currentRuntime, currentCount, groupRecipes) || groupRecipes[0];
|
||||
selectedRecipeId = recipe.id;
|
||||
}
|
||||
|
||||
selectedGroupId = groupIdFor(recipe);
|
||||
if (renderedRuntimeGroup !== selectedGroupId) {
|
||||
renderRuntimeOptions();
|
||||
renderedRuntimeGroup = selectedGroupId;
|
||||
}
|
||||
renderDeviceOptions(recipe);
|
||||
const runtime = runtimeFor(recipe);
|
||||
const profile = servingPanel && servingProfiles[recipe.id];
|
||||
const useServer = Boolean(profile && usagePreference === "server");
|
||||
// Only some servers expose the browser playground or return audio; both come from the serving profile.
|
||||
const hasPlayground = Boolean(profile && profile.playground_url);
|
||||
const hasAudio = Boolean(profile && profile.audio);
|
||||
const playgroundAny = familyRecipes.some((item) => servingProfiles[item.id] && servingProfiles[item.id].playground_url);
|
||||
// The measured local profile and the server config have separate evidence.
|
||||
const activeRecipe = useServer ? { ...recipe, hardware: profile.hardware, evidence: "Source-backed" } : recipe;
|
||||
const knobs = knobsFor(recipe);
|
||||
renderKnobs(recipe, useServer);
|
||||
|
||||
if (usage) {
|
||||
usage.querySelectorAll("[data-cookbook-mode]").forEach((option) => {
|
||||
const selected = option.dataset.cookbookMode === (useServer ? "server" : "python");
|
||||
option.disabled = option.dataset.cookbookMode === "server" && !profile;
|
||||
const hint = option.querySelector("[data-cookbook-server-hint]");
|
||||
if (hint) hint.textContent = (profile ? hasPlayground : playgroundAny) ? "Playground, cURL, or an API client" : "cURL or an API client";
|
||||
option.classList.toggle("cookbook-option--selected", selected);
|
||||
option.setAttribute("aria-pressed", String(selected));
|
||||
});
|
||||
const servedNames = [...new Set(familyRecipes.filter((item) => servingProfiles[item.id]).map((item) => item.group_label || item.label))];
|
||||
servingAvailability.textContent = profile
|
||||
? `${hasPlayground ? "The playground" : "cURL"} and the OpenAI Python client share one server process. Both workflows can run on your own machine.`
|
||||
: servingLoadFailed
|
||||
? "Server examples could not be loaded. Use Python directly."
|
||||
: servedNames.length
|
||||
? `This recipe uses Python directly. ${new Intl.ListFormat("en").format(servedNames)} can also run a local server for ${playgroundAny ? "the playground and " : "cURL and "}the OpenAI Python client.`
|
||||
: "This recipe uses Python directly.";
|
||||
servingPanel.hidden = !useServer;
|
||||
commandBlock.hidden = useServer;
|
||||
root.querySelector("[data-cookbook-python-note]").hidden = useServer;
|
||||
}
|
||||
|
||||
if (useServer) {
|
||||
const isMLX = profile.runtime === "mlx";
|
||||
const isSpark = runtime.id === "spark";
|
||||
const where = hasPlayground ? "in the playground or your app" : "in your app";
|
||||
servingPanel.querySelector("[data-cookbook-server-lifetime]").textContent = isMLX
|
||||
? `Start once, then change prompts ${where}. MLX reuses its pipeline and prompt cache, but loads and releases model components between phases to limit unified-memory use. It does not keep all weights resident.`
|
||||
: isSpark
|
||||
? `Start once, then change prompts ${where}. On a DGX Spark, lazy module load still reloads Qwen3-VL and the DiT between phases of each request, so later prompts are not a free hot cache.`
|
||||
: `Start once, then change prompts ${where}. CUDA requests reuse the loaded model. The Python SDK can also reuse a generator within one process.`;
|
||||
servingPanel.querySelector("[data-cookbook-install-guide]").href = isMLX
|
||||
? "../../getting_started/installation/mlx/"
|
||||
: isSpark
|
||||
? "../../getting_started/installation/spark/"
|
||||
: "../../getting_started/installation/gpu/";
|
||||
servingPanel.querySelector("[data-cookbook-prepare]").hidden = !profile.prepare;
|
||||
servingPanel.querySelector("[data-cookbook-server-prepare]").textContent = profile.prepare;
|
||||
servingPanel.querySelector("[data-cookbook-server-install]").textContent = profile.install;
|
||||
servingPanel.querySelector("[data-cookbook-server-command]").textContent = profile.command;
|
||||
servingPanel.querySelector("[data-cookbook-health-command]").textContent = profile.health_command;
|
||||
servingPanel.querySelectorAll("[data-cookbook-playground-only]").forEach((element) => {
|
||||
element.hidden = !hasPlayground;
|
||||
});
|
||||
if (hasPlayground) servingPanel.querySelector("[data-cookbook-playground]").href = profile.playground_url;
|
||||
const client = profile.clients[selectedClient];
|
||||
const filename = client.source.split("/").pop();
|
||||
servingPanel.querySelector("[data-cookbook-client-install]").textContent = client.install;
|
||||
const clientCode = servingPanel.querySelector("[data-cookbook-client-code]");
|
||||
clientCode.className = `language-${selectedClient === "curl" ? "bash" : selectedClient}`;
|
||||
clientCode.textContent = client.code;
|
||||
servingPanel.querySelector("[data-cookbook-client-filename]").textContent = filename;
|
||||
servingPanel.querySelector("[data-cookbook-client-source]").href = `https://github.com/hao-ai-lab/FastVideo/blob/main/${client.source}`;
|
||||
const runner = { python: "python", javascript: "node", curl: "bash" }[selectedClient];
|
||||
servingPanel.querySelector("[data-cookbook-client-run]").textContent = `Save as ${filename} and run ${runner} ${filename}. The MP4 is saved with the job ID as its filename.`;
|
||||
servingPanel.querySelectorAll("[data-cookbook-client]").forEach((option) => {
|
||||
option.setAttribute("aria-pressed", String(option.dataset.cookbookClient === selectedClient));
|
||||
});
|
||||
}
|
||||
|
||||
modelOptions.querySelectorAll("button").forEach((option) => {
|
||||
const selected = option.dataset.recipeGroup === selectedGroupId;
|
||||
option.classList.toggle("cookbook-option--selected", selected);
|
||||
option.setAttribute("aria-pressed", String(selected));
|
||||
});
|
||||
hardwareOptions.querySelectorAll("button").forEach((option) => {
|
||||
const selected = option.dataset.runtimeId === runtime.id;
|
||||
option.classList.toggle("cookbook-option--selected", selected);
|
||||
option.setAttribute("aria-pressed", String(selected));
|
||||
});
|
||||
|
||||
description.textContent = useServer
|
||||
? `${recipe.group_label || recipe.label} generates video${hasAudio ? " with audio" : ""}. Start the local server, then use ${hasPlayground ? "the playground or " : "cURL or "}the OpenAI Python client. This profile uses the checked-in ${runtime.label} configuration.`
|
||||
: recipe.summary;
|
||||
label.textContent = useServer ? `${recipe.group_label || recipe.label} · Server` : recipe.label;
|
||||
model.textContent = recipe.model;
|
||||
task.textContent = (useServer && recipe.serving && recipe.serving.task) || recipe.task;
|
||||
hardwareValue.textContent = runtimeSummary(activeRecipe);
|
||||
if (artifact) {
|
||||
artifact.textContent = useServer
|
||||
? (hasAudio ? "MP4 with audio" : "MP4 video")
|
||||
: recipe.expected_artifact || "Not yet documented for this recipe.";
|
||||
}
|
||||
if (evidenceCell) {
|
||||
evidenceCell.textContent = activeRecipe.evidence || "Source-backed";
|
||||
evidenceCell.classList.toggle("cookbook-badge--verified", activeRecipe.evidence === "Verified");
|
||||
evidenceCell.classList.toggle("cookbook-badge--source-backed", activeRecipe.evidence !== "Verified");
|
||||
}
|
||||
source.href = `https://github.com/hao-ai-lab/FastVideo/blob/main/${useServer ? profile.source : recipe.source}`;
|
||||
source.textContent = useServer ? "View server configuration" : "Open example source";
|
||||
modelLink.href = `https://huggingface.co/${recipe.model}`;
|
||||
command.textContent = useServer ? recipe.command : appendKnobFlags(recipe.command, knobs, knobValues);
|
||||
|
||||
renderHardwareEvidence(hardwareState, hardwareBadge, activeRecipe);
|
||||
|
||||
const knobCaveats = useServer ? [] : knobs
|
||||
.filter((knob) => String(knobValues[knob.key]) !== String(knob.default))
|
||||
.map((knob) => `${knob.label} is set away from its recorded default (${knobDefaultLabel(knob)}). ` +
|
||||
"The script accepts this value, but it has not been benchmarked here.");
|
||||
const limitations = [...(useServer ? [
|
||||
profile.runtime === "mlx"
|
||||
? "This MLX server config has no recorded hardware run. Measurements from the Python recipe are not server memory requirements. Only text-to-video/audio is wired; reference inputs and fast modes are not exposed here."
|
||||
: runtime.id === "spark"
|
||||
? "This Spark server config has no recorded serving benchmark. Lazy module load reloads Qwen3-VL and the DiT between phases of each request. Compilation of the DiT is disabled."
|
||||
: `This server config has no recorded serving benchmark.${profile.compile_enabled === false ? " Compilation is disabled, unlike the measured Python performance profile." : ""}`,
|
||||
`${profile.sampling.width} × ${profile.sampling.height} · ${profile.sampling.num_frames} frames · ${profile.sampling.fps} fps. The server supplies these defaults; the client sends the model and prompt.`,
|
||||
"Generation is serialized. Job metadata is held in memory and is lost when the server restarts.",
|
||||
] : recipe.limitations || []),
|
||||
...(useServer && recipe.serving && Array.isArray(recipe.serving.limitations) ? recipe.serving.limitations : []),
|
||||
...knobCaveats];
|
||||
notes.replaceChildren();
|
||||
notes.hidden = limitations.length === 0;
|
||||
if (limitations.length) {
|
||||
const notesHeading = document.createElement("strong");
|
||||
notesHeading.textContent = "Know before you run";
|
||||
const notesList = document.createElement("ul");
|
||||
limitations.forEach((item) => {
|
||||
const listItem = document.createElement("li");
|
||||
listItem.textContent = item;
|
||||
notesList.append(listItem);
|
||||
});
|
||||
notes.append(notesHeading, notesList);
|
||||
}
|
||||
|
||||
const nextQuery = new URLSearchParams(window.location.search);
|
||||
nextQuery.set("recipe", recipe.id);
|
||||
nextQuery.set("runtime", runtime.id);
|
||||
const deviceSiblings = recipesForRuntime(runtime.id, groups.get(selectedGroupId) || []);
|
||||
if (deviceSiblings.length > 1 && recipe.hardware?.gpu_count != null) {
|
||||
nextQuery.set("gpus", String(recipe.hardware.gpu_count));
|
||||
} else {
|
||||
nextQuery.delete("gpus");
|
||||
}
|
||||
knobDefs.forEach((knob, key) => {
|
||||
if (knobs.some((activeKnob) => activeKnob.key === key)) nextQuery.set(key, String(knobValues[key]));
|
||||
else nextQuery.delete(key);
|
||||
});
|
||||
if (usage) {
|
||||
nextQuery.set("use", useServer ? "server" : "python");
|
||||
if (useServer && clientDetails?.open) nextQuery.set("client", selectedClient);
|
||||
else nextQuery.delete("client");
|
||||
}
|
||||
const nextUrl = `${window.location.pathname}?${nextQuery.toString()}${window.location.hash}`;
|
||||
if (historyMode === "push") window.history.pushState({}, "", nextUrl);
|
||||
else if (historyMode === "replace") window.history.replaceState({}, "", nextUrl);
|
||||
const modeSummary = useServer ? " with a persistent server" : "";
|
||||
status.textContent = `${recipe.label} selected for ${runtime.label}${modeSummary}.`;
|
||||
};
|
||||
|
||||
modelOptions.addEventListener("click", (event) => {
|
||||
const option = event.target.closest("button[data-recipe-group]");
|
||||
if (!option) return;
|
||||
selectedGroupId = option.dataset.recipeGroup;
|
||||
render({ groupChanged: true, historyMode: "push" });
|
||||
});
|
||||
hardwareOptions.addEventListener("click", (event) => {
|
||||
const option = event.target.closest("button[data-runtime-id]");
|
||||
if (!option) return;
|
||||
const groupRecipes = groups.get(selectedGroupId) || [];
|
||||
const currentCount = byId.get(selectedRecipeId)?.hardware?.gpu_count;
|
||||
const nextRecipe = pickRecipeForRuntime(option.dataset.runtimeId, currentCount, groupRecipes);
|
||||
if (!nextRecipe) return;
|
||||
selectedRecipeId = nextRecipe.id;
|
||||
selectedGroupId = groupIdFor(nextRecipe);
|
||||
render({ historyMode: "push" });
|
||||
});
|
||||
deviceOptions?.addEventListener("click", (event) => {
|
||||
const option = event.target.closest("button[data-recipe-id]");
|
||||
if (!option) return;
|
||||
selectedRecipeId = option.dataset.recipeId;
|
||||
selectedGroupId = groupIdFor(byId.get(selectedRecipeId));
|
||||
render({ historyMode: "push" });
|
||||
});
|
||||
usage?.addEventListener("click", (event) => {
|
||||
const option = event.target.closest("button[data-cookbook-mode]");
|
||||
if (!option || option.disabled) return;
|
||||
usagePreference = option.dataset.cookbookMode;
|
||||
render({ historyMode: "push" });
|
||||
});
|
||||
servingPanel?.addEventListener("click", (event) => {
|
||||
const option = event.target.closest("button[data-cookbook-client]");
|
||||
if (!option) return;
|
||||
selectedClient = option.dataset.cookbookClient;
|
||||
render({ historyMode: "push" });
|
||||
});
|
||||
knobsContainer?.addEventListener("click", (event) => {
|
||||
const option = event.target.closest("button[data-knob-key]");
|
||||
if (!option) return;
|
||||
const knob = knobDefs.get(option.dataset.knobKey);
|
||||
knobValues[option.dataset.knobKey] = typeof knob.default === "number"
|
||||
? Number(option.dataset.knobValue) : option.dataset.knobValue;
|
||||
render({ historyMode: "push" });
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
familyPopstate = () => {
|
||||
if (!root.isConnected) return;
|
||||
const nextQuery = new URLSearchParams(window.location.search);
|
||||
const nextRecipe = nextQuery.get("recipe");
|
||||
selectedRecipeId = nextRecipe && byId.has(nextRecipe) ? nextRecipe : defaultRecipeId;
|
||||
selectedGroupId = groupIdFor(byId.get(selectedRecipeId));
|
||||
usagePreference = workflow(nextQuery.get("use"));
|
||||
selectedClient = ["python", "javascript", "curl"].includes(nextQuery.get("client")) ? nextQuery.get("client") : "curl";
|
||||
if (clientDetails) clientDetails.open = nextQuery.has("client");
|
||||
knobDefs.forEach((knob, key) => {
|
||||
const fromQuery = nextQuery.get(key);
|
||||
const validValues = knobOptions(knob).map((option) => String(option.value));
|
||||
if (fromQuery !== null && validValues.includes(fromQuery)) {
|
||||
knobValues[key] = typeof knob.default === "number" ? Number(fromQuery) : fromQuery;
|
||||
}
|
||||
});
|
||||
render({ historyMode: "none" });
|
||||
};
|
||||
bindFamilyPopstate();
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
initEvervault(document);
|
||||
document.querySelectorAll("[data-cookbook][data-family]").forEach((root) => {
|
||||
if (root.dataset.initialized) return;
|
||||
root.dataset.initialized = "true";
|
||||
initFamilyBuilder(root);
|
||||
});
|
||||
};
|
||||
|
||||
if (window.document$) window.document$.subscribe(init);
|
||||
else document.addEventListener("DOMContentLoaded", init);
|
||||
})();
|
||||