Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d0a10d989 |
@@ -49,7 +49,6 @@ export default function HardwarePicker({
|
||||
value,
|
||||
onChange,
|
||||
gpuCount = 1,
|
||||
purpose = "target",
|
||||
reload,
|
||||
}: {
|
||||
backend: string;
|
||||
@@ -59,7 +58,6 @@ export default function HardwarePicker({
|
||||
value: HardwareSelection;
|
||||
onChange: (selection: HardwareSelection) => void;
|
||||
gpuCount?: number;
|
||||
purpose?: "target" | "filter";
|
||||
reload?: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
@@ -71,9 +69,9 @@ export default function HardwarePicker({
|
||||
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 unknown = status === "ready" && !!value.id && value.id !== "custom" && !record;
|
||||
const loading = status === "loading" && !!value.id && value.id !== "custom";
|
||||
const candidates = catalog.filter((item) => item.backend === (backend === "mlx" ? "mlx" : "cuda"));
|
||||
const matches = searchHardware(candidates, query).slice(0, 20);
|
||||
const draftMemory = amount(draft.memory);
|
||||
const draftBudget = amount(draft.budget);
|
||||
@@ -144,8 +142,7 @@ export default function HardwarePicker({
|
||||
</span>
|
||||
{record.usable_budget_gb ? (
|
||||
<span className="hardware-selected-meta">
|
||||
{value.id === "custom" ? "Your budget" : "Usable budget"}: {record.usable_budget_gb}{" "}
|
||||
{record.memory_unit}
|
||||
Usable memory: {record.usable_budget_gb} {record.memory_unit}
|
||||
</span>
|
||||
) : null}
|
||||
{record.host_ram_gb && record.memory_type !== "unified" ? (
|
||||
@@ -155,20 +152,24 @@ export default function HardwarePicker({
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" className="hardware-clear" onClick={() => onChange({ id: "" })}>
|
||||
Clear hardware {purpose}
|
||||
Clear hardware
|
||||
</button>
|
||||
</div>
|
||||
) : value.id === "custom" ? (
|
||||
<p className="hardware-current" role="status">
|
||||
Add this device's memory to finish selecting it.
|
||||
</p>
|
||||
) : loading ? (
|
||||
<p className="hardware-current" role="status">
|
||||
Loading hardware catalog…
|
||||
</p>
|
||||
) : unknown ? (
|
||||
<p className="hardware-current" role="status">
|
||||
Saved hardware target ({value.id}) is not in this catalog.
|
||||
The saved device ({value.id}) is not in this catalog.
|
||||
</p>
|
||||
) : (
|
||||
<p className="hardware-current" role="status">
|
||||
No hardware {purpose} selected.
|
||||
No hardware selected.
|
||||
</p>
|
||||
)}
|
||||
{record ? (
|
||||
@@ -184,7 +185,7 @@ export default function HardwarePicker({
|
||||
<div className="hardware-picker-actions">
|
||||
{!record && value.id ? (
|
||||
<button type="button" className="hardware-clear" onClick={() => onChange({ id: "" })}>
|
||||
Clear hardware {purpose}
|
||||
Clear hardware
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
@@ -198,21 +199,11 @@ export default function HardwarePicker({
|
||||
setAdding(false);
|
||||
}}
|
||||
>
|
||||
{record
|
||||
? "Change hardware"
|
||||
: purpose === "filter"
|
||||
? "Choose hardware"
|
||||
: backend === "mlx"
|
||||
? "+ Find or add a Mac"
|
||||
: "+ Find or add a GPU"}
|
||||
{record ? "Change 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>
|
||||
<p className="hardware-picker-caption">Used for the setup link and, on single-unit systems, the GPU count.</p>
|
||||
) : null}
|
||||
{open ? (
|
||||
<div className="hardware-lookup" id={panelId} onKeyDown={(event) => event.key === "Escape" && close()}>
|
||||
@@ -296,13 +287,13 @@ export default function HardwarePicker({
|
||||
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>
|
||||
<option value="dedicated">GPU memory (VRAM)</option>
|
||||
<option value="unified">Unified memory</option>
|
||||
<option value="system">System memory (RAM)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="hardware-form-field">
|
||||
<span>Nominal memory</span>
|
||||
<span>Total memory</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -323,7 +314,7 @@ export default function HardwarePicker({
|
||||
</select>
|
||||
</label>
|
||||
<label className="hardware-form-field">
|
||||
<span>Usable budget (optional)</span>
|
||||
<span>Usable memory (optional)</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -348,16 +339,14 @@ export default function HardwarePicker({
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{budgetTooLarge ? (
|
||||
<p className="hardware-form-error">The usable budget cannot exceed the nominal memory.</p>
|
||||
) : null}
|
||||
{budgetTooLarge ? <p className="hardware-form-error">Usable memory cannot exceed total 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.
|
||||
Total memory is the device's capacity. Usable memory is the part FastVideo may use, such as a MIG slice
|
||||
or a limit you set. Unified memory is shared with the CPU.
|
||||
</p>
|
||||
<div className="hardware-form-actions">
|
||||
<button type="submit" disabled={!validDraft}>
|
||||
Use this device
|
||||
Save device
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import type { MemoryAssessment } from "@/lib/capacity";
|
||||
|
||||
export type Validity = "valid" | "invalid" | "loading";
|
||||
export type Fit = "fits" | "tight" | "exceeds" | "unsupported" | "unknown";
|
||||
export type Evidence = "recorded" | "none" | "stale";
|
||||
|
||||
const validityLabel: Record<Validity, string> = {
|
||||
valid: "Configuration valid",
|
||||
@@ -12,48 +12,51 @@ const validityLabel: Record<Validity, string> = {
|
||||
const fitLabel: Record<Fit, string> = {
|
||||
fits: "Fits estimate",
|
||||
tight: "Tight fit",
|
||||
exceeds: "Likely exceeds memory",
|
||||
unsupported: "Unsupported combination",
|
||||
unknown: "Fit unknown",
|
||||
exceeds: "Exceeds memory",
|
||||
unsupported: "Unsupported",
|
||||
unknown: "Memory not estimated",
|
||||
};
|
||||
const evidenceLabel: Record<Evidence, string> = {
|
||||
recorded: "Run evidence available",
|
||||
none: "No recorded run",
|
||||
stale: "Run evidence does not match these settings",
|
||||
const weightsLabel: Record<Fit, string> = {
|
||||
fits: "Weights fit",
|
||||
tight: "Weights fit, little headroom",
|
||||
exceeds: "Weights exceed memory",
|
||||
unsupported: "Weights unsupported",
|
||||
unknown: "Weights not estimated",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Two facts, each with its own evidence: the recipe schema accepts the values,
|
||||
* and the analytical estimate covers the selected device. Neither is a promise
|
||||
* that the recipe runs.
|
||||
*/
|
||||
export default function RecipeFacts({
|
||||
validity,
|
||||
fit = "unknown",
|
||||
evidence = "none",
|
||||
memory,
|
||||
subject = "configuration",
|
||||
detail,
|
||||
validityText,
|
||||
validityDetail,
|
||||
detail,
|
||||
}: {
|
||||
validity: Validity;
|
||||
fit?: Fit;
|
||||
evidence?: Evidence;
|
||||
memory?: MemoryAssessment;
|
||||
subject?: "configuration" | "example";
|
||||
detail?: string;
|
||||
validityText?: string;
|
||||
validityDetail?: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const id = `recipe-facts-${validity}-${fit}-${evidence}`;
|
||||
const id = useId();
|
||||
const fit: Fit = memory?.status ?? "unknown";
|
||||
const estimated = fit === "fits" || fit === "tight" || fit === "exceeds";
|
||||
const weightsBasis = memory?.basis === "weights";
|
||||
const label = weightsBasis ? weightsLabel[fit] : fitLabel[fit];
|
||||
return (
|
||||
<div className="recipe-facts">
|
||||
<div className="recipe-fact-list" aria-label="Recipe facts">
|
||||
<div className="recipe-fact-list" aria-label="Recipe checks">
|
||||
<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>
|
||||
<span data-state={fit}>{label}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -70,24 +73,19 @@ export default function RecipeFacts({
|
||||
<strong>{validityLabel[validity]}.</strong>{" "}
|
||||
{validityDetail ??
|
||||
(validity === "valid"
|
||||
? "Selected values pass the recipe schema and runtime topology checks."
|
||||
? "These values pass the recipe schema and runtime topology checks."
|
||||
: validity === "invalid"
|
||||
? "Output is withheld until the highlighted fields are corrected."
|
||||
? "Output is hidden until the highlighted fields are fixed."
|
||||
: "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."}
|
||||
<strong>{label}.</strong>{" "}
|
||||
{weightsBasis
|
||||
? `${memory?.reason ?? ""} Resident weights need ${memory?.requiredGiB?.toFixed(1)} GiB of ${memory?.capacityGiB?.toFixed(1)} GiB available.`
|
||||
: estimated
|
||||
? `The analytical estimate needs ${memory?.requiredGiB?.toFixed(1)} GiB of ${memory?.capacityGiB?.toFixed(1)} GiB available. It models weights, encoders, and the configured workload. It is not a measured guarantee.`
|
||||
: (memory?.reason ??
|
||||
"FastVideo has no capacity model for this checkpoint, so it does not estimate memory use here.")}
|
||||
</p>
|
||||
{detail && <p>{detail}</p>}
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,17 @@ description: Recipes for video, image, audio, and world generation.
|
||||
mode: wide
|
||||
---
|
||||
|
||||
Pick a model, choose your hardware, and copy the command. Every recipe starts from checked-in YAML, so the settings you see are the settings that run.
|
||||
|
||||
Before you pick a device, check [weights and quantization](weights/) for measured
|
||||
checkpoint sizes and the FastH3 precision variants.
|
||||
|
||||
<a id="inference-cookbook"></a>
|
||||
<a id="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>
|
||||
<a id="planned-families-heading"></a>
|
||||
|
||||
<Cookbook />
|
||||
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
---
|
||||
title: FastH3 OmniRef
|
||||
description: Configure FastH3 OmniRef reference-to-video serving on an RTX 5090, RTX PRO 6000, or DGX Spark.
|
||||
title: FastH3 Omni v1
|
||||
description: Configure FastH3 Omni v1 reference-to-video serving and copy inference or serving commands.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="fasth3-omniref" />
|
||||
|
||||
## Apple silicon
|
||||
|
||||
The MLX path composes the BF16 Omni release with the NVFP4 text encoder, converts
|
||||
the DiT to INT6, and runs the same image and audio references on Metal. Follow
|
||||
[FastH3 Omni on MLX](/inference/fasth3_omniref_mlx).
|
||||
For copyable local commands, open the [MLX recipe](/cookbook/minimax-h3/models/fasth3-omniref-mlx).
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: Weights and quantization
|
||||
description: Measured checkpoint sizes for every cookbook model, plus the FastH3 quantized variants and which one fits a given GPU.
|
||||
mode: wide
|
||||
---
|
||||
|
||||
Every number here is the size of the safetensors files in the model repository,
|
||||
measured on 2026-10-27 with the Hugging Face tree API. Treat it as the weight
|
||||
floor. A run also needs activations, VSA workspace, and decode buffers, so
|
||||
weights that fit do not guarantee a run that fits.
|
||||
|
||||
The cookbook editor starts from each recipe's maintained deployment. Changing
|
||||
a quantization control does not select one of the published exports below.
|
||||
|
||||
## Catalog weights
|
||||
|
||||
Sizes are GiB at the checkpoint's runtime precision. Repositories that store
|
||||
F32 weights are converted to BF16 here, which is what the loader uses.
|
||||
|
||||
| Model | Weights | Transformer | Text encoder | VAE |
|
||||
| ----------------------- | ------- | ----------- | ------------ | ---- |
|
||||
| FastH3 8-Step V2 | 137.7 | 65.3 | 62.1 | 10.3 |
|
||||
| FastH3 4-Step V1 | 137.7 | 65.3 | 62.1 | 10.3 |
|
||||
| FastH3 Trim | 56.8 | 34.9 | 15.3 | 6.5 |
|
||||
| FastH3 Omni v1 | 92.5 | 65.3 | 15.3 | 11.9 |
|
||||
| Pika H3 Flex | 136.7 | 64.3 | 62.1 | 10.3 |
|
||||
| FastWan2.1 1.3B | 13.6 | 2.8 | 10.6 | 0.3 |
|
||||
| Wan2.1 T2V 14B | 37.4 | 26.6 | 10.6 | 0.3 |
|
||||
| Wan2.1 I2V 14B | 41.9 | 30.5 | 10.6 | 0.3 |
|
||||
| Wan2.2 I2V A14B | 58.8 | 53.2 | 5.3 | 0.3 |
|
||||
| Hunyuan 1.5 I2V 480p | 32.2 | 15.5 | 13.6 | 2.3 |
|
||||
| Hunyuan 1.5 T2V 480p | 24.9 | 15.5 | 7.0 | 2.4 |
|
||||
| Kandinsky 5 Lite | 11.1 | 2.2 | 8.5 | 0.5 |
|
||||
| Kandinsky 6 Pro | 41.9 | 32.5 | 8.0 | 1.4 |
|
||||
| Kandinsky 6 Pro distill | 37.5 | 28.1 | 8.0 | 1.4 |
|
||||
| LTX2 Distilled | 66.9 | 35.2 | 25.4 | 3.6 |
|
||||
|
||||
Pika H3 Flex's transformer is the 61.7 GiB H3 trunk plus its 2.6 GiB task adapter.
|
||||
Ref2VA swaps in `transformer_ref` and the `ref2va` adapter, which are the same size.
|
||||
|
||||
The FastVideo organization publishes the same FastH3 model at several
|
||||
precisions. The transformer shrinks with precision; the text encoder and VAE
|
||||
only shrink when the repository ships them quantized too.
|
||||
|
||||
| Checkpoint | Variant | Weights | Transformer | Text encoder | VAE |
|
||||
| ----------------- | -------------- | ------- | ----------- | ------------ | ---- |
|
||||
| 8-Step V2 | BF16 | 137.7 | 65.3 | 62.1 | 10.3 |
|
||||
| 8-Step V2 | FP8 | 69.2 | 47.4 | 15.3 | 6.5 |
|
||||
| 8-Step V2 | NVFP4 | 114.5 | 49.8 | 62.1 | 2.5 |
|
||||
| 8-Step V2 | NVFP4 Consumer | 58.7 | 36.9 | 15.3 | 6.5 |
|
||||
| Trim | BF16 | 56.8 | 34.9 | 15.3 | 6.5 |
|
||||
| Trim | FP8 | 41.7 | 19.9 | 15.3 | 6.5 |
|
||||
| Trim | NVFP4 | 32.9 | 11.1 | 15.3 | 6.5 |
|
||||
| Trim | MLX INT6 | 33.0 | 14.3 | 14.2 | 4.5 |
|
||||
| 4-Step V1 | BF16 | 137.7 | 65.3 | 62.1 | 10.3 |
|
||||
| 4-Step V1 | NVFP4 | 114.5 | 49.8 | 62.1 | 2.5 |
|
||||
| Omni v1 | BF16 DiT | 65.3 | 65.3 | n/a | n/a |
|
||||
| Omni v1 | FP8 | 67.2 | 47.4 | 15.3 | 4.5 |
|
||||
| Omni v1 | NVFP4 QAD | 63.3 | 37.0 | 15.3 | 11.0 |
|
||||
| NVFP4 VAE decoder | quality | 5.8 | n/a | n/a | 5.8 |
|
||||
| NVFP4 VAE decoder | fast | 1.3 | n/a | n/a | 1.3 |
|
||||
|
||||
The NVFP4 checkpoint keeps the BF16 text encoder, which is why it is larger
|
||||
than FP8. The Consumer variant pairs the smaller NVFP4 transformer with the
|
||||
FP8 text encoder and VAE, which brings the listed weights to 58.7 GiB. These
|
||||
export sizes do not establish device fit. Loading, offload, quantization
|
||||
profiles, and temporary buffers affect runtime residency. The decoder-only
|
||||
rows exclude the VAE encoder and can contain master weights that the loader
|
||||
packs before inference.
|
||||
|
||||
## Pick by GPU
|
||||
|
||||
| GPU family | Examples | Use | Why |
|
||||
| ---------------- | ----------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Blackwell | RTX 5090, RTX PRO 6000, B200, GB200 | NVFP4 or FP8 with a matching maintained profile | Native FP4 tensor cores cut the transformer to about half of BF16 |
|
||||
| Hopper | H100, H200 | FP8 | FP8 tensor cores; the NVFP4 path requires Blackwell |
|
||||
| Ada | RTX 4090, L40S | FP8 | FP8 works, NVFP4 does not |
|
||||
| Ampere and older | A100, RTX 3090 | BF16 | No FP8 or FP4 hardware |
|
||||
| Apple silicon | M-series | MLX INT6 or INT8 | The MLX conversions are INT6 and INT8, not FP4 |
|
||||
| DGX Spark | GB10 | NVFP4 with sequential load | Unified memory, so the host RAM counts toward the budget |
|
||||
|
||||
## What the cookbook does now
|
||||
|
||||
For configurations without transformer quantization or a decoder swap, FastH3
|
||||
pages compare checkpoint weights with the selected device. This is a weights
|
||||
check, not a request peak. Host-only RAM cannot establish GPU fit. Quantized transformers, TAEH3, and NVFP4 decoder swaps stay
|
||||
unestimated until metadata covers the effective layer profile, retained
|
||||
components, and packed runtime storage. The editor explains the missing
|
||||
coverage instead of substituting an unrelated export or decoder file size.
|
||||
|
||||
The transformer quantization control writes
|
||||
`generator.engine.quantization.transformer_quant`, so the copied YAML names the
|
||||
precision. Encoder and decoder choices use component paths and stay visible in
|
||||
the YAML too.
|
||||
|
||||
## Automatic selection
|
||||
|
||||
The editor does not pick a precision from the device yet. When it does, the
|
||||
rule is:
|
||||
|
||||
1. Read the device architecture and memory from the hardware catalog.
|
||||
2. Prefer the smallest variant the architecture accelerates: NVFP4 on
|
||||
Blackwell, FP8 on Hopper and Ada, INT6 on Apple, BF16 everywhere else.
|
||||
3. Keep the BF16 text encoder when the quantized checkpoint ships one, and
|
||||
switch the decoder to NVFP4 only on Blackwell.
|
||||
4. Expose the choice as a deployment setting, not a hidden default, so the
|
||||
copied command still shows which checkpoint runs.
|
||||
|
||||
That needs one deployment per variant in
|
||||
[`docs/cookbook/recipes`](https://github.com/hao-ai-lab/FastVideo/tree/main/docs/cookbook/recipes)
|
||||
and a match rule in the editor. Until then, select the precision by hand and
|
||||
read the weights fact.
|
||||
+120
-67
@@ -2,7 +2,7 @@ import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { MotionConfig } from "motion/react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import { Tabs } from "radix-ui";
|
||||
import { modelDirectory, recipePages, sitePath, type DirectoryModel } from "@/lib/cookbook-pages";
|
||||
import { modelDirectory, recipePages, recentlyAdded, sitePath, type DirectoryModel } from "@/lib/cookbook-pages";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CardCurtainRevealTitle,
|
||||
} from "@/components/ui/card-curtain-reveal";
|
||||
import RecipeFacts from "@/components/cookbook/RecipeFacts";
|
||||
import { assessRecipe, assessWeightsRecipe, useCapacityModel, weightsFor } from "@/lib/capacity";
|
||||
import { configAndLaunch, inferenceOutput, sdkCommand } from "@/lib/cookbook-commands";
|
||||
import {
|
||||
gpuCountFrom,
|
||||
@@ -130,7 +131,7 @@ function FieldControl({
|
||||
</div>
|
||||
{choices ? (
|
||||
<div role="group" aria-label={label} className="option-chips">
|
||||
{value === undefined && (
|
||||
{value === undefined && !choices.some((choice) => choice === null) && (
|
||||
<button type="button" aria-pressed={!changed} onClick={() => update(field.path, undefined, field)}>
|
||||
Default
|
||||
</button>
|
||||
@@ -142,18 +143,9 @@ function FieldControl({
|
||||
aria-pressed={value === choice}
|
||||
onClick={() => update(field.path, JSON.stringify(choice), field)}
|
||||
>
|
||||
{choiceLabel(field.path, choice)}
|
||||
{choice === null ? "Default" : choiceLabel(field.path, choice)}
|
||||
</button>
|
||||
))}
|
||||
{nullable && !choices.includes(null) && (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={value === null}
|
||||
onClick={() => update(field.path, "null", { ...field, schema: { ...field.schema, enum: [null] } })}
|
||||
>
|
||||
null
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : kind.includes("object") || kind.includes("array") ? (
|
||||
<Textarea
|
||||
@@ -313,6 +305,41 @@ function Editor({
|
||||
const hardwareRecord = resolveHardware(hardware, hardwareCatalog.records);
|
||||
const gpuCount = gpuCountFrom(result?.config);
|
||||
const model = modelDirectory.find((model) => model.id === catalog.id.split("/")[0])!;
|
||||
const capacityModel = useCapacityModel(model.id);
|
||||
const weights = weightsFor(model.id);
|
||||
const memory =
|
||||
result && !failure
|
||||
? hardwareRecord
|
||||
? capacityModel.model
|
||||
? assessRecipe({
|
||||
model: capacityModel.model,
|
||||
example: { workload: catalog.workload, env: catalog.env },
|
||||
hardware: hardwareRecord,
|
||||
config: result.config,
|
||||
budgetGb: hardwareRecord.usable_budget_gb,
|
||||
hostGb: hardwareRecord.host_ram_gb,
|
||||
})
|
||||
: catalog.runtime.backend === "mlx" && capacityModel.status !== "loading"
|
||||
? {
|
||||
status: "unknown" as const,
|
||||
reason: "MLX checkpoints use a different precision and load pattern; memory is not estimated.",
|
||||
}
|
||||
: capacityModel.status !== "loading" && weights
|
||||
? assessWeightsRecipe({
|
||||
weights,
|
||||
config: result.config,
|
||||
hardware: hardwareRecord,
|
||||
budgetGb: hardwareRecord.usable_budget_gb,
|
||||
})
|
||||
: {
|
||||
status: "unknown" as const,
|
||||
reason:
|
||||
capacityModel.status === "loading"
|
||||
? "Loading the memory estimate."
|
||||
: "No capacity model ships for this checkpoint.",
|
||||
}
|
||||
: { status: "unknown" as const, reason: "Select a device to estimate memory for this recipe." }
|
||||
: undefined;
|
||||
const renderField = (field: Field) => (
|
||||
<FieldControl
|
||||
key={`${reset}-${field.path}`}
|
||||
@@ -560,7 +587,7 @@ function Editor({
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<RecipeFacts validity={failure ? "invalid" : result ? "valid" : "loading"} fit="unknown" evidence="none" />
|
||||
<RecipeFacts validity={failure ? "invalid" : result ? "valid" : "loading"} memory={memory} />
|
||||
</MatrixRow>
|
||||
{catalog.workload === "i2v" && runMode !== "serving" && (
|
||||
<MatrixRow title="Inputs">
|
||||
@@ -581,7 +608,7 @@ function Editor({
|
||||
}}
|
||||
/>
|
||||
<p id={`${sourceImageId}-hint`} className="matrix-note">
|
||||
Path to an image on the machine running FastVideo. Included as request.inputs.image_path.
|
||||
Local path to the first frame. FastVideo reads it on the machine that runs the command.
|
||||
</p>
|
||||
</div>
|
||||
</MatrixRow>
|
||||
@@ -671,6 +698,11 @@ function MatrixRow({ title, children }: { title: string; children: ReactNode })
|
||||
);
|
||||
}
|
||||
|
||||
function deploymentFromUrl() {
|
||||
const requested = new URL(window.location.href).searchParams.get("deployment");
|
||||
return requested === "fasth3-omniref/cuda-rest" ? "fasth3-omniref/rtx5090-rest" : requested;
|
||||
}
|
||||
|
||||
export default function Cookbook({ modelId }: { modelId?: string }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
@@ -693,7 +725,7 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
|
||||
throw new Error("No cookbook deployments are available. Check the catalog and retry.");
|
||||
}
|
||||
setIndex(data);
|
||||
const requested = new URL(window.location.href).searchParams.get("deployment");
|
||||
const requested = deploymentFromUrl();
|
||||
const requestedModel = data.models.find((model) =>
|
||||
model.deployments.some((deployment) => deployment.id === requested),
|
||||
);
|
||||
@@ -739,7 +771,7 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
|
||||
}
|
||||
useEffect(() => {
|
||||
const restore = () => {
|
||||
const requested = new URL(window.location.href).searchParams.get("deployment");
|
||||
const requested = deploymentFromUrl();
|
||||
const available = index?.models.find((item) => item.id === modelId)?.deployments ?? [];
|
||||
const next = available.find((deployment) => deployment.id === requested)?.id ?? available[0]?.id;
|
||||
if (!next) return;
|
||||
@@ -806,11 +838,8 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
|
||||
}
|
||||
|
||||
const GPU_COUNT_PATH = "generator.engine.num_gpus";
|
||||
const SINGLE_GPU_FORM_FACTOR = /desktop|laptop|workstation|system|soc/;
|
||||
|
||||
/** Desktop and workstation cards, DGX Spark, and Macs are one-GPU machines; datacenter parts keep the recipe default. */
|
||||
function singleGpuDevice(record: { form_factor?: string }) {
|
||||
return SINGLE_GPU_FORM_FACTOR.test(record.form_factor ?? "");
|
||||
function singleGpuDevice(record: { form_factor?: string; kind?: string }) {
|
||||
return record.kind === "system" || record.form_factor === "soc" || record.form_factor === "laptop";
|
||||
}
|
||||
|
||||
/** Returns new edits where every fixed parallel size that no longer divides `gpus` becomes automatic. */
|
||||
@@ -833,6 +862,13 @@ function deploymentLabel(deployment: DeploymentChoice, deployments: DeploymentCh
|
||||
}
|
||||
|
||||
/** Mirrors the editor: MLX and WebSocket deployments offer serving only. */
|
||||
function modelModes(index: Index | undefined, model: DirectoryModel): string[] | undefined {
|
||||
const modes = model.configurable ? configurableModes(index, model.id) : exampleModes(model);
|
||||
const hasMlx = model.recipes.some((recipe) => recipe.gpu_types.includes("Apple Silicon"));
|
||||
if (hasMlx && modes && !modes.some((mode) => /mlx/i.test(mode))) return [...modes, "MLX"];
|
||||
return modes;
|
||||
}
|
||||
|
||||
function configurableModes(index: Index | undefined, modelId: string): string[] | undefined {
|
||||
const deployments = index?.models.find((model) => model.id === modelId)?.deployments;
|
||||
if (!deployments?.length) return undefined;
|
||||
@@ -847,8 +883,20 @@ function exampleModes(model: DirectoryModel): string[] {
|
||||
return ["Python SDK", "CLI", ...(model.recipes.some((recipe) => recipe.serving) ? ["Serving"] : [])];
|
||||
}
|
||||
|
||||
const FASTH3_SECTION = new Set(["fasth3-omniref", "fasth3-8step", "fasth3-4step", "fasth3-trim"]);
|
||||
|
||||
/** Rest state shows identity only; hover or keyboard focus opens a curtain over the details. */
|
||||
function RecipeCard({ model, href, modes }: { model: DirectoryModel; href: string; modes?: string[] }) {
|
||||
function RecipeCard({
|
||||
model,
|
||||
href,
|
||||
modes,
|
||||
badge,
|
||||
}: {
|
||||
model: DirectoryModel;
|
||||
href: string;
|
||||
modes?: string[];
|
||||
badge?: string;
|
||||
}) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const details = useRef<HTMLDivElement>(null);
|
||||
const [restOffset, setRestOffset] = useState(72);
|
||||
@@ -869,6 +917,7 @@ function RecipeCard({ model, href, modes }: { model: DirectoryModel; href: strin
|
||||
<img src={sitePath(`/assets/logos/${model.logo}.webp`)} alt="" width="40" height="40" />
|
||||
<span className="recipe-card-kicker">{model.provider}</span>
|
||||
{model.featured ? <span className="recipe-featured">Featured</span> : null}
|
||||
{badge ? <span className="recipe-featured recipe-new">{badge}</span> : null}
|
||||
</div>
|
||||
<CardCurtainRevealTitle
|
||||
as={model.configurable ? "h3" : "h4"}
|
||||
@@ -932,6 +981,7 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
|
||||
};
|
||||
const visible = modelDirectory.filter(
|
||||
(model) =>
|
||||
!model.hidden &&
|
||||
(category === "All models" || model.category === category) &&
|
||||
(family === "All families" || model.family === family) &&
|
||||
(task === "All tasks" || model.task === task) &&
|
||||
@@ -947,9 +997,10 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
|
||||
setRuntime("all");
|
||||
setTask("All tasks");
|
||||
}
|
||||
const configurable = visible.filter((model) => model.configurable);
|
||||
const examples = visible.filter((model) => !model.configurable);
|
||||
const exampleFamilies = [...new Set(examples.map((model) => model.family))];
|
||||
const fastH3 = visible.filter((model) => FASTH3_SECTION.has(model.id));
|
||||
const recent = visible.filter((model) => !FASTH3_SECTION.has(model.id) && recentlyAdded(model.model));
|
||||
const rest = visible.filter((model) => !FASTH3_SECTION.has(model.id) && !recentlyAdded(model.model));
|
||||
const families = [...new Set(rest.map((model) => model.family))];
|
||||
const filtered =
|
||||
!!query.trim() ||
|
||||
category !== "All models" ||
|
||||
@@ -957,7 +1008,7 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
|
||||
runtime !== "all" ||
|
||||
task !== "All tasks";
|
||||
const categoryCount = (value: string) =>
|
||||
modelDirectory.filter((model) => value === "All models" || model.category === value).length;
|
||||
modelDirectory.filter((model) => !model.hidden && (value === "All models" || model.category === value)).length;
|
||||
return (
|
||||
<MotionConfig reducedMotion="user">
|
||||
<div className="cookbook not-prose recipe-directory">
|
||||
@@ -991,7 +1042,6 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
|
||||
<div className="recipe-directory-subbar">
|
||||
<p role="status">
|
||||
{visible.length} {visible.length === 1 ? "model" : "models"}
|
||||
{configurable.length ? ` · ${configurable.length} configurable` : ""}
|
||||
{runtime !== "all" ? ` · ${runtime === "mlx" ? "MLX" : "CUDA"} only` : ""}
|
||||
{task !== "All tasks" ? ` · ${task}` : ""}
|
||||
{filtered && visible.length ? (
|
||||
@@ -1043,59 +1093,62 @@ function RecipeDirectory({ index, error, retry }: { index?: Index; error: string
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{configurable.length ? (
|
||||
<section className="recipe-section" aria-labelledby="recipe-configurable-heading">
|
||||
{fastH3.length ? (
|
||||
<section className="recipe-section" aria-labelledby="recipe-fasth3-heading">
|
||||
<header className="recipe-section-head">
|
||||
<h2 id="recipe-configurable-heading">Configurable recipes</h2>
|
||||
<p>Edit settings, then copy one config for the Python SDK, the CLI, or an OpenAI-compatible server.</p>
|
||||
<h2 id="recipe-fasth3-heading">FastVideo FastH3</h2>
|
||||
</header>
|
||||
<div className="recipe-card-grid recipe-card-grid-configurable">
|
||||
{configurable.map((model) => (
|
||||
{fastH3.map((model) => (
|
||||
<RecipeCard key={model.id} model={model} href={sitePath(model.path)} modes={modelModes(index, model)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{recent.length ? (
|
||||
<section className="recipe-section" aria-labelledby="recipe-new-heading">
|
||||
<header className="recipe-section-head">
|
||||
<h2 id="recipe-new-heading">New</h2>
|
||||
</header>
|
||||
<div className="recipe-card-grid recipe-card-grid-configurable">
|
||||
{recent.map((model) => (
|
||||
<RecipeCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
href={sitePath(model.path)}
|
||||
modes={configurableModes(index, model.id)}
|
||||
modes={modelModes(index, model)}
|
||||
badge="New"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{examples.length ? (
|
||||
<section className="recipe-section" aria-labelledby="recipe-examples-heading">
|
||||
<header className="recipe-section-head">
|
||||
<h2 id="recipe-examples-heading">Example recipes</h2>
|
||||
<p>
|
||||
Maintained example scripts, grouped by model family. Adjust their documented settings and run them as
|
||||
written.
|
||||
</p>
|
||||
</header>
|
||||
{exampleFamilies.map((name) => {
|
||||
const members = examples.filter((model) => model.family === name);
|
||||
return (
|
||||
<div className="recipe-family-group" key={name}>
|
||||
<div className="recipe-family-head">
|
||||
<h3>{name}</h3>
|
||||
<span>
|
||||
{members.length} {members.length === 1 ? "recipe" : "recipes"}
|
||||
</span>
|
||||
<a href={sitePath(members[0].familyPath)}>Family guide →</a>
|
||||
</div>
|
||||
<div className="recipe-card-grid">
|
||||
{members.map((model) => (
|
||||
<RecipeCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
href={sitePath(model.path)}
|
||||
modes={exampleModes(model)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{families.map((name) => {
|
||||
const members = rest.filter((model) => model.family === name);
|
||||
return (
|
||||
<section className="recipe-section" key={name} aria-label={name}>
|
||||
<div className="recipe-family-group">
|
||||
<div className="recipe-family-head">
|
||||
<h3>{name}</h3>
|
||||
<span>
|
||||
{members.length} {members.length === 1 ? "recipe" : "recipes"}
|
||||
</span>
|
||||
<a href={sitePath(members[0].familyPath)}>Family guide →</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
) : null}
|
||||
<div className="recipe-card-grid">
|
||||
{members.map((model) => (
|
||||
<RecipeCard
|
||||
key={model.id}
|
||||
model={model}
|
||||
href={sitePath(model.path)}
|
||||
modes={modelModes(index, model)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{!visible.length ? (
|
||||
<div className="recipe-empty">
|
||||
<h2>No matching recipes</h2>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function FastModels() {
|
||||
models: group.models.filter(
|
||||
(model) =>
|
||||
(family === "All models" || family === group.name) &&
|
||||
`${group.name} ${group.summary} ${model.id}`.toLowerCase().includes(query.trim().toLowerCase()),
|
||||
`${group.name} ${model.id}`.toLowerCase().includes(query.trim().toLowerCase()),
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.models.length);
|
||||
@@ -48,7 +48,7 @@ export default function FastModels() {
|
||||
<header className="model-catalog-heading">
|
||||
<div>
|
||||
<h2 id="checkpoint-catalog">Browse checkpoints</h2>
|
||||
<p>Find the export your runtime needs.</p>
|
||||
<p>Pick the checkpoint that matches your runtime.</p>
|
||||
</div>
|
||||
<a href="https://huggingface.co/collections/FastVideo/fastvideo-fasth3">
|
||||
Earlier FastH3 releases <span aria-hidden="true">↗</span>
|
||||
@@ -67,7 +67,7 @@ export default function FastModels() {
|
||||
<input
|
||||
id={id}
|
||||
type="search"
|
||||
placeholder="Name, precision, or runtime…"
|
||||
placeholder="Name, family, or precision…"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
|
||||
@@ -4,17 +4,18 @@ import { modelDirectory, sitePath } from "@/lib/cookbook-pages";
|
||||
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"),
|
||||
!model.hidden &&
|
||||
(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
|
||||
← All recipes
|
||||
</a>
|
||||
<div className="recipe-card-grid">
|
||||
{models.map((model) => (
|
||||
<a className="recipe-card" key={model.id} href={sitePath(model.path)}>
|
||||
<a className="recipe-card recipe-card-static" key={model.id} href={sitePath(model.path)}>
|
||||
<div className="recipe-card-top">
|
||||
<img src={sitePath(`/assets/logos/${model.logo}.webp`)} alt="" width="40" height="40" />
|
||||
<span className="recipe-card-kicker">{model.provider}</span>
|
||||
|
||||
@@ -484,9 +484,7 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
|
||||
<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"
|
||||
validityDetail="Checks that the YAML parses and that edited numbers are valid. It does not run the recipe."
|
||||
subject={yamlSource ? "configuration" : "example"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from "react";
|
||||
// @ts-expect-error The shared ES module has no TypeScript declarations.
|
||||
import { assessCapacity } from "../../docs/js/cookbook-memory.mjs";
|
||||
import weightsData from "../../docs/assets/cookbook-weights.json";
|
||||
|
||||
export type MemoryStatus = "fits" | "tight" | "exceeds" | "unsupported" | "unknown";
|
||||
|
||||
/** The subset of an `assessCapacity` result the UI shows. */
|
||||
export interface MemoryAssessment {
|
||||
status: MemoryStatus;
|
||||
reason?: string;
|
||||
requiredGiB?: number;
|
||||
capacityGiB?: number;
|
||||
basis?: string;
|
||||
parts?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface WeightsModel {
|
||||
parts: Record<string, number>;
|
||||
alternates?: Record<string, number>;
|
||||
sources?: string[];
|
||||
}
|
||||
|
||||
const WEIGHTS = weightsData as { models: Record<string, WeightsModel> };
|
||||
|
||||
/** Measured checkpoint sizes for recipes that have no full capacity model yet. */
|
||||
export function weightsFor(modelId: string | undefined): WeightsModel | undefined {
|
||||
return modelId ? WEIGHTS.models[modelId] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare resident checkpoint weights with one device. This answers whether the
|
||||
* weights load, not whether a request peaks below the device limit: activations,
|
||||
* VSA workspace, and decode buffers are not modeled.
|
||||
*/
|
||||
export function assessWeightsRecipe({
|
||||
weights,
|
||||
config,
|
||||
hardware,
|
||||
budgetGb,
|
||||
}: {
|
||||
weights: WeightsModel;
|
||||
config: Record<string, unknown>;
|
||||
hardware: { memory_gb?: number; usable_budget_gb?: number; memory_unit?: string; memory_type?: string };
|
||||
budgetGb?: number;
|
||||
}): MemoryAssessment | undefined {
|
||||
if (hardware.memory_type !== "dedicated" && hardware.memory_type !== "unified") {
|
||||
return {
|
||||
status: "unknown",
|
||||
reason:
|
||||
"A weights fit check requires dedicated GPU memory or unified memory. Host-only system RAM is not GPU capacity.",
|
||||
};
|
||||
}
|
||||
const generator = (config.generator ?? {}) as {
|
||||
engine?: { offload?: Record<string, boolean> };
|
||||
pipeline?: {
|
||||
components?: Record<string, string | null>;
|
||||
experimental?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
const parts = { ...weights.parts };
|
||||
const components = generator.pipeline?.components ?? {};
|
||||
const experimental = generator.pipeline?.experimental ?? {};
|
||||
if (experimental.video_decode_backend === "taeh3") {
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: "TAEH3 decoder weights are not modeled. This backend does not load the bundled H3 video VAE.",
|
||||
};
|
||||
}
|
||||
if (experimental.h3_vae_nvfp4_decoder) {
|
||||
return {
|
||||
status: "unknown",
|
||||
reason:
|
||||
"NVFP4 decoder residency is not modeled: the VAE retains its encoder and packs decoder weights after loading. Decoder file size is not the resident VAE size.",
|
||||
};
|
||||
}
|
||||
if (components.text_encoder_weights && weights.alternates?.text_encoder_quantized !== undefined) {
|
||||
parts.text_encoder = weights.alternates.text_encoder_quantized;
|
||||
}
|
||||
const quant = (config.generator as { engine?: { quantization?: { transformer_quant?: string | null } } })?.engine
|
||||
?.quantization?.transformer_quant;
|
||||
if (quant) {
|
||||
return {
|
||||
status: "unknown",
|
||||
reason:
|
||||
"Transformer quantization residency is not modeled for this checkpoint and layer profile. Published export sizes do not describe on-the-fly quantization of this configuration.",
|
||||
};
|
||||
}
|
||||
const offload = generator.engine?.offload ?? {};
|
||||
if (offload.text_encoder) delete parts.text_encoder;
|
||||
const resident = Object.values(parts).reduce((sum, value) => sum + value, 0);
|
||||
const capacityGb = budgetGb ?? hardware.usable_budget_gb ?? hardware.memory_gb;
|
||||
if (!capacityGb) return undefined;
|
||||
const capacityGiB = hardware.memory_unit === "GiB" ? capacityGb : (capacityGb * 1e9) / GIB;
|
||||
const label: Record<string, string> = {
|
||||
transformer: "transformer",
|
||||
text_encoder: "text encoder",
|
||||
vae: "VAE",
|
||||
audio_vae: "audio VAE",
|
||||
};
|
||||
const split = Object.entries(parts)
|
||||
.map(([role, value]) => `${label[role] ?? role} ${value.toFixed(1)} GiB`)
|
||||
.join(", ");
|
||||
const status: MemoryStatus = resident > capacityGiB ? "exceeds" : resident > capacityGiB * 0.85 ? "tight" : "fits";
|
||||
const offloaded = offload.text_encoder ? " The text encoder is offloaded." : "";
|
||||
return {
|
||||
status,
|
||||
basis: "weights",
|
||||
requiredGiB: resident,
|
||||
capacityGiB,
|
||||
parts,
|
||||
reason: `Weights only: ${split}.${offloaded} Activations, VSA workspace, and decode buffers are not modeled.`,
|
||||
};
|
||||
}
|
||||
|
||||
interface CapacityModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
examples?: { deployment_id?: string }[];
|
||||
reference_runs?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CapacityState {
|
||||
status: "loading" | "ready" | "unavailable";
|
||||
model?: CapacityModel;
|
||||
}
|
||||
|
||||
const CAPACITY_INDEX = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/assets/cookbook-config/capacity/index.json`;
|
||||
const GIB = 1024 ** 3;
|
||||
|
||||
let indexPromise: Promise<{ models: { id: string; catalog_url: string }[] }> | null = null;
|
||||
|
||||
function loadIndex() {
|
||||
indexPromise ??= fetch(CAPACITY_INDEX)
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`Capacity index: HTTP ${response.status}`);
|
||||
return response.json() as Promise<{ models: { id: string; catalog_url: string }[] }>;
|
||||
})
|
||||
.catch((error) => {
|
||||
indexPromise = null;
|
||||
throw error;
|
||||
});
|
||||
return indexPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the capacity model for one cookbook entry. Most recipes do not have one;
|
||||
* the hook stays `unavailable` and the UI says so instead of guessing.
|
||||
*/
|
||||
export function useCapacityModel(modelId: string | undefined): CapacityState {
|
||||
const [state, setState] = useState<CapacityState>({ status: "loading" });
|
||||
useEffect(() => {
|
||||
if (!modelId) {
|
||||
setState({ status: "unavailable" });
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
setState({ status: "loading" });
|
||||
loadIndex()
|
||||
.then(async (index) => {
|
||||
const entry = index.models.find((model) => model.id === modelId);
|
||||
if (!entry) throw new Error("No capacity model ships for this checkpoint.");
|
||||
const url = new URL(entry.catalog_url, new URL(CAPACITY_INDEX, window.location.origin));
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Capacity model: HTTP ${response.status}`);
|
||||
const model = (await response.json()) as CapacityModel;
|
||||
if (live) setState({ status: "ready", model });
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setState({ status: "unavailable" });
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [modelId]);
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the analytical estimate for the edited configuration. Returns `undefined`
|
||||
* when no capacity model covers the checkpoint, so the caller can say so.
|
||||
*/
|
||||
export function assessRecipe({
|
||||
model,
|
||||
example,
|
||||
hardware,
|
||||
config,
|
||||
budgetGb,
|
||||
hostGb,
|
||||
}: {
|
||||
model: CapacityModel;
|
||||
example: { workload?: string; env?: Record<string, string> };
|
||||
hardware?: {
|
||||
memory_gb?: number;
|
||||
memory_unit?: string;
|
||||
memory_type?: string;
|
||||
usable_budget_gb?: number;
|
||||
host_ram_gb?: number;
|
||||
id?: string;
|
||||
};
|
||||
config?: Record<string, unknown>;
|
||||
budgetGb?: number;
|
||||
hostGb?: number;
|
||||
}): MemoryAssessment | undefined {
|
||||
if (!config || !hardware?.memory_gb) return undefined;
|
||||
try {
|
||||
const result = assessCapacity({
|
||||
model,
|
||||
example,
|
||||
hardware,
|
||||
config,
|
||||
memoryBudgetGiB: budgetGb ? (hardware.memory_unit === "GiB" ? budgetGb : (budgetGb * 1e9) / GIB) : undefined,
|
||||
hostMemoryGb: hostGb,
|
||||
}) as MemoryAssessment;
|
||||
return {
|
||||
status: (result.status ?? "unknown") as MemoryStatus,
|
||||
reason: result.reason,
|
||||
requiredGiB: result.requiredGiB,
|
||||
capacityGiB: result.capacityGiB,
|
||||
basis: result.basis,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,15 @@ import sourceCatalog from "../../docs/assets/cookbook-recipes.json" with { type:
|
||||
|
||||
/** Presentation metadata only. Deployments and editable fields come from the cookbook backend. */
|
||||
export const recipePages = [
|
||||
{
|
||||
id: "fasth3-preview",
|
||||
family: "MiniMax H3",
|
||||
title: "FastH3 Preview V0.2",
|
||||
mark: "H3",
|
||||
path: "/cookbook/minimax-h3/fasth3-preview",
|
||||
description: "The few-step FastH3 preview checkpoint on CUDA or native Apple Silicon MLX.",
|
||||
tags: ["Text to video", "Audio", "4-step"],
|
||||
},
|
||||
{
|
||||
id: "fasth3-8step",
|
||||
family: "MiniMax H3",
|
||||
@@ -20,15 +29,6 @@ export const recipePages = [
|
||||
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",
|
||||
@@ -41,7 +41,7 @@ export const recipePages = [
|
||||
{
|
||||
id: "fasth3-omniref",
|
||||
family: "MiniMax H3",
|
||||
title: "FastH3 OmniRef",
|
||||
title: "FastH3 Omni v1",
|
||||
mark: "H3",
|
||||
path: "/cookbook/minimax-h3/fasth3-omniref",
|
||||
description: "Video and audio from a prompt plus ordered image, video, and audio references.",
|
||||
@@ -128,6 +128,16 @@ const backendModels: Record<string, string> = {
|
||||
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers": "wan21-i2v",
|
||||
"FastVideo/LTX2-Distilled-Diffusers": "ltx2-distilled",
|
||||
};
|
||||
const hiddenBackends = new Set(["fasth3-preview"]);
|
||||
|
||||
const modelAddedOn: Record<string, string> = {
|
||||
"Qwen/Qwen-Image-2.1": "2026-10-25",
|
||||
};
|
||||
export function recentlyAdded(model: string) {
|
||||
const added = modelAddedOn[model];
|
||||
return !!added && Date.now() - Date.parse(added) < 30 * 86_400_000;
|
||||
}
|
||||
|
||||
const TASK_GROUPS: Array<[RegExp, string]> = [
|
||||
[/world/, "Text to world"],
|
||||
[/video\/text to audio|video.*to audio/, "Video to audio"],
|
||||
@@ -147,57 +157,69 @@ export function taskLabel(value: string): string {
|
||||
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",
|
||||
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),
|
||||
);
|
||||
const mappedModels = [...new Set(sourceRecipes.map((recipe) => recipe.model))].map((model) => {
|
||||
const recipes = sourceRecipes.filter((recipe) => recipe.model === model);
|
||||
const first = recipes[0];
|
||||
const family = familyInfo[first.family];
|
||||
const backend = recipePages.find((page) => page.id === backendModels[model]);
|
||||
const ownModel = model.startsWith("FastVideo/Fast");
|
||||
const logo = ownModel ? "fastvideo" : family.logo;
|
||||
const providers: Record<string, string> = {
|
||||
fastvideo: "FastVideo",
|
||||
minimax: "MiniMax",
|
||||
"wan-ai": "Wan AI",
|
||||
ltx: "Lightricks",
|
||||
"tencent-hunyuan": "Tencent",
|
||||
kandinsky: "Kandinsky Lab",
|
||||
"black-forest-labs": "Black Forest Labs",
|
||||
qwen: "Qwen",
|
||||
stabilityai: "Stability AI",
|
||||
nvidia: "NVIDIA",
|
||||
};
|
||||
return {
|
||||
id: backend?.id ?? first.id,
|
||||
model,
|
||||
recipes,
|
||||
title:
|
||||
backend?.title ??
|
||||
(model.includes("FastH3-4-step")
|
||||
? "FastH3 4-Step V1"
|
||||
: model.includes("FastH3-Preview-v0.2")
|
||||
? "FastH3 Preview V0.2"
|
||||
: model === "MiniMaxAI/MiniMax-H3"
|
||||
? "MiniMax H3"
|
||||
: first.label.split(" on ")[0]),
|
||||
family: backend?.family ?? family.title,
|
||||
familyPath: `/cookbook/${family.path}`,
|
||||
path: backend?.path ?? `/cookbook/${family.path}/models/${first.id}`,
|
||||
logo,
|
||||
provider: providers[logo],
|
||||
category: family.category,
|
||||
description: backend?.description ?? first.summary,
|
||||
tags: backend?.tags ?? [...new Set(recipes.map((recipe) => recipe.task))],
|
||||
task: taskLabel(backend?.tags[0] ?? first.task),
|
||||
platforms: [...new Set(recipes.flatMap((recipe) => recipe.gpu_types))],
|
||||
configurable: !!backend,
|
||||
hidden: hiddenBackends.has(backend?.id ?? ""),
|
||||
featured: backend?.featured ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
const byPage = new Map<string, (typeof mappedModels)[number]>();
|
||||
for (const entry of mappedModels) {
|
||||
const existing = byPage.get(entry.id);
|
||||
if (!existing) {
|
||||
byPage.set(entry.id, entry);
|
||||
continue;
|
||||
}
|
||||
existing.recipes.push(...entry.recipes.filter((recipe) => !existing.recipes.some((seen) => seen.id === recipe.id)));
|
||||
existing.platforms = [...new Set([...existing.platforms, ...entry.platforms])];
|
||||
}
|
||||
|
||||
export const modelDirectory = [...byPage.values()].sort(
|
||||
(a, b) =>
|
||||
Number(b.featured) - Number(a.featured) ||
|
||||
Number(b.family === "MiniMax H3") - Number(a.family === "MiniMax H3") ||
|
||||
Number(b.configurable) - Number(a.configurable),
|
||||
);
|
||||
export type DirectoryModel = (typeof modelDirectory)[number];
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const NEWS_HEADINGS = ["news", "recent updates", "what's new", "latest news", "announcements"];
|
||||
|
||||
const PROJECT_HEADINGS = [
|
||||
"awesome work using fastvideo or our research projects",
|
||||
"projects using fastvideo",
|
||||
"built on fastvideo",
|
||||
"awesome work",
|
||||
];
|
||||
|
||||
function normalizeHeading(line) {
|
||||
return line
|
||||
.replace(/^#{2,3}\s+/, "")
|
||||
.replace(/[`*_]/g, "")
|
||||
.replace(/[^\p{L}\p{N}\s'’-]/gu, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function sectionBody(readme, names) {
|
||||
const lines = readme.split("\n");
|
||||
const start = lines.findIndex((line) => /^##\s+/.test(line) && names.includes(normalizeHeading(line)));
|
||||
if (start < 0) return null;
|
||||
const rest = lines.slice(start + 1);
|
||||
const end = rest.findIndex((line) => /^##\s+/.test(line));
|
||||
return (end < 0 ? rest : rest.slice(0, end)).join("\n").trim();
|
||||
}
|
||||
|
||||
const BULLET = /^-\s+(?:`(\d{4})[/-](\d{2})[/-](\d{2})`:|\*\*(\d{4})[/-](\d{2})[/-](\d{2}):?\*\*:?)\s*(.+)$/;
|
||||
|
||||
export function readReadmeSections(readme) {
|
||||
const news = sectionBody(readme, NEWS_HEADINGS);
|
||||
const projects = sectionBody(readme, PROJECT_HEADINGS);
|
||||
if (news === null || projects === null) return { news: null, projects: null };
|
||||
const items = [];
|
||||
for (const line of news.split("\n")) {
|
||||
const match = BULLET.exec(line.trim());
|
||||
if (!match) continue;
|
||||
const [, y1, m1, d1, y2, m2, d2, text] = match;
|
||||
items.push({ date: `${y1 ?? y2}/${m1 ?? m2}/${d1 ?? d2}`, text: text.trim() });
|
||||
}
|
||||
return { news: items, projects };
|
||||
}
|
||||
|
||||
export const README_HEADINGS = { news: NEWS_HEADINGS, projects: PROJECT_HEADINGS };
|
||||
File diff suppressed because one or more lines are too long
@@ -141,21 +141,17 @@
|
||||
"label": "All recipes",
|
||||
"href": "/cookbook"
|
||||
},
|
||||
{
|
||||
"label": "H3 server and playground",
|
||||
"href": "/cookbook/openai-api"
|
||||
},
|
||||
{
|
||||
"label": "MiniMax H3",
|
||||
"display": "group",
|
||||
"collapsed": false,
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{
|
||||
"label": "MiniMax H3",
|
||||
"href": "/cookbook/minimax-h3/models/minimax-h3-t2v"
|
||||
},
|
||||
{
|
||||
"label": "FastH3 OmniRef",
|
||||
"label": "FastH3 Omni v1",
|
||||
"href": "/cookbook/minimax-h3/fasth3-omniref"
|
||||
},
|
||||
{
|
||||
@@ -166,10 +162,6 @@
|
||||
"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"
|
||||
@@ -187,7 +179,7 @@
|
||||
{
|
||||
"label": "Wan",
|
||||
"display": "group",
|
||||
"collapsed": false,
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{
|
||||
"label": "FastWan2.1 1.3B",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"test": "playwright test",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test:content": "node --test tests/content.test.mjs tests/commands.test.mjs"
|
||||
"test:content": "node --test tests/content.test.mjs tests/commands.test.mjs tests/readme-sections.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"blume": "2.2.3",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promi
|
||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { readReadmeSections, README_HEADINGS } from "../lib/readme.mjs";
|
||||
|
||||
const site = fileURLToPath(new URL("../", import.meta.url));
|
||||
const repo = resolve(site, "..");
|
||||
@@ -176,12 +177,11 @@ export async function transferDocs() {
|
||||
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 writeFile(file.replace(/\.md$/, ".mdx"), text.replace(/\{#([^}]+)\}/g, "[#$1]"));
|
||||
await rm(file);
|
||||
}
|
||||
}
|
||||
@@ -251,10 +251,15 @@ export async function transferDocs() {
|
||||
|
||||
// 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 { news, projects } = readReadmeSections(readme);
|
||||
if (!news || !projects) {
|
||||
const accepted = (names) => names.map((name) => `"## ${name.replace(/\b\w/g, (c) => c.toUpperCase())}"`).join(", ");
|
||||
throw new Error(
|
||||
`README section not found. Accepted news headings: ${accepted(README_HEADINGS.news)}. ` +
|
||||
`Accepted project headings: ${accepted(README_HEADINGS.projects)}.`,
|
||||
);
|
||||
}
|
||||
const recent = news.slice(0, 2).map(({ date, text }) => {
|
||||
const links = [...text.matchAll(/\[([^\]]+)\]\(([^)]+)\)/g)];
|
||||
const headline = text
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
|
||||
@@ -31,7 +31,7 @@ test("converted guides render admonitions, preserve content, and publish local i
|
||||
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("FastVideo is an inference and post-training framework for video diffusion models."));
|
||||
assert.ok(!home.includes("About FastVideo and performance overview"));
|
||||
await readFile(new URL("dist/assets/logos/logo.svg", site));
|
||||
});
|
||||
|
||||
@@ -223,18 +223,18 @@ test("inherited values, explicit false, zero and null retain their meanings", as
|
||||
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(37);
|
||||
await expect(cards.first()).toContainText("FastH3 OmniRef");
|
||||
await expect(cards).toHaveCount(36);
|
||||
await expect(cards.first()).toContainText("FastH3 Omni v1");
|
||||
await page.getByLabel("Model family", { exact: true }).selectOption("Wan");
|
||||
await page.getByLabel("Search recipes").fill("Wan2.1 I2V");
|
||||
await expect(cards).toHaveCount(1);
|
||||
await cards.click();
|
||||
await expect(page).toHaveURL(/cookbook\/wan\/wan21-i2v$/);
|
||||
await expect(page).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).toHaveURL(/cookbook\/wan\/fastwan21\/?$/);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("FastWan2.1 1.3B");
|
||||
});
|
||||
|
||||
@@ -253,8 +253,7 @@ test("hardware preferences stay distinct from schema-backed GPU count and topolo
|
||||
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 expect(page.getByText("Weights fit", { 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");
|
||||
@@ -277,7 +276,7 @@ test("output tabs support keyboard navigation and precision chips use the backen
|
||||
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(37);
|
||||
await expect(page.locator(".recipe-card")).toHaveCount(36);
|
||||
});
|
||||
|
||||
test("cookbook cards and controls remain readable in dark mode and reflow at 320px", async ({ page }) => {
|
||||
@@ -462,16 +461,16 @@ test("custom devices persist and no preview control can block generated YAML", a
|
||||
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: "Total memory", exact: true }).fill("24");
|
||||
await page.getByRole("spinbutton", { name: "Usable memory (optional)", exact: true }).fill("32");
|
||||
await expect(page.getByText("Usable memory cannot exceed total memory.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Save device", exact: true })).toBeDisabled();
|
||||
await page.getByRole("spinbutton", { name: "Usable memory (optional)", exact: true }).fill("22");
|
||||
await page.getByRole("spinbutton", { name: "Host RAM (optional)", exact: true }).fill("64");
|
||||
await page.getByRole("button", { name: "Use this device", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save device", exact: true }).click();
|
||||
await expect(page.getByText("RTX 4090", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".hardware-selected")).toContainText("24 GB VRAM");
|
||||
await expect(page.locator(".hardware-selected")).toContainText("Your budget: 22 GB");
|
||||
await expect(page.locator(".hardware-selected")).toContainText("Usable memory: 22 GB");
|
||||
await expect(page.locator(".hardware-selected")).toContainText("Host RAM: 64 GB");
|
||||
await expect(page).toHaveURL(/hardware=custom/);
|
||||
await expect(page).toHaveURL(/hwName=RTX\+4090/);
|
||||
@@ -480,28 +479,26 @@ test("custom devices persist and no preview control can block generated YAML", a
|
||||
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 page.getByRole("button", { name: "Clear hardware", exact: true }).click();
|
||||
await expect(page.getByText("RTX 4090", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("hardware is never preselected; a single-GPU device sets one GPU with valid parallelism", async ({ page }) => {
|
||||
test("hardware is never preselected and a desktop card keeps the recipe GPU count", async ({ page }) => {
|
||||
await page.goto(route);
|
||||
await expect(page.locator(".hardware-picker")).toBeVisible({ timeout: 30000 });
|
||||
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
|
||||
await output(page, "config.yaml");
|
||||
const yaml = page.getByLabel("Generated YAML", { exact: true });
|
||||
await expect(yaml).toContainText('"num_gpus": 4');
|
||||
await chooseHardware(page, "RTX 5090");
|
||||
await expect(page).toHaveURL(/hardware=geforce-rtx-5090-32gb/);
|
||||
await expect(page.getByText(/GeForce RTX 5090 .*32 GB VRAM/)).toBeVisible();
|
||||
await expect(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).toContainText('"num_gpus": 4');
|
||||
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();
|
||||
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
|
||||
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("lowering the GPU count resets parallelism that no longer fits", async ({ page }) => {
|
||||
@@ -522,7 +519,7 @@ test("browser back after a hardware-only change keeps the editor loaded", async
|
||||
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.getByText("No hardware selected.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
|
||||
await expect(page.getByText("Loading deployment settings…")).toHaveCount(0);
|
||||
});
|
||||
@@ -535,15 +532,135 @@ test("hardware links resolve catalog aliases and non-CUDA devices are not CUDA t
|
||||
await expect(page.getByRole("radio", { name: /Arc B580/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("recipe facts separate validity, fit, and run evidence with inspectable assumptions", async ({ page }) => {
|
||||
test("recipe facts state validity and why memory has no estimate", async ({ page }) => {
|
||||
await page.goto(route);
|
||||
const facts = page.getByLabel("Recipe facts");
|
||||
const facts = page.locator(".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 expect(facts.getByText("Memory not estimated", { 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();
|
||||
await expect(page.getByText(/Select a device to estimate memory/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("host-only system RAM cannot produce a GPU weights fit verdict", async ({ page }) => {
|
||||
await page.goto(`${route}?hardware=custom&hwName=Host&hwType=system&hwMemory=128&hwUnit=GB&hwBackend=cuda`);
|
||||
const facts = page.locator(".recipe-facts");
|
||||
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Details", exact: true }).click();
|
||||
await expect(facts.getByText(/Host-only system RAM is not GPU capacity/)).toBeVisible();
|
||||
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Download YAML", exact: false })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("TAEH3 stops using the bundled VAE weights in memory facts", async ({ page }) => {
|
||||
await page.goto(`${route}?hardware=custom&hwName=GPU&hwType=dedicated&hwMemory=80&hwUnit=GB&hwBackend=cuda`);
|
||||
const facts = page.locator(".recipe-facts");
|
||||
await expect(facts.getByText("Weights exceed memory", { exact: true })).toBeVisible();
|
||||
await chip(page, "Video decoder", "taeh3");
|
||||
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Details", exact: true }).click();
|
||||
await expect(facts.getByText(/TAEH3 decoder weights are not modeled/)).toBeVisible();
|
||||
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
|
||||
await output(page, "config.yaml");
|
||||
await expect(page.getByLabel("Generated YAML")).toContainText('"video_decode_backend": "taeh3"');
|
||||
await chip(page, "Video decoder", "h3-vae");
|
||||
await expect(facts.getByText("Weights exceed memory", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("FastH3 keeps unmodeled quantization and decoder residency unknown", async ({ page }) => {
|
||||
await page.goto(route);
|
||||
await chooseHardware(page, "RTX 5090");
|
||||
const facts = page.locator(".recipe-facts");
|
||||
await expect(facts.getByText("Weights exceed memory", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Details", exact: true }).click();
|
||||
await expect(page.getByText(/Resident weights need 75\.6 GiB of 29\.8 GiB/)).toBeVisible();
|
||||
await page
|
||||
.getByRole("group", { name: "Transformer quantization", exact: true })
|
||||
.getByRole("button", { name: "NVFP4", exact: true })
|
||||
.click();
|
||||
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
|
||||
await expect(facts.getByText(/checkpoint and layer profile/)).toBeVisible();
|
||||
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
|
||||
await page
|
||||
.getByRole("group", { name: "NVFP4 video decoder", exact: true })
|
||||
.getByRole("button", { name: /8L · fast/ })
|
||||
.click();
|
||||
await expect(facts.getByText("Memory not estimated", { exact: true })).toBeVisible();
|
||||
await expect(facts.getByText(/VAE retains its encoder and packs decoder weights/)).toBeVisible();
|
||||
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
|
||||
await output(page, "config.yaml");
|
||||
await expect(page.getByLabel("Generated YAML")).toContainText('"transformer_quant": "NVFP4"');
|
||||
await expect(page.getByLabel("Generated YAML")).toContainText("fast-8L/decoder-nvfp4.safetensors");
|
||||
});
|
||||
|
||||
test("4-Step quantization and full-depth decoder swaps also stay unestimated", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-4step");
|
||||
await chooseHardware(page, "RTX 5090");
|
||||
const facts = page.locator(".recipe-facts");
|
||||
await page.getByRole("button", { name: "Details", exact: true }).click();
|
||||
await chip(page, "Transformer quantization", "FP8");
|
||||
await expect(facts.getByText(/checkpoint and layer profile/)).toBeVisible();
|
||||
await chip(page, "Transformer quantization", "Default");
|
||||
await page
|
||||
.getByRole("group", { name: "NVFP4 video decoder", exact: true })
|
||||
.getByRole("button", { name: /36L/ })
|
||||
.click();
|
||||
await expect(facts.getByText(/VAE retains its encoder and packs decoder weights/)).toBeVisible();
|
||||
await expect(facts.getByText(/Resident weights need/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("the retired Preview route and deployment links keep their checkpoint", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-preview");
|
||||
await output(page, "config.yaml");
|
||||
await expect(page.getByLabel("Generated YAML")).toContainText("FastVideo-Minimax-FastH3-Preview-v0.2");
|
||||
await page.goto("/FastVideo/cookbook?deployment=fasth3-preview%2Fcuda-rest");
|
||||
await expect(page).toHaveURL(/minimax-h3\/fasth3-preview/);
|
||||
await output(page, "config.yaml");
|
||||
await expect(page.getByLabel("Generated YAML")).toContainText("FastVideo-Minimax-FastH3-Preview-v0.2");
|
||||
});
|
||||
|
||||
for (const [deployment, label, sequential, tileBatch] of [
|
||||
["rtx5090-rest", "RTX 5090", true, "4"],
|
||||
["rtx-pro6000-rest", "RTX PRO 6000", false, "8"],
|
||||
["spark-rest", "DGX Spark", false, "12"],
|
||||
["cuda-rest", "RTX 5090", true, "4"],
|
||||
] as const) {
|
||||
test(`Omni deployment link ${deployment} preserves its hardware baseline`, async ({ page }) => {
|
||||
await page.goto(`/FastVideo/cookbook?deployment=fasth3-omniref%2F${deployment}`);
|
||||
await expect(page).toHaveURL(/minimax-h3\/fasth3-omniref/);
|
||||
await expect(page.getByRole("button", { name: label, exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByRole("button", { name: "Serving", exact: true }).click();
|
||||
await output(page, "config.yaml");
|
||||
const yaml = page.getByLabel("Generated YAML");
|
||||
await expect(yaml).toContainText(`"h3_sequential_load": ${sequential}`);
|
||||
await expect(yaml).toContainText('"layer_profile": "h3_dit_vsa"');
|
||||
await output(page, "Commands");
|
||||
await expect(page.getByLabel("Server command")).toContainText(`FASTVIDEO_H3_VAE_TILE_BATCH=${tileBatch}`);
|
||||
if (deployment === "spark-rest")
|
||||
await expect(page.getByLabel("Server command")).toContainText("FASTVIDEO_NVFP4_MM_BACKEND=cudnn");
|
||||
});
|
||||
}
|
||||
|
||||
test("packed FastH3 checkpoints keep their fixed precision", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-trim");
|
||||
await page.getByRole("tab", { name: "config.yaml" }).click();
|
||||
await expect(page.getByLabel("Generated YAML")).toContainText('"transformer_quant": "NVFP4"');
|
||||
await expect(page.getByLabel("Generated YAML")).toContainText('"layer_profile": "h3_dit_vsa"');
|
||||
await expect(page.getByText("Valid configuration", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("group", { name: "Transformer quantization", exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("MLX FastH3 stays unestimated and a GiB device keeps its unit", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-8step?deployment=fasth3-8step%2Fmlx-rest");
|
||||
await page.getByRole("button", { name: "+ Find or add a Mac", exact: true }).click();
|
||||
await page.getByLabel("Search hardware", { exact: true }).fill("M4 Max");
|
||||
await page.getByRole("radio", { name: /Apple M4 Max 128 GB/ }).click();
|
||||
await expect(page.getByText("Memory not estimated", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Details", exact: true }).click();
|
||||
await expect(page.getByText(/MLX checkpoints use a different precision/)).toBeVisible();
|
||||
await page.goto(
|
||||
"/FastVideo/cookbook/minimax-h3/fasth3-8step?hardware=custom&hwName=Big&hwType=dedicated&hwMemory=80&hwUnit=GiB&hwBackend=cuda",
|
||||
);
|
||||
await expect(page.getByText("Weights fit, little headroom", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("catalog lookup resolves aliases, variants, and unified memory without pooling", async ({ page }) => {
|
||||
@@ -552,7 +669,7 @@ test("catalog lookup resolves aliases, variants, and unified memory without pool
|
||||
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: "Clear hardware", exact: true }).click();
|
||||
await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click();
|
||||
await page.getByLabel("Search hardware", { exact: true }).fill("GB10");
|
||||
await page.getByRole("radio", { name: /DGX Spark/ }).click();
|
||||
@@ -572,9 +689,9 @@ test("MLX lookup uses unified memory and a custom Mac stays reusable", async ({
|
||||
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-selected")).toContainText("Usable memory: 96 GB");
|
||||
await expect(page.locator(".hardware-address-note")).toContainText("not dedicated VRAM");
|
||||
await expect(page.getByRole("button", { name: "Clear hardware target", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Clear hardware", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the directory filters by runtime without a hardware picker", async ({ page }) => {
|
||||
@@ -588,6 +705,33 @@ test("the directory filters by runtime without a hardware picker", async ({ page
|
||||
await expect(page.locator(".recipe-card").filter({ hasText: "FastWan2.1 1.3B" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("MLX Omni discovery and its original route expose the MLX launch command", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook");
|
||||
await page.getByLabel("Runtime", { exact: true }).selectOption("mlx");
|
||||
await page.getByLabel("Search recipes").fill("FastH3 Omni");
|
||||
const cards = page.locator(".recipe-card");
|
||||
await expect(cards).toHaveCount(1);
|
||||
await expect(cards).toHaveAttribute("href", "/FastVideo/cookbook/minimax-h3/models/fasth3-omniref-mlx");
|
||||
await cards.click();
|
||||
await expect(page.getByLabel("Inference command")).toContainText("convert_minimax_h3_mlx.py");
|
||||
await expect(page.getByLabel("Inference command")).toContainText("--mlx-checkpoint ./FastH3-OmniRef-MLX/int6");
|
||||
await expect(page.getByLabel("Inference command")).not.toContainText("fastvideo serve");
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Inference command")).toContainText("--mlx-checkpoint ./FastH3-OmniRef-MLX/int6");
|
||||
});
|
||||
|
||||
test("category counts match visible cards and exclude retired Preview", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook");
|
||||
await expect(page.locator(".recipe-card")).toHaveCount(36);
|
||||
for (const category of ["All models", "Video", "Image", "Audio", "World"]) {
|
||||
const button = page.getByRole("button", { name: category, exact: true });
|
||||
const count = Number(await button.locator(".recipe-filter-count").innerText());
|
||||
await button.click();
|
||||
await expect(page.locator(".recipe-card")).toHaveCount(count);
|
||||
await expect(page.locator(".recipe-card").filter({ hasText: "FastH3 Preview V0.2" })).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("task discovery filters the directory and round-trips through the URL", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook");
|
||||
await page.getByLabel("Task", { exact: true }).selectOption("Image to video");
|
||||
@@ -613,10 +757,10 @@ test("directory filters round-trip through the URL", async ({ page }) => {
|
||||
|
||||
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.getByText(/The saved device \(retired-gpu\) is not in this catalog/)).toBeVisible();
|
||||
await page.getByRole("button", { name: "Clear hardware", exact: true }).click();
|
||||
await expect(page).not.toHaveURL(/hardware=/);
|
||||
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("No hardware selected.", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("catalog search renders one plain no-results message", async ({ page }) => {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("Omni Apple silicon links open the published guide and MLX recipe", async ({ page }) => {
|
||||
for (const slash of ["", "/"]) {
|
||||
await page.goto(`/FastVideo/cookbook/minimax-h3/fasth3-omniref${slash}`);
|
||||
const guide = page.getByRole("link", { name: "FastH3 Omni on MLX", exact: true });
|
||||
await expect(guide).toHaveAttribute("href", "/FastVideo/inference/fasth3_omniref_mlx");
|
||||
await guide.click();
|
||||
await expect(page).toHaveURL(/\/FastVideo\/inference\/fasth3_omniref_mlx\/?$/);
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
}
|
||||
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-omniref");
|
||||
await page.getByRole("link", { name: "MLX recipe", exact: true }).click();
|
||||
await expect(page.getByLabel("Inference command")).toContainText("--mlx-checkpoint");
|
||||
});
|
||||
|
||||
test("GitHub stars refresh automatically and survive a failed refresh", async ({ page }) => {
|
||||
await page.clock.install();
|
||||
let requests = 0;
|
||||
@@ -159,6 +173,12 @@ test("the model catalog filters by family and checkpoint name", async ({ page })
|
||||
await expect(catalog.getByRole("status")).toHaveText("3 checkpoints");
|
||||
await page.getByRole("searchbox", { name: "Search checkpoints", exact: true }).fill("14B");
|
||||
await expect(catalog.getByRole("status")).toHaveText("1 checkpoint");
|
||||
await page.getByRole("button", { name: "All models", exact: true }).click();
|
||||
await page.getByRole("searchbox").fill("NVFP4");
|
||||
const codes = await catalog.locator(".model-checkpoints code").allInnerTexts();
|
||||
expect(codes.length).toBeGreaterThan(0);
|
||||
expect(codes.every((text) => text.includes("NVFP4"))).toBe(true);
|
||||
await expect(catalog.getByText("FastVideo/FastVideo-FastH3-8-Step-V2-FP8", { exact: true })).toHaveCount(0);
|
||||
await page.getByRole("searchbox").fill("no-such-model");
|
||||
await expect(catalog.getByRole("status")).toHaveText("0 checkpoints");
|
||||
await page.getByRole("button", { name: "Clear filters", exact: true }).click();
|
||||
@@ -178,7 +198,7 @@ test("family runbooks use React model cards without the old cookbook runtime", a
|
||||
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(
|
||||
await expect(page.getByRole("link", { name: "← All recipes", exact: true })).toHaveAttribute(
|
||||
"href",
|
||||
/\/FastVideo\/cookbook$/,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { readReadmeSections } from "../lib/readme.mjs";
|
||||
|
||||
const legacy = `# FastVideo
|
||||
|
||||
## NEWS
|
||||
- \`2026/10/06\`: FastH3 V2 runs on a single consumer machine.
|
||||
- \`2026/09/15\`: Release FastH3 8-Step V2.
|
||||
|
||||
## Awesome work using FastVideo or our research projects
|
||||
- [Some project](https://example.com)
|
||||
|
||||
## Community
|
||||
- Slack
|
||||
`;
|
||||
|
||||
const rewritten = `# FastVideo
|
||||
|
||||
## Overview
|
||||
FastVideo is a framework.
|
||||
|
||||
## Recent updates
|
||||
- **2026-10-06:** FastH3 V2 runs on a single consumer machine.
|
||||
- **2026-09-15:** Release FastH3 8-Step V2.
|
||||
|
||||
## Projects using FastVideo
|
||||
- [Some project](https://example.com)
|
||||
|
||||
## Community and contributing
|
||||
- Slack
|
||||
`;
|
||||
|
||||
test("reads the legacy README headings and dates", () => {
|
||||
const { news, projects } = readReadmeSections(legacy);
|
||||
assert.equal(news.length, 2);
|
||||
assert.deepEqual(
|
||||
news.map((item) => item.date),
|
||||
["2026/10/06", "2026/09/15"],
|
||||
);
|
||||
assert.match(news[0].text, /^FastH3 V2/);
|
||||
assert.match(projects, /Some project/);
|
||||
});
|
||||
|
||||
test("reads a rewritten README with sentence case headings and bold dates", () => {
|
||||
const { news, projects } = readReadmeSections(rewritten);
|
||||
assert.equal(news?.length, 2);
|
||||
assert.deepEqual(
|
||||
news.map((item) => item.date),
|
||||
["2026/10/06", "2026/09/15"],
|
||||
);
|
||||
assert.doesNotMatch(news[0].text, /\*\*/);
|
||||
assert.match(projects, /Some project/);
|
||||
assert.doesNotMatch(projects, /Slack/);
|
||||
});
|
||||
|
||||
test("stops at the next heading and ignores other sections", () => {
|
||||
const { news } = readReadmeSections(legacy);
|
||||
assert.doesNotMatch(news[1].text, /Awesome work/);
|
||||
});
|
||||
|
||||
test("returns null when a section is missing", () => {
|
||||
assert.deepEqual(readReadmeSections("# FastVideo\n\n## Overview\n"), { news: null, projects: null });
|
||||
});
|
||||
@@ -798,6 +798,39 @@
|
||||
.recipe-card-grid-configurable .recipe-card {
|
||||
border-block-start: 2px solid var(--blume-accent);
|
||||
}
|
||||
/* Family runbook cards are static: no curtain, so they need their own rhythm. */
|
||||
.recipe-card-static {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-block-size: 13.5rem;
|
||||
padding: 1.25rem 1.25rem 1rem;
|
||||
}
|
||||
.recipe-card-static .recipe-card-top {
|
||||
margin-block-end: 0;
|
||||
padding-block-end: 0;
|
||||
}
|
||||
.recipe-card-static h2 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.recipe-card-static footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-block-start: auto;
|
||||
margin-inline: 0;
|
||||
padding-block: 0.75rem 0;
|
||||
border-block-start: 1px solid var(--recipe-line);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.recipe-card-static footer span:first-child {
|
||||
color: var(--blume-muted-foreground);
|
||||
}
|
||||
.recipe-empty {
|
||||
padding-block: 2rem;
|
||||
text-align: center;
|
||||
@@ -1622,3 +1655,25 @@
|
||||
.recipe-request-code summary {
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
|
||||
/* Article rhythm.
|
||||
Blume's prose gives <details> no vertical margin, so consecutive cookbook
|
||||
sections touch. Tables inherit the body line height, which makes dense cells
|
||||
taller than the prose around them. Both are set here, once, instead of in
|
||||
each page's markup. */
|
||||
.prose details {
|
||||
margin-block: 1rem;
|
||||
}
|
||||
.prose details + details {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.prose summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
.prose table {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose td,
|
||||
.prose th {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"generated": "2026-10-28",
|
||||
"note": "Measured from Hugging Face safetensors files. Sizes are GiB at the checkpoint's own precision.",
|
||||
"models": {
|
||||
"fasth3-8step": {
|
||||
"parts": {
|
||||
"transformer": 65.29,
|
||||
"text_encoder": 62.13,
|
||||
"vae": 9.7,
|
||||
"audio_vae": 0.56
|
||||
},
|
||||
"alternates": {
|
||||
"transformer_nvfp4": 36.91,
|
||||
"transformer_fp8": 47.35,
|
||||
"vae_nvfp4_quality": 5.82,
|
||||
"vae_nvfp4_fast": 1.33,
|
||||
"text_encoder_quantized": 15.33
|
||||
},
|
||||
"sources": [
|
||||
"https://huggingface.co/FastVideo/FastVideo-FastH3-8-Step-V2/tree/main"
|
||||
]
|
||||
},
|
||||
"fasth3-4step": {
|
||||
"parts": {
|
||||
"transformer": 65.29,
|
||||
"text_encoder": 62.13,
|
||||
"vae": 9.7,
|
||||
"audio_vae": 0.56
|
||||
},
|
||||
"alternates": {
|
||||
"transformer_nvfp4": 36.91,
|
||||
"transformer_fp8": 47.35,
|
||||
"vae_nvfp4_quality": 5.82,
|
||||
"vae_nvfp4_fast": 1.33,
|
||||
"text_encoder_quantized": 15.33
|
||||
},
|
||||
"sources": [
|
||||
"https://huggingface.co/FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree/tree/main"
|
||||
]
|
||||
},
|
||||
"fasth3-trim": {
|
||||
"parts": {
|
||||
"transformer": 34.94,
|
||||
"text_encoder": 15.33,
|
||||
"vae": 5.93,
|
||||
"audio_vae": 0.56
|
||||
},
|
||||
"alternates": {
|
||||
"transformer_nvfp4": 11.1,
|
||||
"transformer_fp8": 19.88,
|
||||
"vae_nvfp4_quality": 5.82,
|
||||
"vae_nvfp4_fast": 1.33
|
||||
},
|
||||
"sources": [
|
||||
"https://huggingface.co/FastVideo/FastVideo-FastH3-Trim-8-Step/tree/main"
|
||||
]
|
||||
},
|
||||
"fasth3-omniref": {
|
||||
"parts": {
|
||||
"transformer": 65.33,
|
||||
"text_encoder": 15.33,
|
||||
"vae": 9.7,
|
||||
"audio_vae": 0.56
|
||||
},
|
||||
"alternates": {
|
||||
"transformer_nvfp4": 36.95,
|
||||
"transformer_fp8": 47.39,
|
||||
"vae_nvfp4_quality": 5.82,
|
||||
"vae_nvfp4_fast": 1.33
|
||||
},
|
||||
"sources": [
|
||||
"https://huggingface.co/FastVideo/FastVideo-FastH3-Omni-8-Step-V1/tree/main",
|
||||
"https://huggingface.co/FastVideo/FastH3-Omni-NVFP4-QAD/tree/main"
|
||||
]
|
||||
},
|
||||
"pika-h3-flex-t2va": {
|
||||
"parts": {
|
||||
"transformer": 61.73,
|
||||
"adapter": 2.58,
|
||||
"text_encoder": 62.13,
|
||||
"vae": 9.7,
|
||||
"audio_vae": 0.56
|
||||
},
|
||||
"alternates": {
|
||||
"vae_nvfp4_quality": 5.82,
|
||||
"vae_nvfp4_fast": 1.33,
|
||||
"text_encoder_quantized": 15.33
|
||||
},
|
||||
"sources": ["https://huggingface.co/Pika-Labs/Pika-H3-Flex/tree/main"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,15 +157,22 @@ __all__ = [
|
||||
|
||||
## 4. Register the Backend
|
||||
|
||||
Update `fastvideo/attention/backends/__init__.py` to export your new class.
|
||||
Registration is two edits:
|
||||
|
||||
```python
|
||||
from .mynew_attn import MyNewAttnBackend
|
||||
```
|
||||
1. Add a member to `AttentionBackendEnum` in `fastvideo/platforms/interface.py`.
|
||||
2. Add the selection branch in `fastvideo/platforms/cuda.py`, next to the
|
||||
existing backends, so `--attention-backend` maps the enum to your class.
|
||||
|
||||
## 5. Platform Integration
|
||||
`fastvideo/attention/backends/__init__.py` is empty and does not register
|
||||
anything.
|
||||
|
||||
If your backend requires specific platform checks (e.g., checking for H100 support), handle that in `fastvideo/platforms/cuda.py` or within your backend's `__init__`.
|
||||
## 5. Platform integration
|
||||
|
||||
If your backend needs platform checks, such as an H100 guard, put them in
|
||||
`fastvideo/platforms/cuda.py` or in the backend constructor.
|
||||
|
||||
The maintained version of this guide is
|
||||
[Add an attention backend](../../contributing/attention_backend.md).
|
||||
|
||||
## 6. Add Documentation
|
||||
|
||||
|
||||
@@ -85,10 +85,10 @@ from fastvideo_kernel import video_sparse_attn
|
||||
# topk: Number of blocks to attend
|
||||
|
||||
output = video_sparse_attn(
|
||||
q, k, v,
|
||||
block_sizes,
|
||||
block_sizes,
|
||||
topk=32
|
||||
q, k, v,
|
||||
variable_block_sizes,
|
||||
q_variable_block_sizes,
|
||||
topk=32,
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ node docs/js/build-cookbook-capacity.mjs --check
|
||||
node docs/js/build-cookbook-capacity.mjs --preview fastwan21
|
||||
```
|
||||
|
||||
The check currently prints `Validated 1 capacity model(s) and 50 hardware variant(s).`
|
||||
The check currently prints `Validated 1 capacity model(s) and 52 hardware variant(s).`
|
||||
The preview prints the model with component references expanded and each
|
||||
example's baseline under `base_config`. Inspect its identity, counts, geometry,
|
||||
and evidence. Both commands validate deployments and capacity metadata without
|
||||
@@ -404,6 +404,41 @@ matching metric, scope, phases, and execution context. One point can calibrate a
|
||||
existing scaling assumption, not establish accuracy across modes. Missing startup
|
||||
evidence leaves startup coverage unknown. Keep observations intact when deriving coefficients.
|
||||
|
||||
## FastH3 coverage
|
||||
|
||||
No FastH3 checkpoint has a full capacity model yet. A weights-only preflight
|
||||
is live on the FastH3 pages: it compares measured resident checkpoint weights
|
||||
with the selected device and labels the result as weights, not a request peak.
|
||||
Host-only system RAM is not accepted as GPU capacity. Transformer quantization,
|
||||
TAEH3, and NVFP4 decoder swaps return unknown until their
|
||||
effective layer profiles, retained components, and packed storage are modeled.
|
||||
A full estimate still stops early: `checkCoverage()` in `docs/js/cookbook-memory.mjs` still stops before the
|
||||
estimate for every FastH3 serving profile.
|
||||
|
||||
| Profile | First stop | Why |
|
||||
| --- | --- | --- |
|
||||
| OmniRef | Workload | Runs image-to-video; the estimator models text-to-video only |
|
||||
| 8-Step CUDA | Topology | Defaults to four GPUs and Ulysses sequence parallelism |
|
||||
| Trim Spark | Attention | `VIDEO_SPARSE_ATTN_H3` is not a dense backend, so VSA workspace is unmodeled |
|
||||
| 4-Step Spark | Execution | `h3_sequential_load` loads components on demand |
|
||||
| Omni NVFP4 decoder | Precision | The decoder swap changes decoder precision |
|
||||
|
||||
To make a FastH3 estimate possible, in order:
|
||||
|
||||
1. Add capacity entries for each checkpoint: transformer parameter counts and
|
||||
geometry, the text encoder, the VAE, and the NVFP4 decoder variants.
|
||||
2. Model an image-to-video request: reference latents and audio tokens change
|
||||
the working set, and OmniRef passes ordered image, video, and audio inputs.
|
||||
3. Model per-rank sharding for four-GPU Ulysses, including replicated weights
|
||||
and per-rank sequence slices, so the estimate compares against one device.
|
||||
4. Add a VSA-H3 workspace term, or a dense upper bound that is labeled as one.
|
||||
5. Support quantized decoder precision instead of rejecting the configuration.
|
||||
6. Record successful runs on the RTX 5090, RTX PRO 6000, and DGX Spark profiles
|
||||
so the analytical estimate can be calibrated rather than trusted blindly.
|
||||
|
||||
Until those land, the cookbook reports a weights fact for FastH3 instead of a
|
||||
peak estimate. That is the correct result, not a bug.
|
||||
|
||||
## Static consumers
|
||||
|
||||
[`cookbook-capacity.mjs`](../js/cookbook-capacity.mjs) exposes these async loaders,
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>DGX Spark</td>
|
||||
<td>FastH3 V1 on one GB10, or two Sparks with Ray sequence parallel (<code>sp_size=2</code>) over QSFP RoCE. Select NVIDIA DGX Spark, then 1 Spark or 2 Sparks.</td>
|
||||
<td>FastH3 V1 on one GB10. Two Sparks run one clip faster over QSFP RoCE (<code>sp_size=2</code>), but this page configures one Spark.</td>
|
||||
<td>Not wired</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -100,7 +100,7 @@ cd FastVideo</code></pre>
|
||||
<li>The FastH3 CUDA performance profile was measured on four GB200 GPUs. Use its strict profile when exact operation order matters more than the measured performance configuration.</li>
|
||||
<li>The MLX source runtime supports T2VA, optional temporal <code>--fast</code>, optional spatial <code>--fast-spatial</code>, and opt-in VSA on <code>--include-vsa</code> checkpoints. FastH3 V2 MLX converts with <code>--include-vsa</code> and runs eight forwards. FL2VA, Ref2VA, and two-pass refinement are not wired.</li>
|
||||
<li>GPU count and VAE decode backend are configurable in the builder above for FastH3 CUDA recipes. Only the value shown by default has a recorded run; other supported values are unmeasured here.</li>
|
||||
<li>DGX Spark is a runtime on FastH3 V1, not a separate family card. Select NVIDIA DGX Spark, then 1 Spark or 2 Sparks. The CUDA GPU-count knob does not apply to Spark.</li>
|
||||
<li>DGX Spark is a runtime on FastH3 V1, not a separate family card. This page configures one Spark. For two Sparks, run <code>examples/inference/basic/basic_fasth3_spark_pair.yaml</code>; there is no serving baseline. The CUDA GPU-count knob does not apply to Spark.</li>
|
||||
<li>CompactH3 NVFP4 is one Blackwell GPU. RTX 5090 (32 GB) parks the encoder in pinned host RAM. RTX PRO 6000 Blackwell (96 GB) keeps encoder, DiT, and VAE resident. Keep <code>FASTVIDEO_ATTENTION_BACKEND=ATTN_QAT_INFER</code>, <code>FASTVIDEO_FA4=0</code>, <code>FASTVIDEO_VSA_PTX_BLACKWELL=0</code>, and <code>FLASHINFER_CUDA_ARCH_LIST=12.0a</code>. On PRO 6000 enable VAE compile and leave DiT <code>inference_torch_compile</code> off. CompactH3 is a dense prune; do not enable <code>VIDEO_SPARSE_ATTN_H3</code> until a VSA-trained student exists.</li>
|
||||
<li>GB10 has no FA4 / ptx_blackwell VSA kernel. Keep <code>FASTVIDEO_FA4=0</code> and <code>FASTVIDEO_VSA_PTX_BLACKWELL=0</code>. Legal <code>num_frames</code> values are <code>17n+5</code>, capped at 362 (15.08 s). A 345-frame request on one Spark can OOM. Native 16:9 sizes include 832×480 and 1344×768.</li>
|
||||
<li>Gated or missing checkpoints: run <code>huggingface-cli login</code> and confirm you accepted the model's license on Hugging Face.</li>
|
||||
@@ -111,6 +111,6 @@ cd FastVideo</code></pre>
|
||||
<details id="cookbook-evidence">
|
||||
<summary>Evidence status</summary>
|
||||
<div >
|
||||
<p>Every command, model ID, and flag on this page maps to a checked-in FastVideo source. Recipes marked <strong>Verified</strong> also have a recorded hardware path in linked FastVideo evidence. The full H3 CUDA examples remain <strong>Source-backed</strong> where the source records a GPU count but no GPU model or memory requirement. Unlisted hardware is unknown, not unsupported.</p>
|
||||
<p>Every command, model ID, and flag on this page maps to a checked-in FastVideo source. No recipe here carries a recorded run for a specific device, so the editor does not claim one. The full H3 CUDA examples remain <strong>Source-backed</strong> where the source records a GPU count but no GPU model or memory requirement. Unlisted hardware is unknown, not unsupported.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
+17
-20
@@ -1,30 +1,27 @@
|
||||
# Run H3 with a server and playground
|
||||
|
||||
Start FastH3 on CUDA, one DGX Spark, or Apple Silicon MLX, then iterate on prompts in a browser,
|
||||
with cURL, or from your app. The server and clients can run on the same machine.
|
||||
Start FastH3 on CUDA, one DGX Spark, or Apple Silicon MLX, then send requests from a
|
||||
browser, cURL, or your app. Several clients can share one server process.
|
||||
|
||||
The server supports the OpenAI-compatible video-job API. The Python and
|
||||
JavaScript client examples use that interface, but requests go to FastVideo.
|
||||
You do not need an OpenAI account or cloud key.
|
||||
CUDA requests reuse a loaded `VideoGenerator`. MLX keeps one pipeline object and
|
||||
a prompt-embedding cache, but loads and releases heavyweight components between
|
||||
phases to fit unified memory. MLX serving still pays that component-loading cost
|
||||
on subsequent requests.
|
||||
|
||||
The [H3 recipe selector](minimax-h3.md) provides the same workflow with runtime
|
||||
selection. This guide covers FastH3 V1 (four forwards) and FastH3 V2
|
||||
(eight forwards). Start a server, then iterate in the playground or with the
|
||||
OpenAI Python client. Other H3 recipes keep their direct Python commands.
|
||||
The server implements the OpenAI-compatible video-job API. The Python and
|
||||
JavaScript clients use that API, but requests go to FastVideo. You do not need an
|
||||
OpenAI account or cloud key.
|
||||
|
||||
CUDA requests reuse one loaded `VideoGenerator`. The Python SDK can do the same
|
||||
when you reuse the generator across `generate()` calls. MLX keeps one
|
||||
`MiniMaxH3MLXPipeline` and a prompt-embedding cache, but still loads and releases
|
||||
components between phases to fit unified memory. MLX serving does not keep all
|
||||
weights resident or remove phase-loading time. Use a server when you want
|
||||
separate clients to share the process without restarting scripts.
|
||||
This guide covers FastH3 V1 (four forwards) and FastH3 V2 (eight forwards). For
|
||||
direct Python generation, see the [H3 cookbook](minimax-h3.md).
|
||||
|
||||
## Install and start the server
|
||||
|
||||
All runtimes need a FastVideo clone and an activated Python environment.
|
||||
|
||||
### CUDA
|
||||
|
||||
Use a FastVideo clone and an activated Python environment. Complete the
|
||||
[CUDA installation requirements](../getting_started/installation/gpu.md)
|
||||
Complete the [CUDA installation requirements](../getting_started/installation/gpu.md)
|
||||
before running these commands:
|
||||
|
||||
```bash
|
||||
@@ -33,9 +30,9 @@ fastvideo serve --config examples/serving/openai_fasth3.yaml --server.host 127.0
|
||||
```
|
||||
|
||||
The configuration loads `FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2` and
|
||||
advertises it as `fasth3`. It configures four CUDA GPUs but does not record
|
||||
a GPU model or VRAM requirement. This is a source-backed server profile, not
|
||||
the measured GB200 Python performance profile. Compilation is disabled.
|
||||
advertises it as `fasth3`. It configures four CUDA GPUs and does not record a GPU
|
||||
model or VRAM requirement, so treat it as a starting point, not a measured
|
||||
profile. Compilation is disabled.
|
||||
|
||||
For FastH3 V2, use the same install and the 8-step config (nine sigma
|
||||
points, eight DiT forwards, VSA sparsity 0.8):
|
||||
|
||||
@@ -97,6 +97,14 @@ runtimes:
|
||||
title: Compile transformer
|
||||
type: boolean
|
||||
default: false
|
||||
generator.engine.quantization.transformer_quant:
|
||||
title: Transformer quantization
|
||||
description: >-
|
||||
Quantizes the DiT at load time. NVFP4 needs a Blackwell GPU. FP8 runs on Hopper and Ada.
|
||||
Leave empty to use the checkpoint's own precision.
|
||||
type: [string, "null"]
|
||||
enum: [null, NVFP4, FP8]
|
||||
default: null
|
||||
# MiniMax-H3 only (fastvideo_args.video_decode_backend). TAEH3 fetches its own SHA-pinned weights.
|
||||
generator.pipeline.experimental.video_decode_backend:
|
||||
title: Video decoder
|
||||
|
||||
@@ -24,6 +24,7 @@ deployments:
|
||||
- generator.engine.parallelism.tp_size
|
||||
- generator.engine.parallelism.sp_size
|
||||
- generator.engine.offload.text_encoder
|
||||
- generator.engine.quantization.transformer_quant
|
||||
- generator.engine.offload.vae
|
||||
- generator.pipeline.experimental.video_decode_backend
|
||||
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
|
||||
|
||||
@@ -21,6 +21,7 @@ deployments:
|
||||
- generator.engine.parallelism.*
|
||||
- "!generator.engine.parallelism.hsdp_*"
|
||||
- generator.engine.offload.text_encoder
|
||||
- generator.engine.quantization.transformer_quant
|
||||
- generator.engine.offload.vae
|
||||
- generator.pipeline.experimental.video_decode_backend
|
||||
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
title: FastH3 OmniRef
|
||||
title: FastH3 Omni v1
|
||||
summary: Video and synchronized audio from a prompt plus ordered image, video, and audio references, in eight forwards.
|
||||
guide: docs/inference/fasth3_omniref.md
|
||||
deployments:
|
||||
@@ -39,10 +39,12 @@ deployments:
|
||||
directories in server.media_roots.
|
||||
- Based on the maintained inference recipe; this serving adaptation has not been GPU-validated.
|
||||
- "Optional NVFP4 decoder (Blackwell only): hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae"
|
||||
- "Optional NVFP4 text encoder (Blackwell only): hf download FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer --include 'text_encoder/*' --local-dir fasth3-nvfp4-encoder"
|
||||
options: &omniref_options
|
||||
- server.*
|
||||
- generator.engine.offload.text_encoder
|
||||
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
|
||||
- generator.pipeline.components.text_encoder_weights
|
||||
- default_request.sampling.num_frames
|
||||
- default_request.sampling.height
|
||||
- default_request.sampling.width
|
||||
@@ -92,6 +94,7 @@ deployments:
|
||||
directories in server.media_roots.
|
||||
- Based on the maintained inference recipe; this serving adaptation has not been GPU-validated.
|
||||
- "Optional NVFP4 decoder (Blackwell only): hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae"
|
||||
- "Optional NVFP4 text encoder (Blackwell only): hf download FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer --include 'text_encoder/*' --local-dir fasth3-nvfp4-encoder"
|
||||
options: *omniref_options
|
||||
overrides:
|
||||
default_request.sampling.num_frames:
|
||||
@@ -142,5 +145,6 @@ deployments:
|
||||
directories in server.media_roots.
|
||||
- Based on the maintained inference recipe; this serving adaptation has not been GPU-validated.
|
||||
- "Optional NVFP4 decoder (Blackwell only): hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae"
|
||||
- "Optional NVFP4 text encoder (Blackwell only): hf download FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer --include 'text_encoder/*' --local-dir fasth3-nvfp4-encoder"
|
||||
options: *omniref_options
|
||||
overrides: *omniref_overrides
|
||||
|
||||
@@ -21,6 +21,7 @@ deployments:
|
||||
- Based on the released one-Spark inference recipe. This serving adaptation and edited hardware combinations have not been GPU-validated.
|
||||
- The checkpoint includes NVFP4 weights and encoder, a lightweight video VAE, and its trained eight-forward schedule. No local CompactH3 assembly is needed.
|
||||
- "Optional NVFP4 decoder (Blackwell only): hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' 'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae"
|
||||
- "Optional NVFP4 text encoder (Blackwell only): hf download FastVideo/FastVideo-FastH3-8-Step-V2-NVFP4-Consumer --include 'text_encoder/*' --local-dir fasth3-nvfp4-encoder"
|
||||
options:
|
||||
- server.*
|
||||
- generator.engine.num_gpus
|
||||
@@ -30,6 +31,7 @@ deployments:
|
||||
- generator.engine.offload.vae
|
||||
- generator.pipeline.experimental.video_decode_backend
|
||||
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
|
||||
- generator.pipeline.components.text_encoder_weights
|
||||
- default_request.prompt
|
||||
- default_request.sampling.num_frames
|
||||
- default_request.sampling.height
|
||||
|
||||
@@ -35,7 +35,7 @@ The preprocessing steps are identical to training. Run the appropriate preproces
|
||||
|
||||
```bash
|
||||
# For Wan2.1 T2V
|
||||
bash scripts/preprocess/v1_preprocess_wan_data_t2v
|
||||
bash scripts/preprocess/v1_preprocess_wan_data_t2v.sh
|
||||
|
||||
# For Wan2.2 TI2V
|
||||
bash examples/distill/Wan2.2-TI2V-5B-Diffusers/crush_smol/preprocess_wan_data_ti2v_5b.sh
|
||||
|
||||
@@ -11,16 +11,16 @@ Attention. See [Attn-QAT Training](../training/attn_qat.md).
|
||||
|
||||
## 📊 Model Overview
|
||||
|
||||
We provide two distilled models:
|
||||
We provide three distilled models:
|
||||
|
||||
- **[FastWan2.1-T2V-1.3B-Diffusers](https://huggingface.co/FastVideo/FastWan2.1-T2V-1.3B-Diffusers)**: 3-step inference, up to **16 FPS** on H100 GPU
|
||||
- **[FastWan2.1-T2V-14B-480P-Diffusers](https://huggingface.co/FastVideo/FastWan2.1-T2V-14B-480P-Diffusers)**: 3-step inference, up to **60x speed up** at 480P, **90x speed up** at 720P for denoising loop
|
||||
- **[FastWan2.2-TI2V-5B-FullAttn-Diffusers](https://huggingface.co/FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers)**: 3-step inference, up to **50x speed up** at 720P for denoising loop
|
||||
|
||||
Both models are trained on **61×448×832** resolution but support generating videos with **any resolution** (1.3B model mainly support 480P, 14B model support 480P and 720P, quality may degrade for different resolutions).
|
||||
All three models are trained on 61 frames at 448×832 but support generating videos with **any resolution** (1.3B model mainly support 480P, 14B model support 480P and 720P, quality may degrade for different resolutions).
|
||||
|
||||
## ⚙️ Inference
|
||||
First install [VSA](../attention/vsa/index.md). Set `MODEL_BASE` to your own model path and run:
|
||||
First install [VSA](../attention/vsa/index.md), then run the 1.3B config. The config pins the checkpoint; edit `model_path` there to use a local copy.
|
||||
|
||||
```bash
|
||||
FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN \
|
||||
@@ -45,7 +45,7 @@ python scripts/huggingface/download_hf.py \
|
||||
|
||||
### Wan2.1 1.3B Model Sparse-Distill
|
||||
|
||||
For the 1.3B model, we use **4 nodes with 32 H200 GPUs** (8 GPUs per node):
|
||||
For the 1.3B model, we use **8 nodes with 64 H200 GPUs** (8 GPUs per node):
|
||||
|
||||
```bash
|
||||
# Multi-node training (8 nodes, 64 GPUs total)
|
||||
|
||||
@@ -6,9 +6,9 @@ load stands down when lazy owns deferral). Two boxes connected
|
||||
by the QSFP ConnectX-7 cables can run **one clip faster** and can hold a
|
||||
**longer clip** (up to the FastH3 15 s cap).
|
||||
|
||||
Copy-paste commands for both counts are on the
|
||||
[MiniMax H3 cookbook](../../cookbook/minimax-h3.md): FastH3 V1 → NVIDIA DGX
|
||||
Spark → 1 Spark or 2 Sparks.
|
||||
The [MiniMax H3 cookbook](../../cookbook/minimax-h3.md) covers one Spark. For
|
||||
two, run `examples/inference/basic/basic_fasth3_spark_pair.yaml`; it has no
|
||||
serving baseline.
|
||||
|
||||
This is FastVideo sequence parallel (`sp_size=2`) over Ray, not a third-party
|
||||
xDiT vendor. Do not install xDiT for this path.
|
||||
@@ -156,7 +156,7 @@ lightweight video VAE. Use the environment above plus the release kernel
|
||||
settings, then run one of these configs from the head:
|
||||
|
||||
```bash
|
||||
export FASTVIDEO_MINIMAX_H3_FUSIONS=all FASTVIDEO_NVFP4_MM_BACKEND=cutlass
|
||||
export FASTVIDEO_MINIMAX_H3_FUSIONS=all FASTVIDEO_NVFP4_MM_BACKEND=cudnn
|
||||
export FASTVIDEO_H3_VAE_TILE_BATCH=1 FASTVIDEO_VSA_TRITON=1
|
||||
export FASTVIDEO_VSA_PTX_BLACKWELL=0 FASTVIDEO_FA4=0
|
||||
export FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3 FASTVIDEO_STAGE_LOGGING=1
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
# 🚀 Quick Start
|
||||
# Quick start
|
||||
|
||||
Get up and running with FastVideo in minutes!
|
||||
Install FastVideo, generate one video, then start from a maintained recipe.
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
First, install FastVideo:
|
||||
Requires Python 3.10 to 3.12 and CUDA 12.6 or newer.
|
||||
|
||||
```bash
|
||||
# If you previously used Conda, use uv instead for a faster, more stable setup
|
||||
uv venv --python 3.12 --seed
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install FastVideo on NVIDIA CUDA 12
|
||||
# CUDA 12. Use cu130 on CUDA 13.
|
||||
UV_TORCH_BACKEND=cu126 uv pip install fastvideo
|
||||
```
|
||||
|
||||
Use `UV_TORCH_BACKEND=cu130` instead on CUDA 13.
|
||||
|
||||
Also optionally install flash-attn:
|
||||
For the fastest attention kernels, install flash-attn:
|
||||
|
||||
```bash
|
||||
uv pip install flash-attn --no-build-isolation -v
|
||||
```
|
||||
|
||||
## Generate a video
|
||||
|
||||
```python
|
||||
from fastvideo import VideoGenerator
|
||||
|
||||
generator = VideoGenerator.from_pretrained(
|
||||
"Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
|
||||
num_gpus=1,
|
||||
text_encoder_cpu_offload=True,
|
||||
)
|
||||
generator.generate_video(
|
||||
"A raccoon peers through a field of sunflowers.",
|
||||
output_path="videos/",
|
||||
save_video=True,
|
||||
)
|
||||
```
|
||||
|
||||
The first run downloads the Wan2.1 1.3B checkpoint. The call writes an MP4 under `videos/`. The full example is `examples/inference/basic/basic.py`.
|
||||
|
||||
## Choose a maintained recipe
|
||||
|
||||
The cookbook selects complete, checked-in recipes instead of mixing model,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Welcome to FastVideo
|
||||
|
||||
FastVideo is an inference and post-training framework for diffusion models. It features an end-to-end unified pipeline for accelerating diffusion models, starting from data preprocessing to model training, finetuning, distillation, and inference. FastVideo is designed to be modular and extensible, allowing users to easily add new optimizations and techniques. Whether it is training-free optimizations or post-training optimizations, FastVideo has you covered.
|
||||
FastVideo is an inference and post-training framework for video diffusion models. One pipeline covers data preprocessing, fine-tuning, distillation, and inference. Attention backends, parallelism, and quantization are separate modules, so you can add one without changing model code.
|
||||
|
||||
## Key Features
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ PipelineConfig (fastvideo/configs/pipelines/base.py)
|
||||
│ ├── WanT2V720PConfig
|
||||
│ └── WanI2V480PConfig
|
||||
├── HunyuanConfig (fastvideo/configs/pipelines/hunyuan.py)
|
||||
├── LTX2T2VConfig (fastvideo/configs/pipelines/ltx2.py)
|
||||
├── LTX2T2VConfig (fastvideo/pipelines/basic/ltx2/pipeline_configs.py)
|
||||
├── CosmosConfig (fastvideo/configs/pipelines/cosmos.py)
|
||||
└── ... (15+ model families)
|
||||
```
|
||||
@@ -136,7 +136,7 @@ Concrete hierarchy: `DiTConfig` → `DiTArchConfig`, `VAEConfig` →
|
||||
### SamplingParam (`fastvideo/api/sampling_param.py`)
|
||||
|
||||
Generation parameters separate from pipeline config. Each model family
|
||||
provides defaults via a profile (see `fastvideo/pipelines/basic/<family>/profiles.py`):
|
||||
provides defaults via a profile (see `fastvideo/pipelines/basic/<family>/presets.py`):
|
||||
|
||||
```python
|
||||
sp = SamplingParam.from_pretrained("Wan-AI/Wan2.1-T2V-1.3B-Diffusers")
|
||||
@@ -505,7 +505,7 @@ User: generator.generate_video(prompt, ...)
|
||||
flow_shift, precision defaults.
|
||||
|
||||
2. **Sampling param profile** — Create a profile in
|
||||
`fastvideo/pipelines/basic/<model>/profiles.py` with default height,
|
||||
`fastvideo/pipelines/basic/<model>/presets.py` with default height,
|
||||
width, num_frames, guidance_scale, num_inference_steps.
|
||||
|
||||
3. **Register configs** — In `fastvideo/registry.py`, add a
|
||||
|
||||
@@ -72,7 +72,7 @@ def main():
|
||||
sampling_param.guidance_scale = 7.5
|
||||
|
||||
# Random seed for reproducibility
|
||||
sampling_param.seed = 42 # Optional, leave unset for random results
|
||||
sampling_param.seed = 42 # Optional; the default is 1024
|
||||
|
||||
# Generate video with custom parameters
|
||||
prompt = "A beautiful sunset over a calm ocean, with gentle waves."
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/Users/aryank/os/FastVideo-review-int/docs/node_modules
|
||||
@@ -12,6 +12,25 @@ function at(value, path) {
|
||||
return path.split(".").reduce((current, key) => current?.[key], value);
|
||||
}
|
||||
|
||||
test("every local NVFP4 encoder choice includes its download requirement", () => {
|
||||
const path = "generator.pipeline.components.text_encoder_weights";
|
||||
const catalogs = buildCatalogs().filter((catalog) => catalog.controls.some((control) => control.path === path));
|
||||
assert.ok(catalogs.length > 0);
|
||||
for (const catalog of catalogs) {
|
||||
const option = catalog.controls.find((control) => control.path === path);
|
||||
if (!option.schema.enum?.includes("./fasth3-nvfp4-encoder/text_encoder")) continue;
|
||||
assert.ok(
|
||||
catalog.requirements.some(
|
||||
(requirement) =>
|
||||
requirement.includes("hf download ") &&
|
||||
requirement.includes("--include 'text_encoder/*'") &&
|
||||
requirement.includes("--local-dir fasth3-nvfp4-encoder"),
|
||||
),
|
||||
`${catalog.id} must explain how to create the selected encoder directory`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function fixture({ interfaceName = "rest", backend = "cuda", workload = "t2v" } = {}) {
|
||||
return {
|
||||
id: "example/deployment",
|
||||
|
||||
@@ -56,7 +56,7 @@ torchrun --nproc_per_node=$GPU_NUM \
|
||||
| `--preprocess.dataset_path` | Path to dataset (HF repo ID or local folder) |
|
||||
| `--preprocess.dataset_output_dir` | Output directory for Parquet files |
|
||||
| `--preprocess.video_loader_type` | Video decoder: `torchcodec` or `torchvision` |
|
||||
| `--preprocess.max_height` / `max_width` | Target resolution for videos |
|
||||
| `--preprocess.max_height` / `--preprocess.max_width` | Target resolution for videos |
|
||||
| `--preprocess.num_frames` | Number of frames to extract per video |
|
||||
| `--preprocess.train_fps` | Target FPS for frame extraction |
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 🧠 Finetuning
|
||||
# Fine-tuning
|
||||
|
||||
This guide covers finetuning video diffusion models with FastVideo, including full finetuning and LoRA.
|
||||
|
||||
## Training Arguments
|
||||
## Training arguments
|
||||
|
||||
FastVideo training scripts use several argument groups:
|
||||
|
||||
### Training Arguments
|
||||
### Loop and model arguments
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
|
||||
@@ -12,7 +12,7 @@ parquet that step produces.
|
||||
|
||||
## What you need
|
||||
|
||||
| | |
|
||||
| Requirement | Value |
|
||||
|---|---|
|
||||
| GPU | 48GB or more for the LoRA overfit; multi-GPU for full-parameter runs |
|
||||
| Disk | ~40GB for the checkpoint (DiT 17GB + Qwen2.5-VL 16GB + ByT5/VAE) |
|
||||
@@ -145,16 +145,10 @@ a real problem — stop and investigate rather than burning GPU hours.
|
||||
## 4. Scale out
|
||||
|
||||
`examples/train/configs/fine_tuning/hunyuan15/t2v.yaml` runs
|
||||
full-parameter training across 8 GPUs with FSDP. Full-parameter training
|
||||
does not fit one 80GB card:
|
||||
full-parameter training across 8 GPUs with FSDP. One 80 GB card cannot hold it:
|
||||
|
||||
It reads `data/hunyuan15_preprocessed` (and its `validation_prompts.json`),
|
||||
not the overfit directory above — either preprocess into that path (set
|
||||
`FASTVIDEO_TEST_HUNYUAN15_OVERFIT_OUTPUT_DIR=data/hunyuan15_preprocessed`) or
|
||||
pass `--training.data.data_path` and `--callbacks.validation.dataset_file`.
|
||||
|
||||
| | bf16 |
|
||||
|---|---|
|
||||
| Item | Per rank (bf16) |
|
||||
|---|---:|
|
||||
| Weights | 16.7 GB |
|
||||
| Gradients | 16.7 GB |
|
||||
| AdamW `exp_avg` + `exp_avg_sq` | 33.4 GB |
|
||||
@@ -163,21 +157,27 @@ pass `--training.data.data_path` and `--callbacks.validation.dataset_file`.
|
||||
| **Total** | **~80 GB** |
|
||||
|
||||
That subtotal is independent of sequence length, so shortening the clip
|
||||
(`--training.data.num_latent_t`) barely helps — measured peak memory moved
|
||||
by 0.07GB going from 21 latent frames down to 9. Options that do move the
|
||||
needle:
|
||||
(`--training.data.num_latent_t`) barely helps: measured peak memory moved by
|
||||
0.07 GB going from 21 latent frames to 9. Options that do move the needle:
|
||||
|
||||
- **LoRA** (what the overfit config uses) — gradients and optimizer state
|
||||
shrink to the adapter parameters, ~30GB total.
|
||||
- **FSDP sharding across GPUs** — the 66.8GB subtotal divides by the number
|
||||
of shards.
|
||||
- **A memory-light optimizer** — SGD with momentum keeps a single state
|
||||
tensor instead of two. `torch.optim.Adafactor` is smaller still, but is
|
||||
not DTensor-safe: its in-place `.square_()` fails under FSDP2 with
|
||||
`aten.pow_.Scalar: in-place operations that require placement changes
|
||||
are not supported`.
|
||||
- **LoRA** (what the overfit config uses): gradients and optimizer state
|
||||
shrink to the adapter parameters, about 30 GB total.
|
||||
- **FSDP sharding across GPUs**: the 66.8 GB subtotal divides by the number of
|
||||
shards.
|
||||
- **A memory-light optimizer**: SGD with momentum keeps a single state tensor
|
||||
instead of two. `torch.optim.Adafactor` is smaller still, but is not
|
||||
DTensor-safe. Its in-place `.square_()` fails under FSDP2 with
|
||||
`aten.pow_.Scalar: in-place operations that require placement changes are
|
||||
not supported`.
|
||||
|
||||
Set `training.dit_precision: bf16` for any single-device run. The default
|
||||
The config reads `data/hunyuan15_preprocessed` and its
|
||||
`validation_prompts.json`, not the overfit directory above. Either preprocess
|
||||
into that path (set
|
||||
`FASTVIDEO_TEST_HUNYUAN15_OVERFIT_OUTPUT_DIR=data/hunyuan15_preprocessed`) or
|
||||
pass `--training.data.data_path` and `--callbacks.validation.dataset_file`.
|
||||
|
||||
The shipped overfit config already sets `training.dit_precision: bf16`. If you
|
||||
write your own config, set it for any single-device run. The default
|
||||
is fp32, which doubles weights, gradients, and optimizer state.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -18,7 +18,7 @@ should use the config-driven system documented here.
|
||||
### Launch with the helper script
|
||||
|
||||
```bash
|
||||
bash examples/train/run.sh examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml
|
||||
bash examples/train/run.sh examples/train/configs/distribution_matching/wan/dmd2_t2v.yaml
|
||||
```
|
||||
|
||||
The script auto-detects available GPUs and sets up `torchrun`. Override with
|
||||
@@ -35,7 +35,7 @@ NUM_GPUS=4 NNODES=2 NODE_RANK=0 \
|
||||
```bash
|
||||
torchrun --nproc_per_node=8 \
|
||||
fastvideo/train/entrypoint/train.py \
|
||||
--config examples/train/distill_wan2.1_t2v_1.3B_dmd2.yaml
|
||||
--config examples/train/configs/distribution_matching/wan/dmd2_t2v.yaml
|
||||
```
|
||||
|
||||
### CLI flags
|
||||
|
||||
Reference in New Issue
Block a user