Compare commits

..
76 changed files with 1301 additions and 2350 deletions
@@ -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"
+34 -36
View File
@@ -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>
+6 -1
View File
@@ -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).
@@ -1,7 +0,0 @@
---
title: Open-H3-Regenerate-2K
description: Configure Open-H3-Regenerate-2K serving, which regenerates a 768P MiniMax-H3 draft at 2560x1440.
mode: frame
---
<Cookbook modelId="open-h3-regenerate-2k" />
+112
View File
@@ -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
View File
@@ -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>
+3 -3
View File
@@ -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)}
/>
+5 -4
View File
@@ -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>
+1 -3
View File
@@ -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>
+226
View File
@@ -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;
}
}
+85 -73
View File
@@ -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,22 +41,13 @@ 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.",
tags: ["Reference to video", "Audio", "NVFP4"],
featured: true,
},
{
id: "open-h3-regenerate-2k",
family: "MiniMax H3",
title: "Open-H3-Regenerate-2K",
mark: "H3",
path: "/cookbook/minimax-h3/open-h3-regenerate-2k",
description: "Regenerate a 768P MiniMax-H3 draft at 2560x1440 with a three-step refiner.",
tags: ["Video to video", "2K", "3-step"],
},
{
id: "fastwan21",
family: "Wan",
@@ -133,11 +124,20 @@ const backendModels: Record<string, string> = {
"FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2": "fasth3-preview",
"FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree": "fasth3-4step",
"FastVideo/FastH3-OmniRef-NVFP4-QAD": "fasth3-omniref",
"Efficient-Large-Model/Open-H3-Regenerate-2K": "open-h3-regenerate-2k",
"FastVideo/FastWan2.1-T2V-1.3B-Diffusers": "fastwan21",
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers": "wan21-i2v",
"FastVideo/LTX2-Distilled-Diffusers": "ltx2-distilled",
};
const hiddenBackends = new Set(["fasth3-preview"]);
const modelAddedOn: Record<string, string> = {
"Qwen/Qwen-Image-2.1": "2026-10-25",
};
export function recentlyAdded(model: string) {
const added = modelAddedOn[model];
return !!added && Date.now() - Date.parse(added) < 30 * 86_400_000;
}
const TASK_GROUPS: Array<[RegExp, string]> = [
[/world/, "Text to world"],
[/video\/text to audio|video.*to audio/, "Video to audio"],
@@ -157,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];
+45
View File
@@ -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
+3 -15
View File
@@ -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,18 +162,10 @@
"label": "FastH3 Trim",
"href": "/cookbook/minimax-h3/fasth3-trim"
},
{
"label": "FastH3 Preview V0.2",
"href": "/cookbook/minimax-h3/fasth3-preview"
},
{
"label": "FastH3 4-Step V1",
"href": "/cookbook/minimax-h3/fasth3-4step"
},
{
"label": "Open-H3-Regenerate-2K",
"href": "/cookbook/minimax-h3/open-h3-regenerate-2k"
},
{
"label": "Recipes and setup",
"href": "/cookbook/minimax-h3"
@@ -191,7 +179,7 @@
{
"label": "Wan",
"display": "group",
"collapsed": false,
"collapsed": true,
"items": [
{
"label": "FastWan2.1 1.3B",
+1 -1
View File
@@ -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",
+11 -6
View File
@@ -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")
+1 -1
View File
@@ -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));
});
+180 -36
View File
@@ -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 }) => {
+21 -1
View File
@@ -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$/,
);
+64
View File
@@ -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 });
});
+55
View File
@@ -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;
}
-16
View File
@@ -760,22 +760,6 @@
],
"limitations": ["Pass --reference-audio for an additional audio reference. The checked-in source defaults to four GPUs."]
},
{
"id": "open-h3-regenerate-2k",
"family": "minimax_h3",
"stage": "inference",
"task": "Video super-resolution (2K regeneration)",
"label": "Open-H3-Regenerate-2K",
"summary": "Regenerate a 768P MiniMax-H3 draft at 2560x1440 with NVlabs' three-step refiner. The output is silent.",
"model": "Efficient-Large-Model/Open-H3-Regenerate-2K",
"source": "examples/inference/basic/basic_open_h3_regenerate_2k.py",
"command": ": \"${INPUT_VIDEO:?Set INPUT_VIDEO to a 768P MiniMax-H3 draft}\"\npython examples/inference/basic/basic_open_h3_regenerate_2k.py --video \"$INPUT_VIDEO\" --prompt \"A red fox trots through fresh snow in a pine forest at golden hour.\"",
"gpu_types": ["NVIDIA"],
"hardware": {"platform": "cuda", "gpu_count": 1, "evidence": "source-configured"},
"evidence": "Source-backed",
"expected_artifact": "2560x1440 MP4 at outputs/open_h3_regenerate_2k/regen2k_0.mp4",
"related": ["minimax-h3-t2v", "fasth3-8step-v2-cuda"]
},
{
"id": "fasth3-lora-preview",
"family": "minimax_h3",
+92
View File
@@ -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"]
}
}
}
+13 -6
View File
@@ -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
+4 -4
View File
@@ -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,
)
```
+36 -1
View File
@@ -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,
+3 -3
View File
@@ -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
View File
@@ -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):
+9 -1
View File
@@ -44,7 +44,7 @@ runtimes:
host: 0.0.0.0
port: 8000
served_model_name: null
workloads: [t2v, i2v, v2v]
workloads: [t2v, i2v]
options: &cuda_options
server.served_model_name:
anyOf:
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+5 -1
View File
@@ -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
+2
View File
@@ -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
@@ -1,18 +0,0 @@
title: Open-H3-Regenerate-2K
summary: Regenerate a 768P MiniMax-H3 draft at 2560x1440 with three refine steps.
deployments:
cuda-rest:
runtime: fastvideo-cuda-rest
guide: docs/inference/open_h3_regenerate_2k.md
workload: v2v
defaults: examples/serving/openai_open_h3_regenerate_2k.yaml
requirements:
- 'UV_TORCH_BACKEND=cu130 uv pip install -e ".[fasth3]"'
- "One GPU with about 150 GB free (B200 or GB200). Send the draft as video_reference; local paths must sit under server.media_roots."
- "Optional NVFP4 decoder (Blackwell only): hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae"
options:
- server.*
- generator.engine.offload.text_encoder
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
- default_request.sampling.seed
@@ -153,12 +153,6 @@ surfaces:
pipeline_config_extensions:
preset_owned:
draft_size:
sources:
- fastvideo.configs.pipelines.open_h3_regenerate_2k.OpenH3Regenerate2KPipelineConfig
refine_start_sigma:
sources:
- fastvideo.configs.pipelines.open_h3_regenerate_2k.OpenH3Regenerate2KPipelineConfig
audio_encoder_precision:
sources:
- fastvideo.configs.pipelines.wan.WanS2V14BConfig
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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
+25 -9
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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."
-199
View File
@@ -1,199 +0,0 @@
# Open-H3-Regenerate-2K
`OpenH3Regenerate2KPipeline` regenerates a 768P [MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) draft video
(1344x768, 121 frames, 24 fps) at **2560x1440** with three refine steps. It is video to video: the draft is the input,
and the output is a silent 2K MP4. FastVideo ports NVlabs'
[Open-H3-Regenerate-2K](https://huggingface.co/Efficient-Large-Model/Open-H3-Regenerate-2K)
([project page](https://nvlabs.github.io/Sana/Open-H3-Regenerate-2K/),
[reference code](https://github.com/NVlabs/Sana/tree/sol-engine/models/Open-H3-Regenerate-2K)); the dense BF16
`standard_fwd` path of that repo is the parity reference. The accelerated `sol_engine_fwd` path (Sol-Attn, MXFP8, CUDA
graphs) and the fast 24-layer VAE decoder are not ported.
```text
draft video -> H3 VAE encode -> latent upscaler (2x, 3D conv) -> 3 Euler refine steps -> H3 VAE decode -> 2560x1440
H3 DiT + merged LoRA + Sharp curve AdaLN branches
```
## What runs
| Step | Details |
|---|---|
| Draft encode | `ffmpeg` resamples to 24 fps, cover-scales (bilinear) and centre-crops to 1344x768 (`draft_size: 768p`, or 1024x576 for `576p`), holds the last frame to 121 frames plus 3 copies. The FP32 H3 VAE posterior is sampled under seed 42. |
| Latent upscale | The author's 3D-conv upscaler (`fastvideo/models/upsamplers/minimax_h3_latent_upscaler.py`), BF16, to the 160x90 latent grid. |
| Refine | Euler on sigmas `[0.909375, 0.857143, 0.666667, 0]`, CFG 1, model time `1 - sigma`, initial state `(1 - 0.909375) * upscaled + 0.909375 * noise`. `num_inference_steps: 2` uses `[0.7, 0.494118, 0]` (softer, about 30% faster). |
| Transformer | The base MiniMax-H3 DiT (the same module as the other H3 pipelines) with the released merged LoRA (FP32, rank 576 on 312 linears, scale 1) and 50 LMS Sharp curve AdaLN branches. |
| Decode | The released H3 VAE, or an NVFP4 decoder on Blackwell (see below); the output is trimmed to 121 frames. |
The merged LoRA loads through the standard H3 community-LoRA path as a constructor-time adapter: the pipeline sets
`lora_path` to `<model>/lora/open_h3_regenerate_2k_lora.safetensors` when you do not pass one. The Sharp branches take an
8-D timestep curve (`lora/adaln_t_table.bin`, 1025x8) instead of the layer input, so they cannot be folded into weights;
`sharp_curve.py` adds them to the AdaLN projection outputs with forward hooks.
**Audio.** The reference feeds an encoded-silence audio stream to the DiT as conditioning and writes the video with no
audio track (`-an`); it neither regenerates nor passes through the draft's audio. FastVideo does the same: the draft's
soundtrack is ignored and the output MP4 has no audio.
## Weights
The release repo keeps the unmodified MiniMax-H3 components (a copy of `MiniMaxAI/MiniMax-H3` at `6818f6c`) under
`base_model/` next to `lora/` and `latent_upsampler/`. Point `model_path` at the Hub id or at a local copy of that layout.
`adaln_t_table.bin` is base-derived and is not redistributed in the release repo: put the 32,800-byte file (SHA-256
`c02a6c11888297688c1e6278185ea1f947023acfc69f9003bbcdcec9a229a8e7`) at `lora/adaln_t_table.bin`. The reference
`download.py` fetches it by byte range from `Comfy-Org/MiniMax-H3` at revision
`e5eb578a89295337b8ff433a035929ce0279e0b6` (tensor `adaln_t_table` of
`diffusion_models/minimax_h3_ref2va_pruned_int8_convrot.safetensors`) and verifies the hash.
A GB200/B200-class GPU is needed (about 150 GB with the DiT, the 2K activations and the VAE).
## Usage
```bash
python examples/inference/basic/basic_open_h3_regenerate_2k.py \
--model-path Efficient-Large-Model/Open-H3-Regenerate-2K \
--video draft_768p.mp4 --prompt "A red fox trots through fresh snow" --output outputs/regen2k
```
The prompt conditions the refine steps (use the draft's own prompt). Drafts from other video models are better encoded at
576P (`--draft-size 576p`). `--steps 2` runs the two-step variant. The output is always 2560x1440; the first 121 frames of
the draft are used.
## Serving
```bash
fastvideo serve --config examples/serving/openai_open_h3_regenerate_2k.yaml
```
`examples/serving/openai_open_h3_regenerate_2k.yaml` loads the release on one GPU, sets `media_roots`, and pins the 2K
defaults. The draft arrives as the typed `video_reference` of `POST /v1/videos/sync` (the response body is the MP4) or of
`POST /v1/videos` (an async job; poll `GET /v1/videos/{id}` and download `GET /v1/videos/{id}/content`):
```bash
curl -sS -X POST http://localhost:8000/v1/videos/sync \
-H 'Content-Type: application/json' \
-d '{
"prompt": "A red fox trots through fresh snow",
"video_reference": {"video_url": "https://example.com/draft_768p.mp4"},
"seed": 1766580932
}' \
-o regen_2k.mp4 -D -
```
The response headers carry `X-Request-Id`, `X-Model`, `X-Inference-Time-S` and `X-Stage-Durations`. Rules:
- Exactly one `video_reference` (or the legacy `video_path` / `video_url`) and no image or audio references.
- `http(s)` URLs must resolve to public addresses unless `server.allow_private_media_urls` is set. A local path is accepted
only inside a directory listed in `server.media_roots`. `data:` URLs are refused for video references; this policy is
unchanged.
- `size`, `width` and `height` may be omitted or `2560x1440`; `num_frames` may be omitted or 124 (the frame count of the
VAE grid; the video has 121 frames); `fps` must be 24. `seconds` is not accepted.
- `num_inference_steps` is 3 (default) or 2; `seed` sets the refine noise.
- Serve with `attention_backend: TORCH_SDPA`: the refine stage selects cuDNN attention, which at about 133k tokens is
about 3x faster than PyTorch's default flash dispatch. The `FLASH_ATTN` backend bypasses that selection.
## NVFP4 video decoder
On Blackwell GPUs the released 36-block video decoder can be replaced by a pre-packed NVFP4 one; the latent space is
unchanged, so the draft encode, the upscale and the refine steps are the same and only the decode changes. Both tiers of
[FastVideo/FastH3-NVFP4-VAE-Decoder](https://huggingface.co/FastVideo/FastH3-NVFP4-VAE-Decoder) apply here, because the
release carries MiniMax-H3's full 36-block VAE (`base_model/vae`):
| Tier | Decoder blocks | File | Pick it when |
|---|---|---|---|
| `quality-36L` | 36 | `quality-36L/decoder-nvfp4.safetensors` | you want the released detail back; the default choice |
| `fast-8L` | 8 (evenly spaced; the load drops 28 blocks) | `fast-8L/decoder-nvfp4.safetensors` | decode time or memory dominate and some fine detail can go |
```bash
hf download FastVideo/FastH3-NVFP4-VAE-Decoder --include 'quality-36L/decoder-nvfp4.safetensors' \
'fast-8L/decoder-nvfp4.safetensors' --local-dir fasth3-nvfp4-vae
```
Select it through the surface you drive the pipeline with. The config field (also a dotted override of
`fastvideo generate`), the example's `--vae-decoder` and the environment variable all set
`fastvideo_args.h3_vae_nvfp4_decoder`, and the config field wins over the environment.
```bash
# example script
python examples/inference/basic/basic_open_h3_regenerate_2k.py --video draft.mp4 --prompt "..." \
--vae-decoder fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
# `fastvideo generate`: a run config, with the decoder as a dotted override
fastvideo generate --config regen2k_run.yaml \
--generator.pipeline.experimental.h3_vae_nvfp4_decoder fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
```
```yaml
generator:
pipeline:
experimental:
h3_vae_nvfp4_decoder: ./fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
```
`FASTVIDEO_H3_VAE_NVFP4_DECODER` stays as the fallback when the config does not set the field. The
swap happens in the VAE loader, so the dense decoder never reaches the GPU: it is replaced on the CPU by the exported
checkpoint (2.0 GiB of packed weights instead of 9.7 GiB of FP32), moved to the device, and decoded under bf16 autocast.
The decoder keeps the pipeline's tiling (at 2560x1440: 8x13 tiles of 256 px with 64 px overlap) and the fused Triton
block kernels, which are on by default (`FASTVIDEO_H3_VAE_NVFP4_FUSED`); `FASTVIDEO_H3_VAE_TILE_BATCH` decodes several
tiles per decoder call (4 is bit-identical and faster here), and `FASTVIDEO_H3_VAE_NVFP4_CUDA_GRAPH` replays the small
fused calls. The MP4 is unaffected: the same 121 frames are trimmed and written.
Warm decode of one refined latent at 2560x1440 (124 frames) on one GB200, median of 3, with the released decode as the
reference:
| Decoder | Decode (s) | Peak GPU memory (GiB) | PSNR (dB) | SSIM |
|---|---:|---:|---:|---:|
| released H3 VAE (dense, fp16 autocast) | 23.3 | 18.4 | - | - |
| `quality-36L` (fused) | 6.6 | 6.2 | 41.3 | 0.979 |
| `quality-36L` (fused, tile batch 4) | 5.0 | 6.3 | 41.3 | 0.979 |
| `quality-36L` (eager NVFP4 GEMM) | 67.0 | 6.2 | 41.3 | 0.979 |
| `fast-8L` (fused) | 1.7 | 5.3 | 32.8 | 0.892 |
| `fast-8L` (eager NVFP4 GEMM) | 16.1 | 5.2 | 32.8 | 0.892 |
The fused kernels are an exact speedup: the eager and fused rows decode bit-identical frames, and so does batching
tiles, so the PSNR/SSIM columns measure the tier and nothing else. The eager NVFP4 GEMM path loses to the dense fp16
decoder at these tile sizes (67 s against 23 s), so keep `FASTVIDEO_H3_VAE_NVFP4_FUSED` on. PSNR against the released
decode is bounded by the released decode itself: the refined latent already sits 45.2 dB from the NVlabs reference (see
below), so 41.3 dB is a decoder-only difference of the same order. `fast-8L` softens fine texture (fur, foliage) while
keeping structure; use it only when the decode budget is tight.
## Parity with the reference
Measured on one GB200 (BF16, `TORCH_SDPA` with cuDNN attention) against the reference `standard_fwd` with the released
VAE, same draft, seed (1766580932), prompt and VAE posterior seed (42). The reference's noise is a CUDA generator draw
that FastVideo reproduces (the same `torch.Generator(device="cuda")` seed), so no injected noise was needed.
| Stage / quantity | Fox draft (FastVideo H3 t2v, 768P) | Dance draft (576P, project page) |
|---|---|---|
| Draft latent, upscaled latent, DiT input | bit-identical | bit-identical |
| One DiT forward (LoRA + Sharp), relative L2 | 1.1e-2 | 2.6e-2 |
| Refined latent, relative L2 | 1.3e-2 | 3.7e-2 |
| Decoded video PSNR / SSIM (21 frames) | 45.2 dB / 0.993 | 42.1 dB / 0.992 |
The differences come from text-encoder rounding (relative L2 1e-3 on the prompt embeddings) and BF16 kernel choices;
the reference reports the same order (1.4% relative L2 on the refined latent between its own runs).
Warm timings, one GB200 (seconds):
| | Upscale | Refine (3 steps) | VAE decode | Total |
|---|---:|---:|---:|---:|
| Reference `standard_fwd` | 0.87 | 71.6 | 15.0 | 87.5 |
| FastVideo (end to end, incl. draft encode 5-10 s and decode to host) | 5.9-10.7 (encode + upscale + silence) | 69.5 | 25.0 | 107.5 |
The first request builds nothing extra but loads weights (about 70 s once). The decode stage is slower than the
reference because it also copies 121 frames to host memory and the MP4 encode is outside both columns.
## Licences
The release is not covered by one licence; its `README.md` says so, and the runtime code's licence does not replace the
model licences. FastVideo only loads these weights and does not redistribute them.
- The refiner LoRA, the fast H3 VAE decoder and `base_model/` (a copy of `MiniMaxAI/MiniMax-H3` at `6818f6c`: DiT, VAEs,
text encoder) follow the
[MiniMax H3 Community License](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/6818f6c32d12b210915e44ad56a4228c2608f160/LICENSE),
including its territory, use and redistribution terms (`base_model/LICENSE`, `NOTICE`). The Qwen3-VL text encoder's
Apache-2.0 text ships as `LICENSE-Qwen.txt`.
- The latent upscaler follows its own licence:
[LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler](https://github.com/LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler)
(MIT, per the reference code) and its [weights](https://huggingface.co/LBH-123-AI/Minimax_h3_latent_Upscaler)
(`latent_upsampler/LICENSE`, Apache-2.0). `minimax_h3_latent_upscaler.py` is a port of that node's network.
- The merged LoRA includes [LightX2V FL2VA Turbo](https://huggingface.co/lightx2v/Minimax-h3-Turbo) and
[LMS Sharp](https://huggingface.co/Alissonerdx/Minimax-H3-ComfyUI), which keep their own licences; the Sharp
`adaln_t_table` is read from [Comfy-Org/MiniMax-H3](https://huggingface.co/Comfy-Org/MiniMax-H3).
-4
View File
@@ -68,7 +68,6 @@ column links a runnable script in `examples/inference/basic/` where one exists.
| matrixgame | `FastVideo/Matrix-Game-2.0-Base-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-GTA-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-TempleRun-Distilled-Diffusers`<br />`FastVideo/Matrix-Game-2.0-Base-Diffusers`<br />`FastVideo/Matrix-Game-2.0-GTA-Diffusers`<br />`FastVideo/Matrix-Game-2.0-TempleRun-Diffusers`<br />`mignonjia/mg_longtuning_distilled_zelda`<br />`mignonjia/mg_sf_distilled_zelda_1k_steps`<br />`mignonjia/mg_sf_distilled_zelda`<br />`mignonjia/mg_causal_zelda`<br />`mignonjia/mg_bidirectional_zelda` | I2V | [basic_matrixgame2.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame2.py) |
| matrixgame | `FastVideo/Matrix-Game-3.0-Base-Distilled-Diffusers` | I2V | [basic_matrixgame3.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_matrixgame3.py) |
| minimax_h3 | `MiniMaxAI/MiniMax-H3` | T2V, I2V | [T2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_t2v.py)<br />[FL2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_fl2va.py)<br />[Ref2VA](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_minimax_h3_ref2va.py) |
| open_h3_regenerate_2k | `Efficient-Large-Model/Open-H3-Regenerate-2K` | — | [basic_open_h3_regenerate_2k.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_open_h3_regenerate_2k.py) |
| sd35 | `stabilityai/stable-diffusion-3.5-medium` | T2I | [basic_sd35_t2i.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_sd35_t2i.py) |
| qwen_image21 | `Qwen/Qwen-Image-2.1` | T2I, I2I | [basic_qwen_image21.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_qwen_image21.py) |
| stable_audio | `FastVideo/stable-audio-open-1.0-Diffusers` | T2V | [basic_stable_audio.py](https://github.com/hao-ai-lab/FastVideo/blob/main/examples/inference/basic/basic_stable_audio.py) |
@@ -122,9 +121,6 @@ and distilled) generate video with audio from text, optionally plus an image (se
`kandinsky6_sr` IDs upscale an existing video (see the
[Video SR guide](kandinsky6_sr.md)).
**Note (Open-H3-Regenerate-2K)**: `open_h3_regenerate_2k` regenerates an existing 768P MiniMax-H3 draft video at
2560x1440 (video to video, silent output); see the [Open-H3-Regenerate-2K guide](open_h3_regenerate_2k.md).
**Note (LTX-2.5)**: T2V and I2V produce synchronized video and audio. The
official `Lightricks/LTX-2.5` repository uses gated, split component files, so
it must first be converted to FastVideo's component layout. See the
-8
View File
@@ -32,14 +32,6 @@ export const WORKLOADS = Object.freeze({
extension: "wav",
media_type: "audio/wav",
}),
v2v: Object.freeze({
input: "video",
output: "video",
endpoint: "/v1/videos/sync",
extension: "mp4",
media_type: "video/mp4",
reference: "video_reference",
}),
v2a: Object.freeze({
input: "video",
output: "audio",
-1
View File
@@ -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",
-1
View File
@@ -488,7 +488,6 @@ test("sample requests follow shared workload media", () => {
t2i: { endpoint: /\/v1\/images\/generations/, output: /--output output\.json/, reference: null },
t2a: { endpoint: /\/v1\/videos\/sync/, output: /--output output\.wav/, reference: null },
v2a: { endpoint: /\/v1\/videos\/sync/, output: /--output output\.wav/, reference: "video_reference" },
v2v: { endpoint: /\/v1\/videos\/sync/, output: /--output output\.mp4/, reference: "video_reference" },
};
for (const [workload, expected] of Object.entries(expectations)) {
const data = catalog();
+1 -1
View File
@@ -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 |
+3 -3
View File
@@ -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 |
|----------|-------------|
+23 -23
View File
@@ -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
+2 -2
View File
@@ -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
@@ -1,93 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Regenerate a 768P MiniMax-H3 draft video at 2560x1440 with Open-H3-Regenerate-2K.
The draft is VAE-encoded (the first 121 frames at 24 fps, 1344x768), its latent is upscaled, and three refine steps
of the H3 transformer with the released LoRA run at 2560x1440. The output video is silent. See
docs/inference/open_h3_regenerate_2k.md.
python examples/inference/basic/basic_open_h3_regenerate_2k.py --video draft.mp4 --prompt "..." --output out/
"""
from __future__ import annotations
import argparse
import time
from pathlib import Path
from fastvideo import VideoGenerator
from fastvideo.api import (
ComponentConfig,
EngineConfig,
GenerationRequest,
GeneratorConfig,
InputConfig,
OffloadConfig,
OutputConfig,
PipelineSelection,
SamplingConfig,
)
DEFAULT_MODEL = "Efficient-Large-Model/Open-H3-Regenerate-2K"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--model-path", default=DEFAULT_MODEL, help="release repo id or a local copy of it")
parser.add_argument("--video", required=True, help="768P draft video (URL-free local path)")
parser.add_argument("--prompt", required=True, help="text the refine steps are conditioned on")
parser.add_argument("--output", type=Path, default=Path("outputs/open_h3_regenerate_2k"))
parser.add_argument("--steps", type=int, choices=(2, 3), default=3, help="refine steps (3 is the released setting)")
parser.add_argument("--seed", type=int, default=1766580932)
parser.add_argument("--attention-backend", default="TORCH_SDPA")
parser.add_argument("--draft-size", choices=("768p", "576p"), default="768p",
help="size the draft is encoded at; 576p for drafts from other video models")
parser.add_argument("--vae-decoder", default=None,
help="NVFP4 decoder-nvfp4.safetensors that replaces the VAE decoder at load "
"(Blackwell only, see docs/inference/open_h3_regenerate_2k.md)")
parser.add_argument("--runs", type=int, default=1, help="repeat the request to measure warm timings")
return parser.parse_args()
def main() -> None:
args = parse_args()
args.output.mkdir(parents=True, exist_ok=True)
experimental = {
"attention_backend": args.attention_backend,
"draft_size": args.draft_size,
}
if args.vae_decoder:
experimental["h3_vae_nvfp4_decoder"] = args.vae_decoder
generator = VideoGenerator.from_config(
GeneratorConfig(
model_path=args.model_path,
engine=EngineConfig(num_gpus=1, offload=OffloadConfig(dit=False, dit_layerwise=False, text_encoder=True, vae=False)),
pipeline=PipelineSelection(
components=ComponentConfig(),
experimental=experimental,
),
))
try:
for run in range(args.runs):
started = time.perf_counter()
result = generator.generate(
GenerationRequest(
prompt=args.prompt,
inputs=InputConfig(video_path=args.video),
sampling=SamplingConfig(height=1440,
width=2560,
num_frames=124,
fps=24,
num_inference_steps=args.steps,
guidance_scale=1.0,
seed=args.seed),
output=OutputConfig(output_path=str(args.output / f"regen2k_{run}.mp4"),
save_video=True,
return_frames=False),
))
print(f"run {run}: {time.perf_counter() - started:.1f}s -> {result.video_path}")
finally:
generator.shutdown()
if __name__ == "__main__":
main()
@@ -1,46 +0,0 @@
# OpenAI-compatible Open-H3-Regenerate-2K server (one GPU with about 150 GB free, e.g. B200 / GB200).
# POST /v1/videos/sync (or /v1/videos) with a 768P H3 draft as video_reference; the response is the 2560x1440 MP4.
# Local draft paths are accepted only inside server.media_roots; http(s) URLs must resolve to public addresses unless
# server.allow_private_media_urls is set. data: URLs are always refused. See docs/inference/open_h3_regenerate_2k.md.
generator:
model_path: Efficient-Large-Model/Open-H3-Regenerate-2K
engine:
num_gpus: 1
offload:
dit: false
dit_layerwise: false
text_encoder: true
vae: false
pin_cpu_memory: true
compile:
enabled: false
vae_enabled: false
pipeline:
experimental:
attention_backend: TORCH_SDPA
# 768p for MiniMax-H3 drafts; 576p encodes drafts from other video models at 1024x576.
draft_size: 768p
# NVFP4 video decoder (Blackwell only). Download one tier from FastVideo/FastH3-NVFP4-VAE-Decoder and point
# this at it; the release VAE has the full 36 blocks, so both tiers apply. `fast-8L` is the cheap option.
# h3_vae_nvfp4_decoder: ./fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_open_h3_regenerate_2k
served_model_name: open-h3-regenerate-2k
media_roots:
- /data/drafts
media_reference_max_bytes: 536870912
default_request:
sampling:
height: 1440
width: 2560
num_frames: 124
fps: 24
num_inference_steps: 3
guidance_scale: 1.0
seed: 1766580932
output:
return_frames: false
@@ -1,30 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Pipeline configuration for Open-H3-Regenerate-2K (768P H3 draft -> 2560x1440)."""
from __future__ import annotations
from dataclasses import dataclass
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
DRAFT_SIZE_CHOICES = ("768p", "576p")
@dataclass
class OpenH3Regenerate2KPipelineConfig(MiniMaxH3PipelineConfig):
"""MiniMax-H3 policy plus the refine-stage knobs.
``draft_size`` is the size the draft video is VAE-encoded at: 768p (1344x768) for MiniMax-H3 drafts, 576p
(1024x576) for drafts from other video models. ``refine_start_sigma`` re-noises the upscaled draft to another
level; the later steps then follow the shift-6 rule (None keeps the released grid).
"""
draft_size: str = "768p"
refine_start_sigma: float | None = None
def check_pipeline_config(self) -> None:
super().check_pipeline_config()
if self.draft_size not in DRAFT_SIZE_CHOICES:
raise ValueError(f"draft_size must be one of {DRAFT_SIZE_CHOICES}, got {self.draft_size!r}.")
if self.refine_start_sigma is not None and not 0.0 < self.refine_start_sigma < 1.0:
raise ValueError(f"refine_start_sigma must be in (0, 1), got {self.refine_start_sigma}.")
@@ -41,10 +41,6 @@ class RequestAdaptationError(ValueError):
"""The transport request cannot be represented by the loaded pipeline."""
OPEN_H3_REGENERATE_2K_FAMILY = "open_h3_regenerate_2k"
OPEN_H3_REGENERATE_2K_SIZE = (2560, 1440)
OPEN_H3_REGENERATE_2K_NUM_FRAMES = 124
_VIDEO_SUFFIXES = frozenset({".avi", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm"})
@@ -385,13 +381,6 @@ def _apply_reference_inputs(
if request.task is not None and model_family != "minimax_h3":
raise RequestAdaptationError("The task selector is only defined for MiniMax-H3 servers.")
if model_family == OPEN_H3_REGENERATE_2K_FAMILY:
if len(videos) != 1 or images or audios:
raise RequestAdaptationError("Open-H3-Regenerate-2K takes exactly one video_reference (the 768P draft) "
"and no image or audio references.")
kwargs["video_path"] = videos[0]
return
if model_family == "minimax_h3" and (videos or audios):
raise RequestAdaptationError("MiniMax-H3 video/audio references require a server configured with "
"override_pipeline_cls_name=MiniMaxH3Ref2VAModularPipeline.")
@@ -533,16 +522,6 @@ def build_generation_request(
if height is not None and (not isinstance(height, int) or height <= 0):
raise RequestAdaptationError(f"height must be a positive integer, got {height!r}")
if model_family == OPEN_H3_REGENERATE_2K_FAMILY:
if (width, height) not in ((None, None), OPEN_H3_REGENERATE_2K_SIZE):
raise RequestAdaptationError("Open-H3-Regenerate-2K always renders 2560x1440; omit size/width/height "
f"or pass 2560x1440, got {width}x{height}.")
if kwargs.get("num_frames", OPEN_H3_REGENERATE_2K_NUM_FRAMES) != OPEN_H3_REGENERATE_2K_NUM_FRAMES:
raise RequestAdaptationError("Open-H3-Regenerate-2K regenerates the draft's first 121 frames (24 fps); "
"omit seconds and num_frames.")
if kwargs["fps"] != 24:
raise RequestAdaptationError(f"Open-H3-Regenerate-2K requires fps=24, got {kwargs['fps']}.")
if model_family == "minimax_h3":
from fastvideo.pipelines.basic.minimax_h3.packing import (
MINIMAX_H3_CANVAS_MULTIPLE,
+14 -14
View File
@@ -254,7 +254,13 @@ class BaseLayerWithLoRA(nn.Module):
current_device = self.base_layer.weight.data.device
data = self.base_layer.weight.data.to(get_local_torch_device()).full_tensor()
unsharded_base_layer.weight = nn.Parameter(self._merged_weight(data).to(current_device))
# Apply LoRA with alpha scaling
lora_delta = (
self.slice_lora_b_weights(self.lora_B).to(data) @ self.slice_lora_a_weights(self.lora_A).to(data))
scale = ((self.lora_alpha / self.lora_rank) if (self.lora_alpha and self.lora_rank) else 1.0)
lora_delta *= scale * self.lora_strength
data += lora_delta
unsharded_base_layer.weight = nn.Parameter(data.to(current_device))
if isinstance(getattr(self.base_layer, "bias", None), DTensor):
unsharded_base_layer.bias = nn.Parameter(
self.base_layer.bias.to(get_local_torch_device(),
@@ -271,22 +277,16 @@ class BaseLayerWithLoRA(nn.Module):
current_device = self.base_layer.weight.data.device
data = self.base_layer.weight.data.to(get_local_torch_device())
self.base_layer.weight.data = self._merged_weight(data).to(current_device, non_blocking=True)
# Apply LoRA with alpha scaling
lora_delta = (
self.slice_lora_b_weights(self.lora_B.to(data)) @ self.slice_lora_a_weights(self.lora_A.to(data)))
scale = ((self.lora_alpha / self.lora_rank) if (self.lora_alpha and self.lora_rank) else 1.0)
lora_delta *= scale * self.lora_strength
data += lora_delta
self.base_layer.weight.data = data.to(current_device, non_blocking=True)
self.merged = True
def _merged_weight(self, weight: torch.Tensor) -> torch.Tensor:
"""``weight`` plus the scaled adapter delta, in ``weight``'s dtype.
An adapter stored wider than the base (an FP32 adapter on a BF16 DiT) is multiplied and added at its own
precision and rounded once, as a merge-then-cast reference does; with FSDP or without.
"""
accumulate_dtype = torch.promote_types(weight.dtype, self.lora_A.dtype)
lora_B = self.slice_lora_b_weights(self.lora_B.to(device=weight.device, dtype=accumulate_dtype))
lora_A = self.slice_lora_a_weights(self.lora_A.to(device=weight.device, dtype=accumulate_dtype))
scale = ((self.lora_alpha / self.lora_rank) if (self.lora_alpha and self.lora_rank) else 1.0)
return (weight.to(accumulate_dtype) + (lora_B @ lora_A) * (scale * self.lora_strength)).to(weight.dtype)
@torch.no_grad()
# @torch.compile(dynamic=True)
def unmerge_lora_weights(self, *, if_merged_only: bool = True) -> None:
@@ -1655,7 +1655,6 @@ class PipelineComponentLoader:
component_model_path,
)
module_name = module_name.rsplit("/", 1)[-1]
# Get the appropriate loader for this module type
loader = ComponentLoader.for_module_type(module_name, transformers_or_diffusers)
@@ -1,161 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""MiniMax-H3 3D-convolution latent upscaler (LBH-123-AI/Comfyui_Minimax_h3_latent_Upscaler, MIT).
Port of the upstream node's ``LatentResizer3D`` and its ``target dimensions`` size logic, without the ComfyUI shims,
attention blocks (the node forces them off) and temporal chunking (Open-H3-Regenerate-2K runs without it). Module names
match the upstream ``latent_upsampler/*.safetensors`` checkpoint, so it loads without remapping.
The network works on a latent that was normalized with the statistics below, not the VAE-normalized latent the DiT
sees: ``upscale_latent`` therefore re-normalizes its input and de-normalizes the result with the same statistics.
"""
from __future__ import annotations
import re
import torch
import torch.nn.functional as F
from safetensors.torch import load_file
from torch import nn
VAE_DOWNSAMPLE = 16
PIXEL_ALIGNMENT = 32
LATENTS_MEAN = (
0.858090341091156, -0.9606591463088989, 1.0661640167236328, -0.5090325474739075, -0.2727581858634949,
-1.3675414323806763, -0.2553254961967468, -0.26907554268836975, -0.5376840829849243, -0.0464097298681736,
0.6657370328903198, 0.19690127670764923, -0.5460608005523682, -0.4035342037677765, -0.23683024942874908,
0.25928452610969543, -0.30133944749832153, 0.211341992020607, -1.1206848621368408, 0.3581933379173279,
-0.04225143790245056, 0.2604829967021942, 0.22864092886447906, 0.7056031823158264)
LATENTS_STD = (
1.2223774194717407, 1.2767263650894165, 1.6831774711608887, 1.7549455165863037, 1.5636216402053833,
2.194143533706665, 0.9653137922286987, 1.0569885969161987, 0.841948926448822, 0.7729952931404114,
1.8955937623977661, 0.946841835975647, 0.7996809482574463, 0.44988900423049927, 0.7197399735450745,
0.6936293244361877, 2.961095094680786, 2.7694199085235596, 3.0496184825897217, 2.1088054180145264,
3.276226282119751, 3.1627357006073, 2.2816812992095947, 2.6127843856811523)
def _norm(channels: int) -> nn.GroupNorm:
return nn.GroupNorm(32, channels)
class ResBlockEmb3D(nn.Module):
def __init__(self, channels: int, emb_channels: int) -> None:
super().__init__()
self.in_layers = nn.Sequential(_norm(channels), nn.SiLU(), nn.Conv3d(channels, channels, 3, padding=1))
self.emb_layers = nn.Sequential(nn.SiLU(), nn.Linear(emb_channels, 2 * channels))
self.out_norm = _norm(channels)
self.out_layers = nn.Sequential(nn.SiLU(), nn.Dropout(p=0.0), nn.Conv3d(channels, channels, 3, padding=1))
def forward(self, x: torch.Tensor, emb: torch.Tensor) -> torch.Tensor:
h = self.in_layers(x)
scale, shift = self.emb_layers(emb).type(h.dtype)[..., None, None, None].chunk(2, dim=1)
h = self.out_norm(h) * (1 + scale) + shift
return x + self.out_layers(h)
class TemporalConv(nn.Module):
def __init__(self, channels: int, kernel_size: int = 5) -> None:
super().__init__()
self.norm = _norm(channels)
self.dwconv = nn.Conv3d(channels, channels, (kernel_size, 1, 1), padding=(kernel_size // 2, 0, 0),
groups=channels)
self.pwconv = nn.Conv3d(channels, channels, 1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return x + self.pwconv(self.dwconv(F.silu(self.norm(x))))
def _blocks(count: int, channels: int, emb_dim: int, temporal_every: int, kernel: int) -> nn.ModuleList:
blocks = nn.ModuleList()
for index in range(count):
blocks.append(ResBlockEmb3D(channels, emb_dim))
if temporal_every > 0 and index % temporal_every == 0:
blocks.append(TemporalConv(channels, kernel))
return blocks
class MiniMaxH3LatentUpscaler(nn.Module):
def __init__(self,
in_channels: int = 24,
in_blocks: int = 12,
out_blocks: int = 12,
channels: int = 512,
temporal_every: int = 2,
temporal_kernel: int = 5) -> None:
super().__init__()
emb_dim = 64
self.conv_in = nn.Conv3d(in_channels, channels, 3, padding=1)
self.embed = nn.Sequential(nn.Linear(1, emb_dim), nn.SiLU(), nn.Linear(emb_dim, emb_dim))
self.in_blocks = _blocks(in_blocks, channels, emb_dim, temporal_every, temporal_kernel)
self.out_blocks = _blocks(out_blocks, channels, emb_dim, temporal_every, temporal_kernel)
self.norm_out = _norm(channels)
self.conv_out = nn.Conv3d(channels, in_channels, 3, padding=1)
def _run(self, blocks: nn.ModuleList, x: torch.Tensor, emb: torch.Tensor) -> torch.Tensor:
for block in blocks:
x = block(x, emb.expand(x.shape[0], -1)) if isinstance(block, ResBlockEmb3D) else block(x)
return x
def forward(self, x: torch.Tensor, scale: float, target_size: tuple[int, int, int]) -> torch.Tensor:
"""``x``: [B, C, T, h, w] normalized latent; returns [B, C, T, *target_size[1:]]."""
emb = self.embed(torch.tensor([[scale - 1.0]], dtype=x.dtype, device=x.device))
x = self._run(self.in_blocks, self.conv_in(x), emb)
x = F.interpolate(x, size=target_size, mode="trilinear", align_corners=False)
x = self._run(self.out_blocks, x, emb)
return self.conv_out(F.silu(self.norm_out(x)))
@classmethod
def from_checkpoint(cls, path: str) -> MiniMaxH3LatentUpscaler:
state = load_file(path, device="cpu")
model = cls(in_channels=state["conv_in.weight"].shape[1],
channels=state["conv_in.weight"].shape[0],
in_blocks=_count_blocks(state, "in_blocks"),
out_blocks=_count_blocks(state, "out_blocks"),
temporal_kernel=_temporal_kernel(state))
model.load_state_dict(state, strict=True)
return model.eval().requires_grad_(False)
def _count_blocks(state: dict[str, torch.Tensor], prefix: str) -> int:
pattern = re.compile(rf"{prefix}\.(\d+)\.in_layers\.0\.weight")
return len({int(m.group(1)) for key in state if (m := pattern.match(key))})
def _temporal_kernel(state: dict[str, torch.Tensor]) -> int:
return next(tensor.shape[2] for key, tensor in state.items() if key.endswith("dwconv.weight"))
def resolve_upscale_geometry(input_size: tuple[int, int], target_pixels: tuple[int, int]) -> tuple[tuple[int, int], float]:
"""Latent (h, w) after aligning the target pixel size to 32, and the scale the network is conditioned on.
``input_size`` is the latent (h, w); ``target_pixels`` the requested (height, width).
"""
h_in, w_in = input_size
h_target, w_target = target_pixels
scale = (w_target / (w_in * VAE_DOWNSAMPLE) + h_target / (h_in * VAE_DOWNSAMPLE)) / 2.0
aligned = (round(round(h_target / PIXEL_ALIGNMENT) * PIXEL_ALIGNMENT / VAE_DOWNSAMPLE),
round(round(w_target / PIXEL_ALIGNMENT) * PIXEL_ALIGNMENT / VAE_DOWNSAMPLE))
return (max(1, aligned[0]), max(1, aligned[1])), scale
@torch.inference_mode()
def upscale_latent(model: MiniMaxH3LatentUpscaler, latent: torch.Tensor, target_pixels: tuple[int, int],
dtype: torch.dtype = torch.bfloat16) -> torch.Tensor:
"""Upscale a [C, T, h, w] latent to ``target_pixels`` (height, width); FP32 [C, T, H, W] out.
The input is returned unchanged when it already has the target latent size.
"""
channels, frames, h_in, w_in = latent.shape
(h_out, w_out), scale = resolve_upscale_geometry((h_in, w_in), target_pixels)
if (h_out, w_out) == (h_in, w_in):
return latent.float()
if scale < 1.0:
raise ValueError("The MiniMax-H3 latent upscaler only upscales (effective scale >= 1.0).")
mean = torch.tensor(LATENTS_MEAN, dtype=dtype, device=latent.device).view(1, -1, 1, 1, 1)
std = torch.tensor(LATENTS_STD, dtype=dtype, device=latent.device).view(1, -1, 1, 1, 1)
source = (latent[None].to(dtype) - mean) / std
result = model(source, scale, (frames, h_out, w_out))
return (result * std + mean).float()[0]
@@ -1,8 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
from fastvideo.pipelines.basic.open_h3_regenerate_2k.open_h3_regenerate_2k_pipeline import (
EntryClass,
OpenH3Regenerate2KPipeline,
)
__all__ = ["EntryClass", "OpenH3Regenerate2KPipeline"]
@@ -1,54 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Decode a draft video into the 124-frame pixel block the H3 VAE turns into 37 latent frames."""
from __future__ import annotations
import subprocess
import numpy as np
import torch
import fastvideo.envs as envs
DRAFT_SIZES: dict[str, tuple[int, int]] = {"768p": (1344, 768), "576p": (1024, 576)}
DRAFT_FRAMES = 121
# The causal VAE maps 124 frames to 37 latent frames; the last frame is held for the three extra ones.
VAE_PADDING_FRAMES = 3
# Seconds of held last frame ffmpeg appends so a short draft still yields DRAFT_FRAMES.
_TAIL_PAD_SECONDS = 6
_DECODE_TIMEOUT_S = 600
def ffmpeg_decode_command(path: str, width: int, height: int) -> list[str]:
"""fps=24, cover-scale (bilinear) and centre-crop, last frame held, exactly DRAFT_FRAMES frames, no audio."""
video_filter = (f"fps=24,scale={width}:{height}:force_original_aspect_ratio=increase:flags=bilinear,"
f"crop={width}:{height},tpad=stop_mode=clone:stop_duration={_TAIL_PAD_SECONDS}")
return [
envs.FASTVIDEO_FFMPEG_BIN.get(), "-v", "error", "-i", path, "-vf", video_filter, "-frames:v",
str(DRAFT_FRAMES), "-an", "-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1"
]
def load_draft_pixels(path: str, size: str = "768p") -> torch.Tensor:
"""uint8 pixels [1, 3, 124, H, W] on the CPU for the draft video at ``path``."""
if size not in DRAFT_SIZES:
raise ValueError(f"draft size must be one of {sorted(DRAFT_SIZES)}, got {size!r}.")
width, height = DRAFT_SIZES[size]
try:
raw = subprocess.run(ffmpeg_decode_command(path, width, height),
capture_output=True,
check=True,
timeout=_DECODE_TIMEOUT_S).stdout
except FileNotFoundError as error:
raise RuntimeError(f"ffmpeg ({envs.FASTVIDEO_FFMPEG_BIN.get()!r}) is required to read the draft video; "
"set FASTVIDEO_FFMPEG_BIN.") from error
except subprocess.CalledProcessError as error:
raise ValueError(
f"Could not decode the draft video {path!r}: {error.stderr.decode(errors='replace')}") from error
expected = DRAFT_FRAMES * height * width * 3
if len(raw) != expected:
raise ValueError(f"The draft video {path!r} decoded to {len(raw)} bytes, expected {expected} "
f"({DRAFT_FRAMES} frames at {width}x{height}).")
frames = torch.from_numpy(np.frombuffer(raw, np.uint8).reshape(DRAFT_FRAMES, height, width, 3).copy())
pixels = frames.permute(3, 0, 1, 2)[None]
return torch.cat([pixels, pixels[:, :, -1:].expand(-1, -1, VAE_PADDING_FRAMES, -1, -1)], dim=2).contiguous()
@@ -1,110 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Open-H3-Regenerate-2K: regenerate a 768P MiniMax-H3 draft video at 2560x1440.
``draft video -> H3 VAE encode -> latent upscaler -> 3 fixed-sigma Euler steps (H3 DiT + merged LoRA + Sharp curve
AdaLN branches) -> H3 VAE decode``. The release repo holds the unmodified MiniMax-H3 components under ``base_model/``
next to ``lora/`` and ``latent_upsampler/``; see ``docs/inference/open_h3_regenerate_2k.md``.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import torch
from fastvideo import envs
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
from fastvideo.models.upsamplers.minimax_h3_latent_upscaler import MiniMaxH3LatentUpscaler
from fastvideo.pipelines.basic.minimax_h3.minimax_h3_pipeline import MiniMaxH3Pipeline
from fastvideo.pipelines.basic.minimax_h3.stages import MiniMaxH3VideoDecodingStage
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sharp_curve import SharpCurveBranches
from fastvideo.pipelines.basic.open_h3_regenerate_2k.stages import (
Regenerate2KDenoisingStage,
Regenerate2KFinalizeStage,
Regenerate2KLatentPreparationStage,
)
from fastvideo.utils import maybe_download_model, verify_model_config_and_directory
logger = init_logger(__name__)
BASE_MODEL_DIR = "base_model"
LORA_FILE = "lora/open_h3_regenerate_2k_lora.safetensors"
UPSCALER_FILE = "latent_upsampler/minimax_h3_latent_upscaler_3d_conv_v1_bf16.safetensors"
_BASE_COMPONENTS = ("text_encoder", "tokenizer", "processor", "vae", "audio_vae", "transformer", "scheduler",
"audio_scheduler")
def refuse_adaln_cache() -> None:
"""Raise when an H3 AdaLN cache is enabled: it bypasses the projection modules that carry the Sharp branches."""
if envs.FASTVIDEO_H3_ADALN_CACHE.get() or envs.FASTVIDEO_H3_ADALN_TABLE.get():
raise ValueError("Open-H3-Regenerate-2K needs the AdaLN projections: unset FASTVIDEO_H3_ADALN_CACHE and "
"FASTVIDEO_H3_ADALN_TABLE, whose cached modulation skips the 50 Sharp curve branches.")
class OpenH3Regenerate2KPipeline(MiniMaxH3Pipeline):
"""MiniMax-H3 T2VA layout with a draft-latent start, fixed sigmas and a silent output."""
_extra_config_module_map = {name: f"{BASE_MODEL_DIR}/{name}" for name in _BASE_COMPONENTS}
# The merged LoRA reaches only these linears; the AdaLN projections carry the Sharp curve branches instead.
lora_target_modules = ["attn.to_q", "attn.to_k", "attn.to_v", "attn.to_out", "ff.fc_in", "ff.fc_out"]
@classmethod
def get_hf_download_allow_patterns(cls) -> list[str]:
components = [f"{BASE_MODEL_DIR}/{name}/**" for name in _BASE_COMPONENTS]
return [f"{BASE_MODEL_DIR}/model_index.json", *components, "lora/**", "latent_upsampler/*.safetensors"]
def _load_config(self, model_path: str) -> dict[str, Any]:
refuse_adaln_cache()
revision = getattr(self.fastvideo_args, "revision", None)
self.model_path = maybe_download_model(model_path,
revision=revision,
allow_patterns=self.get_hf_download_allow_patterns())
if self.fastvideo_args.lora_path is None:
self.fastvideo_args.lora_path = os.path.join(self.model_path, LORA_FILE)
return verify_model_config_and_directory(os.path.join(self.model_path, BASE_MODEL_DIR),
required_component_dirs=())
def initialize_pipeline(self, fastvideo_args: FastVideoArgs) -> None:
super().initialize_pipeline(fastvideo_args)
self._upscaler: MiniMaxH3LatentUpscaler | None = None
self._curve: SharpCurveBranches | None = None
def _load_upscaler(self) -> MiniMaxH3LatentUpscaler:
if self._upscaler is None:
model = MiniMaxH3LatentUpscaler.from_checkpoint(os.path.join(self.model_path, UPSCALER_FILE))
self._upscaler = model.to(device=self.device, dtype=torch.bfloat16)
return self._upscaler
def _load_curve(self) -> SharpCurveBranches:
if self._curve is None:
self._curve = SharpCurveBranches(Path(self.model_path))
return self._curve
def _add_denoise_stages(self, *, ref2va: bool) -> None:
config = self.fastvideo_args.pipeline_config
vae = self.get_module("vae")
audio_vae = self.get_module("audio_vae")
transformer = self.get_module("transformer")
if vae is None or audio_vae is None or transformer is None:
raise RuntimeError("Open-H3-Regenerate-2K needs the video VAE, audio VAE and transformer loaded.")
self.add_stage(
"latent_preparation_stage",
Regenerate2KLatentPreparationStage(vae,
audio_vae,
self.get_module("scheduler"),
load_upscaler=self._load_upscaler,
draft_size=config.draft_size,
start_sigma=config.refine_start_sigma))
self.add_stage("denoising_stage",
Regenerate2KDenoisingStage(transformer, self._load_curve(), config.refine_start_sigma))
self.add_stage("video_decoding_stage", MiniMaxH3VideoDecodingStage(vae=vae))
self.add_stage("audio_decoding_stage", Regenerate2KFinalizeStage())
self._denoise_stages_ready = True
EntryClass = OpenH3Regenerate2KPipeline
__all__ = ["EntryClass", "OpenH3Regenerate2KPipeline"]
@@ -1,35 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Inference preset for Open-H3-Regenerate-2K."""
from fastvideo.api.presets import InferencePreset, PresetStageSpec
_REFINE_STAGE = PresetStageSpec(
name="refine",
kind="denoising",
description="Latent upscale, then three fixed-sigma Euler refine steps",
allowed_overrides=frozenset({"num_inference_steps"}),
)
OPEN_H3_REGENERATE_2K = InferencePreset(
name="open_h3_regenerate_2k",
version=1,
model_family="open_h3_regenerate_2k",
description="Open-H3-Regenerate-2K: regenerate a 768P MiniMax-H3 draft video at 2560x1440 (silent output)",
workload_type=None,
stage_schemas=(_REFINE_STAGE, ),
defaults={
"height": 1440,
"width": 2560,
"num_frames": 124,
"fps": 24,
"guidance_scale": 1.0,
"batch_cfg": False,
"negative_prompt": "",
"num_inference_steps": 3,
"seed": 1766580932,
},
)
ALL_PRESETS = (OPEN_H3_REGENERATE_2K, )
__all__ = ["ALL_PRESETS", "OPEN_H3_REGENERATE_2K"]
@@ -1,42 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Fixed Open-H3-Regenerate-2K refine schedule: Euler, CFG 1, model time ``1 - sigma``."""
from __future__ import annotations
import torch
REFINE_SHIFT = 6.0
DEFAULT_REFINE_STEPS = 3
# Start noise of the two-step variant; the three-step grid starts at 0.909375.
TWO_STEP_START_SIGMA = 0.7
def shifted_grid(start: float, steps: int, shift: float = REFINE_SHIFT) -> list[float]:
"""Euler grid from ``start`` to 0: even spacing in unshifted sigma, then ``s * x / (1 + (s - 1) * x)``."""
if not 0.0 < start < 1.0:
raise ValueError(f"start sigma must be in (0, 1), got {start}.")
if steps < 1:
raise ValueError(f"steps must be positive, got {steps}.")
raw = start / (shift - (shift - 1) * start)
mapped = [shift * x / (1 + (shift - 1) * x) for x in (raw * (1 - i / steps) for i in range(1, steps))]
return [start, *mapped, 0.0]
REFINE_SCHEDULES: dict[int, tuple[float, ...]] = {
3: (0.909375, 6 / 7, 2 / 3, 0.0),
2: tuple(shifted_grid(TWO_STEP_START_SIGMA, 2)),
}
def refine_sigmas(steps: int = DEFAULT_REFINE_STEPS, start_sigma: float | None = None) -> list[float]:
"""Sigma grid of ``steps`` Euler steps, ending at 0."""
if start_sigma is not None:
return shifted_grid(start_sigma, steps)
if steps not in REFINE_SCHEDULES:
raise ValueError(f"Open-H3-Regenerate-2K supports {sorted(REFINE_SCHEDULES)} refine steps, got {steps}.")
return list(REFINE_SCHEDULES[steps])
def mix_initial_state(upscaled: torch.Tensor, noise: torch.Tensor, sigma: float) -> torch.Tensor:
"""Re-noise the upscaled draft latent to the first sigma: ``(1 - sigma) * upscaled + sigma * noise``."""
return (1.0 - sigma) * upscaled + sigma * noise
@@ -1,91 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""LMS Sharp curve AdaLN branches.
Fifty AdaLN projections get an extra low-rank term whose input is an 8-D curve interpolated from the model timestep
(``adaln_t_table``, 1025 x 8) instead of the layer input, so it cannot be folded into the LoRA. The term is added to the
projection output through a forward hook; ``set_timesteps`` must be called with the per-forward unique timesteps first.
"""
from __future__ import annotations
from pathlib import Path
import torch
import torch.nn.functional as F
from safetensors.torch import load_file
CURVE_BRANCHES_FILE = "lora/sharp_curve_branches.safetensors"
CURVE_TABLE_FILE = "lora/adaln_t_table.bin"
CURVE_TABLE_SHAPE = (1025, 8)
NUM_CURVE_BRANCHES = 50
_KEY_PREFIX = "transformer."
_A_SUFFIX = ".curve_A.weight"
_B_SUFFIX = ".curve_B.weight"
def interpolate_curve(table: torch.Tensor, timesteps: torch.Tensor) -> torch.Tensor:
"""Linear interpolation of ``table`` rows at model times in [0, 1]; ``timesteps`` [U] -> [U, 8]."""
t = timesteps.float()
if t.numel() and (t.min() < 0 or t.max() > 1):
raise ValueError("Sharp curve timesteps must lie in [0, 1].")
table = table.to(t.device)
position = t * (len(table) - 1)
low = position.floor().long().clamp(max=len(table) - 2)
return torch.lerp(table[low], table[low + 1], (position - low).unsqueeze(-1))
_ATTACHED_ATTR = "_open_h3_sharp_curve_branches"
class SharpCurveBranches:
def __init__(self, weights_dir: str | Path) -> None:
root = Path(weights_dir)
raw = torch.frombuffer(bytearray((root / CURVE_TABLE_FILE).read_bytes()), dtype=torch.float32)
if raw.numel() != CURVE_TABLE_SHAPE[0] * CURVE_TABLE_SHAPE[1]:
raise ValueError(f"{root / CURVE_TABLE_FILE} must hold a {CURVE_TABLE_SHAPE} float32 table.")
self.table = raw.reshape(CURVE_TABLE_SHAPE).clone()
tensors = load_file(str(root / CURVE_BRANCHES_FILE), device="cpu")
self.factors: dict[str, tuple[torch.Tensor, torch.Tensor]] = {
key[len(_KEY_PREFIX):-len(_A_SUFFIX)]: (value.float(), tensors[key[:-len(_A_SUFFIX)] + _B_SUFFIX].float())
for key, value in tensors.items() if key.endswith(_A_SUFFIX)
}
if len(self.factors) != NUM_CURVE_BRANCHES:
raise ValueError(f"Expected {NUM_CURVE_BRANCHES} Sharp curve branches, found {len(self.factors)}.")
self.coords: torch.Tensor | None = None
self._device_factors: dict[str, tuple[torch.Tensor, torch.Tensor]] = {}
def set_timesteps(self, unique_timesteps: torch.Tensor) -> None:
self.coords = interpolate_curve(self.table, unique_timesteps)
def attach(self, transformer: torch.nn.Module) -> None:
"""Register the branch hooks on ``transformer`` unless this instance already carries them.
The mark lives on the module, so a reloaded lazy transformer, a new instance, gets its own hooks.
"""
if getattr(transformer, _ATTACHED_ATTR, None) is self:
return
modules = dict(transformer.named_modules())
missing = sorted(set(self.factors) - set(modules))
if missing:
raise ValueError(f"Sharp curve branches match no transformer module: {missing[:5]}")
for name in self.factors:
modules[name].register_forward_hook(self._hook(name))
setattr(transformer, _ATTACHED_ATTR, self)
def _hook(self, name: str):
def add_branch(module, inputs, output):
projected = output[0] if isinstance(output, tuple) else output
coords = self.coords
if coords is None or coords.shape[0] != projected.shape[0]:
raise RuntimeError("Sharp curve timesteps were not set for this forward.")
factors = self._device_factors.get(name)
if factors is None or factors[0].device != projected.device:
factors = tuple(f.to(projected.device) for f in self.factors[name])
self._device_factors[name] = factors
delta = F.linear(F.linear(coords, factors[0]), factors[1]).to(projected.dtype)
projected = projected + delta
return (projected, *output[1:]) if isinstance(output, tuple) else projected
return add_branch
@@ -1,12 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
from fastvideo.pipelines.basic.open_h3_regenerate_2k.stages.denoising import Regenerate2KDenoisingStage
from fastvideo.pipelines.basic.open_h3_regenerate_2k.stages.finalize import Regenerate2KFinalizeStage
from fastvideo.pipelines.basic.open_h3_regenerate_2k.stages.latent_preparation import (
Regenerate2KLatentPreparationStage, )
__all__ = [
"Regenerate2KDenoisingStage",
"Regenerate2KFinalizeStage",
"Regenerate2KLatentPreparationStage",
]
@@ -1,120 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Fixed-sigma Euler refine loop of Open-H3-Regenerate-2K."""
from __future__ import annotations
import contextlib
from typing import Any
import torch
from torch.nn.attention import SDPBackend, sdpa_kernel
from fastvideo.distributed import get_local_torch_device
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.forward_context import set_forward_context
from fastvideo.logger import init_logger
from fastvideo.pipelines.basic.minimax_h3.packing import MiniMaxH3PackedLayout, build_row_timesteps
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_latent_preparation import MINIMAX_H3_LAYOUT_KEY
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sampling import refine_sigmas
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sharp_curve import SharpCurveBranches
from fastvideo.pipelines.lazy_module import is_lazy_module
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
from fastvideo.pipelines.stages.base import PipelineStage
from fastvideo.pipelines.stages.validators import StageValidators as V
from fastvideo.pipelines.stages.validators import VerificationResult
logger = init_logger(__name__)
class Regenerate2KDenoisingStage(PipelineStage):
"""Euler steps over the refine sigmas with the video clock at ``1 - sigma`` and the silent audio held clean."""
performance_component_metric = "dit_time_s"
def __init__(self, transformer: Any, curve: SharpCurveBranches, start_sigma: float | None) -> None:
super().__init__()
self.transformer = transformer
self.curve = curve
self.start_sigma = start_sigma
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
result = VerificationResult()
result.add_check("layout", batch.extra.get(MINIMAX_H3_LAYOUT_KEY), V.not_none)
result.add_check("prompt_embeds", batch.prompt_embeds, V.list_of_tensors_dims(3))
result.add_check("latents", batch.latents, V.with_dims(2))
result.add_check("audio_latents", batch.audio_latents, V.with_dims(2))
return result
def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
result = VerificationResult()
result.add_check("latents", batch.latents, V.with_dims(2))
return result
@torch.no_grad()
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
transformer = self.transformer
self.curve.attach(transformer.materialize() if is_lazy_module(transformer) else transformer)
layout = batch.extra.get(MINIMAX_H3_LAYOUT_KEY)
if not isinstance(layout, MiniMaxH3PackedLayout):
raise ValueError("The MiniMax-H3 packed layout is missing before denoising.")
device = get_local_torch_device()
sigmas = refine_sigmas(batch.num_inference_steps, self.start_sigma)
state = batch.latents.to(device)
layerwise = bool(getattr(fastvideo_args, "dit_layerwise_offload", False))
full_cpu_offload = (bool(getattr(fastvideo_args, "dit_cpu_offload", False)) and not layerwise
and not bool(getattr(fastvideo_args, "use_fsdp_inference", False)))
try:
if full_cpu_offload:
transformer.to(device)
with self._attention_kernel(device):
state = self._euler_loop(batch, layout, state, sigmas, device, transformer)
finally:
if layerwise:
manager = getattr(transformer, "_layerwise_offload_manager", None)
if manager is not None and getattr(manager, "enabled", False):
manager.release_all()
if full_cpu_offload:
transformer.to("cpu")
batch.latents = state
return batch
@staticmethod
def _attention_kernel(device: torch.device) -> contextlib.AbstractContextManager[None]:
"""cuDNN attention: at 133k tokens PyTorch's default SDPA dispatch picks its flash kernel, 3x slower on B200."""
return sdpa_kernel(SDPBackend.CUDNN_ATTENTION) if device.type == "cuda" else contextlib.nullcontext()
def _euler_loop(self, batch: ForwardBatch, layout: MiniMaxH3PackedLayout, state: torch.Tensor, sigmas: list[float],
device: torch.device, transformer: Any) -> torch.Tensor:
position_ids = layout.position_ids.to(device)
token_tags = layout.token_tags.to(device)
video_indices = layout.video_indices.to(device)
audio_indices = layout.audio_indices.to(device)
text_indices = layout.text_indices.to(device)
prompt_embeds = batch.prompt_embeds[0].to(device)
audio = batch.audio_latents.to(device=device, dtype=torch.bfloat16)[None]
for index, (sigma, following) in enumerate(zip(sigmas[:-1], sigmas[1:], strict=True)):
unique, inverse = build_row_timesteps(layout,
video_timestep=1.0 - sigma,
audio_timestep=1.0,
condition_video_timestep=1.0,
condition_audio_timestep=1.0)
unique, inverse = unique.to(device), inverse.to(device)
self.curve.set_timesteps(unique)
torch.compiler.cudagraph_mark_step_begin()
with set_forward_context(current_timestep=index, attn_metadata=None, forward_batch=batch):
velocity, _ = transformer(hidden_states=state.to(torch.bfloat16)[None],
audio_hidden_states=audio,
encoder_hidden_states=prompt_embeds,
timestep=unique,
timestep_indices=inverse,
token_tags=token_tags,
position_ids=position_ids,
video_indices=video_indices,
audio_indices=audio_indices,
text_indices=text_indices)
state = state + (sigma - following) * velocity[0].float()
batch.step_index = index
return state
__all__ = ["Regenerate2KDenoisingStage"]
@@ -1,39 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Trim the decoded video to the draft length and drop the per-request H3 state."""
from __future__ import annotations
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.pipelines.basic.open_h3_regenerate_2k.draft_video import DRAFT_FRAMES
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
from fastvideo.pipelines.stages.base import PipelineStage
from fastvideo.pipelines.stages.validators import StageValidators as V
from fastvideo.pipelines.stages.validators import VerificationResult
class Regenerate2KFinalizeStage(PipelineStage):
"""The released pipeline writes a silent video: the audio stream only conditions the DiT and is never decoded."""
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
result = VerificationResult()
result.add_check("output", batch.output, V.with_dims(5))
return result
def verify_output(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
result = VerificationResult()
result.add_check("output", batch.output, V.with_dims(5))
return result
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
# The VAE decodes 124 frames (37 latent frames); the draft has 121.
batch.output = batch.output[:, :, :DRAFT_FRAMES]
batch.prompt_embeds = []
batch.latents = None
batch.audio_latents = None
for key in tuple(batch.extra):
if key.startswith("minimax_h3_"):
batch.extra.pop(key)
return batch
__all__ = ["Regenerate2KFinalizeStage"]
@@ -1,129 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Draft video -> upscaled, re-noised H3 latent plus the fixed silent-audio rows."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import torch
from fastvideo.distributed import get_local_torch_device
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.logger import init_logger
from fastvideo.models import pinned_offload
from fastvideo.models.upsamplers.minimax_h3_latent_upscaler import MiniMaxH3LatentUpscaler, upscale_latent
from fastvideo.pipelines.basic.minimax_h3.packing import (
MINIMAX_H3_AUDIO_CHANNELS,
MINIMAX_H3_FPS,
MINIMAX_H3_KEYFRAME_ENCODE_SEED,
h3_dit_patch_size,
patchify_video_latents,
)
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_latent_preparation import (
MINIMAX_H3_LAYOUT_KEY,
MiniMaxH3LatentPreparationStage,
)
from fastvideo.pipelines.basic.open_h3_regenerate_2k.draft_video import load_draft_pixels
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sampling import mix_initial_state, refine_sigmas
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
from fastvideo.pipelines.stages.validators import VerificationResult
logger = init_logger(__name__)
DRAFT_FRAME_COUNT = 124
class Regenerate2KLatentPreparationStage(MiniMaxH3LatentPreparationStage):
"""Encode the draft with the H3 VAE, upscale its latent to the target grid and re-noise it to the first sigma."""
def __init__(self, vae: Any, audio_vae: Any, scheduler: Any, *, load_upscaler: Callable[[],
MiniMaxH3LatentUpscaler],
draft_size: str, start_sigma: float | None) -> None:
super().__init__(vae, audio_vae, scheduler, ref2va=False)
self.load_upscaler = load_upscaler
self.draft_size = draft_size
self.start_sigma = start_sigma
self._silence: tuple[tuple[str, int], torch.Tensor] | None = None
def verify_input(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> VerificationResult:
result = super().verify_input(batch, fastvideo_args)
result.add_check("video_path", batch.video_path, lambda value: isinstance(value, str) and bool(value))
return result
def _encode_draft(self, video_path: str, device: torch.device, fastvideo_args: FastVideoArgs) -> torch.Tensor:
"""Normalized native latent [C, T, h, w] of the draft, with the VAE posterior sampled under a fixed seed."""
pixels = load_draft_pixels(video_path, self.draft_size)
pinned_offload.load(self.vae, device, pin=fastvideo_args.pin_cpu_memory)
try:
posterior = self.vae.encode_pixels(pixels).latent_dist
sample = posterior.sample(generator=torch.Generator("cpu").manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED))
return self.vae.normalize_latents(sample.float())[0].to(device)
finally:
if fastvideo_args.vae_cpu_offload:
pinned_offload.unload(self.vae)
def _silent_audio_rows(self, num_frames: int, device: torch.device, fastvideo_args: FastVideoArgs) -> torch.Tensor:
"""The encoded-silence audio stream, cached: it is the same for every request of this length."""
key = (str(device), num_frames)
if self._silence is not None and self._silence[0] == key:
return self._silence[1]
pinned_offload.load(self.audio_vae, device, pin=fastvideo_args.pin_cpu_memory)
try:
samples = round(num_frames / MINIMAX_H3_FPS * int(self.audio_vae.sampling_rate))
silence = torch.zeros(MINIMAX_H3_AUDIO_CHANNELS, 1, samples, device=device)
posterior = self.audio_vae.encode(silence).latent_dist
latents = self.audio_vae.normalize_latents(posterior.mode().float()).transpose(1, 2)
rows = latents.reshape(-1, self.audio_vae.latent_channels).to(torch.bfloat16)
finally:
if fastvideo_args.vae_cpu_offload:
pinned_offload.unload(self.audio_vae)
self._silence = (key, rows)
return rows
@staticmethod
def _noise_rows(shape: tuple[int, int], device: torch.device, seed: int) -> torch.Tensor:
generator = torch.Generator(device=device).manual_seed(seed)
return torch.randn((1, *shape), device=device, dtype=torch.float32, generator=generator)[0]
@torch.no_grad()
def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch:
if batch.num_frames != DRAFT_FRAME_COUNT:
raise ValueError(f"Open-H3-Regenerate-2K refines {DRAFT_FRAME_COUNT} frames, got {batch.num_frames}.")
device = get_local_torch_device()
patch_size = h3_dit_patch_size(fastvideo_args)
_, channels, num_latent_frames, latent_height, latent_width = batch.raw_latent_shape
draft = self._encode_draft(batch.video_path, device, fastvideo_args)
upscaler = self.load_upscaler()
upscaled = upscale_latent(upscaler, draft, (batch.height, batch.width))
if tuple(upscaled.shape) != (channels, num_latent_frames, latent_height, latent_width):
raise ValueError(f"The upscaled latent has shape {tuple(upscaled.shape)}, expected "
f"{(channels, num_latent_frames, latent_height, latent_width)}.")
upscaled_rows = patchify_video_latents(upscaled[None], patch_size)
if batch.latents is None:
noise_rows = self._noise_rows(tuple(upscaled_rows.shape), device, 0 if batch.seed is None else batch.seed)
else:
expected = (1, channels, num_latent_frames, latent_height, latent_width)
if tuple(batch.latents.shape) != expected:
raise ValueError(f"Injected noise must have shape {expected}, got {tuple(batch.latents.shape)}.")
noise_rows = patchify_video_latents(batch.latents.to(device=device, dtype=torch.float32), patch_size)
layout = self._build_layout(batch, fastvideo_args)
audio_rows = self._silent_audio_rows(batch.num_frames, device, fastvideo_args)
if audio_rows.shape[0] != layout.audio_indices.numel():
raise ValueError(f"The silent audio has {audio_rows.shape[0]} rows, the packed layout expects "
f"{layout.audio_indices.numel()} ({layout.num_audio_latents} latents).")
batch.latents = mix_initial_state(upscaled_rows, noise_rows,
refine_sigmas(batch.num_inference_steps, self.start_sigma)[0])
batch.audio_latents = audio_rows
batch.extra[MINIMAX_H3_LAYOUT_KEY] = layout
for key in tuple(batch.extra):
if key.startswith("minimax_h3_") and key != MINIMAX_H3_LAYOUT_KEY:
batch.extra.pop(key)
return batch
__all__ = ["Regenerate2KLatentPreparationStage"]
+7 -36
View File
@@ -42,7 +42,6 @@ from fastvideo.configs.pipelines.flux_2 import (
from fastvideo.configs.pipelines.matrixgame2 import MatrixGame2I2V480PConfig
from fastvideo.configs.pipelines.matrixgame3 import MatrixGame3I2V720PConfig
from fastvideo.configs.pipelines.minimax_h3 import MiniMaxH3PipelineConfig
from fastvideo.configs.pipelines.open_h3_regenerate_2k import OpenH3Regenerate2KPipelineConfig
from fastvideo.configs.pipelines.mmaudio import (
MMAudioLarge44kV2PipelineConfig,
MMAudioLarge44kV2AConfig,
@@ -217,13 +216,6 @@ def _resolve_class_name(config: dict[str, Any]) -> str | None:
return class_name
OPEN_H3_REGENERATE_2K_HF_PATH = "Efficient-Large-Model/Open-H3-Regenerate-2K"
def _is_open_h3_regenerate_2k_release(model_path: str) -> bool:
return os.path.isfile(os.path.join(model_path, "lora", "open_h3_regenerate_2k_lora.safetensors"))
def _get_config_info(
model_path: str,
*,
@@ -255,9 +247,6 @@ def _get_config_info(
return _CONFIG_REGISTRY.get(model_id)
# 3. Use detectors (path or model_index pipeline name).
if os.path.exists(model_path) and _is_open_h3_regenerate_2k_release(model_path):
# The release keeps its Diffusers manifest under base_model/, so a renamed local copy has no root manifest.
return _CONFIG_REGISTRY.get(_MODEL_HF_PATH_TO_NAME[OPEN_H3_REGENERATE_2K_HF_PATH])
if os.path.exists(model_path):
config = verify_model_config_and_directory(model_path, required_component_dirs=[])
elif allow_remote:
@@ -1163,22 +1152,6 @@ def _register_configs() -> None:
_register_wan_configs(WAN_MODEL_DEFINITIONS)
# Open-H3-Regenerate-2K (video -> video). Registered before MiniMax H3 so the first matching detector wins. There is
# no V2V WorkloadType, so, like the Kandinsky6 SR entries, it declares no workload.
register_configs(
sampling_param_cls=None,
pipeline_config_cls=OpenH3Regenerate2KPipelineConfig,
workload_types=(),
hf_model_paths=[OPEN_H3_REGENERATE_2K_HF_PATH],
model_detectors=[
lambda path: any(token in path.lower()
for token in ("open-h3-regenerate", "open_h3_regenerate", "openh3regenerate2kpipeline")),
],
model_family="open_h3_regenerate_2k",
default_preset="open_h3_regenerate_2k",
pipeline_cls_name="OpenH3Regenerate2KPipeline",
)
# MiniMax H3
register_configs(
sampling_param_cls=None,
@@ -1290,18 +1263,19 @@ def get_model_info(
# is triggered just to log the replaced name).
pipeline_name: str | None = override_pipeline_cls_name
logger.info("Using override pipeline class name %s", pipeline_name)
elif config_info.pipeline_cls_name is not None:
# The resolved (path/detector-based) config pins the pipeline class, e.g. an I2V checkpoint whose
# `_class_name` would otherwise resolve to the T2V pipeline. The manifest is not read: a release such as
# Open-H3-Regenerate-2K keeps it in a subfolder.
pipeline_name = config_info.pipeline_cls_name
logger.info("Pinned pipeline class name %s", pipeline_name)
else:
if os.path.exists(model_path):
config = verify_model_config_and_directory(model_path, required_component_dirs=[])
else:
config = maybe_download_model_index(model_path, revision=revision)
pipeline_name = _resolve_class_name(config)
if config_info.pipeline_cls_name is not None:
# The resolved (path/detector-based) config pins the pipeline class,
# e.g. an I2V checkpoint whose `_class_name` would otherwise resolve
# to the T2V pipeline.
logger.info("Pinning pipeline class name from %s to %s", pipeline_name, config_info.pipeline_cls_name)
pipeline_name = config_info.pipeline_cls_name
if pipeline_name is None:
raise ValueError("Model config does not contain a _class_name attribute. "
@@ -1376,8 +1350,6 @@ def _register_presets() -> None:
ALL_PRESETS as MATRIXGAME3_PRESETS, )
from fastvideo.pipelines.basic.minimax_h3.presets import (
ALL_PRESETS as MINIMAX_H3_PRESETS, )
from fastvideo.pipelines.basic.open_h3_regenerate_2k.presets import (
ALL_PRESETS as OPEN_H3_REGENERATE_2K_PRESETS, )
from fastvideo.pipelines.basic.mmaudio.presets import (
ALL_PRESETS as MMAUDIO_PRESETS, )
from fastvideo.pipelines.basic.sd35.presets import (
@@ -1411,7 +1383,6 @@ def _register_presets() -> None:
MATRIXGAME2_PRESETS,
MATRIXGAME3_PRESETS,
MINIMAX_H3_PRESETS,
OPEN_H3_REGENERATE_2K_PRESETS,
MMAUDIO_PRESETS,
SD35_PRESETS,
STABLE_AUDIO_PRESETS,
@@ -1,145 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Open-H3-Regenerate-2K through the OpenAI-compatible video routes, with a fake generator (no GPU)."""
import asyncio
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
from fastapi.testclient import TestClient
from PIL import Image
from fastvideo.entrypoints.cli.inference_config import build_serve_config
from fastvideo.entrypoints.openai.api_server import create_app
from fastvideo.entrypoints.openai.media_policy import MediaPolicy
from fastvideo.entrypoints.openai.stores import VIDEO_STORE
ROOT = Path(__file__).resolve().parents[3]
MODEL = "Efficient-Large-Model/Open-H3-Regenerate-2K"
ARTIFACT = b"fake-2k-video-for-transport-tests"
PROMPT = "a quiet street at dusk"
class FileGenerator:
def __init__(self):
self.requests = []
def generate(self, request):
self.requests.append(request)
output = Path(request.output.output_path)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_bytes(ARTIFACT)
return SimpleNamespace(video_path=str(output), generation_time=0.01)
def shutdown(self):
pass
@pytest.fixture
def served(monkeypatch, tmp_path):
generator = FileGenerator()
monkeypatch.setattr("fastvideo.entrypoints.openai.api_server.VideoGenerator.from_fastvideo_args",
lambda args: generator)
drafts = tmp_path / "drafts"
drafts.mkdir()
(drafts / "draft.mp4").write_bytes(b"draft")
Image.new("RGB", (8, 8)).save(drafts / "still.png")
args = SimpleNamespace(model_path=MODEL, lora_path=None, lora_nickname="default", lora_strength=1.0,
override_pipeline_cls_name=None)
config = build_serve_config(SimpleNamespace(config=str(ROOT / "examples/serving/openai_open_h3_regenerate_2k.yaml")),
overrides=[])
app = create_app(args,
str(tmp_path / "server"),
config.default_request,
config.server.served_model_name,
media_policy=MediaPolicy(roots=(str(drafts), )))
with TestClient(app) as client:
yield client, generator, drafts
asyncio.run(VIDEO_STORE.clear())
def _body(drafts, **extra):
return {"prompt": PROMPT, "video_reference": {"video_url": str(drafts / "draft.mp4")}, **extra}
def test_serving_yaml_pins_the_2k_defaults():
config = build_serve_config(SimpleNamespace(config=str(ROOT / "examples/serving/openai_open_h3_regenerate_2k.yaml")),
overrides=[])
sampling = config.default_request.sampling
assert (sampling.height, sampling.width, sampling.num_frames, sampling.num_inference_steps) == (1440, 2560, 124, 3)
assert config.generator.model_path == MODEL
def test_sync_route_returns_the_mp4_and_maps_the_draft_to_video_path(served):
client, generator, drafts = served
response = client.post("/v1/videos/sync", json=_body(drafts, seed=7))
assert response.status_code == 200, response.text
assert response.content == ARTIFACT
assert response.headers["content-type"] == "video/mp4"
assert response.headers["x-model"] == "open-h3-regenerate-2k"
request = generator.requests[0]
assert request.prompt == PROMPT
assert request.inputs.video_path == str((drafts / "draft.mp4").resolve())
assert (request.sampling.height, request.sampling.width, request.sampling.num_frames) == (1440, 2560, 124)
assert request.sampling.num_inference_steps == 3 and request.sampling.seed == 7
def test_async_route_completes_and_serves_the_content(served):
client, generator, drafts = served
created = client.post("/v1/videos", json=_body(drafts))
assert created.status_code == 200, created.text
video_id = created.json()["id"]
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
status = client.get(f"/v1/videos/{video_id}").json()["status"]
if status in ("completed", "failed"):
break
time.sleep(0.02)
assert status == "completed"
assert client.get(f"/v1/videos/{video_id}/content").content == ARTIFACT
assert generator.requests[0].inputs.video_path.endswith("draft.mp4")
def test_two_refine_steps_can_be_requested(served):
client, generator, drafts = served
assert client.post("/v1/videos/sync", json=_body(drafts, num_inference_steps=2)).status_code == 200
assert generator.requests[0].sampling.num_inference_steps == 2
@pytest.mark.parametrize("reference, message", [
({"video_url": "data:video/mp4;base64,AAAA"}, "Data video references are not supported"),
({"video_url": "/etc/passwd"}, "Local media paths are not accepted"),
({"file_id": "file_1"}, "file_id video references are not supported"),
])
def test_unusable_video_references_are_rejected(served, reference, message):
client, generator, _ = served
response = client.post("/v1/videos/sync", json={"prompt": PROMPT, "video_reference": reference})
assert response.status_code == 400 and message in response.text
assert not generator.requests
@pytest.mark.parametrize("extra, message", [
({"video_reference": None}, "exactly one video_reference"),
({"image_reference": {"image_url": "STILL"}}, "exactly one video_reference"),
({"size": "1920x1080"}, "always renders 2560x1440"),
({"seconds": 5}, "first 121 frames"),
({"fps": 30}, "requires fps=24"),
])
def test_requests_the_pipeline_cannot_honor_are_rejected(served, extra, message):
client, generator, drafts = served
body = {**_body(drafts), **extra}
if "image_reference" in body:
body["image_reference"] = {"image_url": str(drafts / "still.png")}
body = {key: value for key, value in body.items() if value is not None}
response = client.post("/v1/videos/sync", json=body)
assert response.status_code == 400 and message in response.text
assert not generator.requests
def test_two_drafts_are_rejected(served):
client, generator, drafts = served
reference = {"video_url": str(drafts / "draft.mp4")}
response = client.post("/v1/videos/sync", json={"prompt": PROMPT, "video_reference": [reference, reference]})
assert response.status_code == 400 and "exactly one video_reference" in response.text
@@ -1,61 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""CPU contract: merging an adapter stored wider than the base rounds once, after a wide add."""
from __future__ import annotations
import pytest
import torch
from fastvideo.layers.linear import ReplicatedLinear
from fastvideo.layers.lora import linear as lora_linear
from fastvideo.layers.lora.linear import get_lora_layer
IN, OUT, RANK = 32, 24, 16
@pytest.fixture(autouse=True)
def _cpu_merge(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(torch._dynamo.config, "disable", True)
monkeypatch.setattr(lora_linear, "get_local_torch_device", lambda: torch.device("cpu"))
def _merged(adapter_dtype: torch.dtype) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
torch.manual_seed(0)
layer = ReplicatedLinear(IN, OUT, bias=False, params_dtype=torch.bfloat16)
with torch.no_grad():
layer.weight.copy_(torch.randn(OUT, IN))
base = layer.weight.detach().clone()
wrapped = get_lora_layer(layer)
a, b = torch.randn(RANK, IN, dtype=adapter_dtype), torch.randn(OUT, RANK, dtype=adapter_dtype) * 0.1
wrapped.set_lora_weights(a, b, lora_alpha=RANK, strength=1.0)
wrapped.merge_lora_weights()
return wrapped.base_layer.weight.detach(), base, a, b
def test_fp32_adapter_is_added_in_fp32_and_rounded_once() -> None:
merged, base, a, b = _merged(torch.float32)
expected = (base.float() + b @ a).to(torch.bfloat16)
assert merged.dtype == torch.bfloat16
assert torch.equal(merged, expected)
def test_bf16_adapter_keeps_the_bf16_merge() -> None:
merged, base, a, b = _merged(torch.bfloat16)
assert torch.equal(merged, base + b @ a)
def test_merge_helper_used_with_and_without_fsdp_rounds_once() -> None:
torch.manual_seed(1)
layer = ReplicatedLinear(IN, OUT, bias=False, params_dtype=torch.bfloat16)
with torch.no_grad():
layer.weight.copy_(torch.randn(OUT, IN))
wrapped = get_lora_layer(layer)
a, b = torch.randn(RANK, IN), torch.randn(OUT, RANK) * 0.1
wrapped.lora_A, wrapped.lora_B = torch.nn.Parameter(a), torch.nn.Parameter(b)
wrapped.lora_rank, wrapped.lora_alpha, wrapped.lora_strength = RANK, RANK, 0.5
weight = layer.weight.detach().clone()
merged = wrapped._merged_weight(weight)
assert merged.dtype == torch.bfloat16
assert torch.equal(merged, (weight.float() + 0.5 * (b @ a)).to(torch.bfloat16))
@@ -1,390 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""CPU unit tests for the Open-H3-Regenerate-2K building blocks."""
import numpy as np
import pytest
import torch
from safetensors.torch import save_file
from fastvideo.models.upsamplers.minimax_h3_latent_upscaler import (
MiniMaxH3LatentUpscaler,
resolve_upscale_geometry,
upscale_latent,
)
from fastvideo.pipelines.basic.open_h3_regenerate_2k import draft_video
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sampling import (
mix_initial_state,
refine_sigmas,
shifted_grid,
)
from fastvideo.pipelines.basic.open_h3_regenerate_2k.sharp_curve import (
NUM_CURVE_BRANCHES,
SharpCurveBranches,
interpolate_curve,
)
def test_default_sigmas_match_the_released_grid():
assert refine_sigmas(3) == pytest.approx([0.909375, 0.857143, 0.666667, 0.0], abs=1e-6)
def test_two_step_sigmas_start_at_0_7():
assert refine_sigmas(2) == pytest.approx([0.7, 0.494118, 0.0], abs=1e-6)
def test_shifted_grid_from_the_released_start_is_the_original_four_step_grid():
assert shifted_grid(0.909375, 4) == pytest.approx([0.909375, 0.841446, 0.732075, 0.526697, 0.0], abs=1e-6)
def test_custom_start_sigma_is_decreasing_and_ends_at_zero():
grid = refine_sigmas(3, start_sigma=0.8)
assert grid[0] == 0.8 and grid[-1] == 0.0
assert all(a > b for a, b in zip(grid, grid[1:]))
@pytest.mark.parametrize("steps", [0, 4])
def test_unsupported_step_count_is_rejected(steps):
with pytest.raises(ValueError):
refine_sigmas(steps)
def test_initial_state_mix_weights_noise_by_sigma():
upscaled, noise = torch.ones(2, 3), torch.zeros(2, 3)
assert torch.allclose(mix_initial_state(upscaled, noise, 0.909375), torch.full((2, 3), 0.090625))
assert torch.equal(mix_initial_state(upscaled, noise, 0.0), upscaled)
def test_upscale_geometry_targets_2k_from_768p_and_576p():
assert resolve_upscale_geometry((48, 84), (1440, 2560))[0] == (90, 160)
shape, scale = resolve_upscale_geometry((36, 64), (1440, 2560))
assert shape == (90, 160) and scale == pytest.approx(2.5)
def test_tiny_upscaler_forward_shape_and_identity_passthrough():
model = MiniMaxH3LatentUpscaler(channels=32, in_blocks=2, out_blocks=2).eval()
latent = torch.randn(24, 5, 4, 6)
out = upscale_latent(model, latent, (96, 160), dtype=torch.float32)
assert out.shape == (24, 5, 6, 10) and out.dtype == torch.float32
assert torch.equal(upscale_latent(model, latent, (64, 96)), latent)
def test_upscaler_checkpoint_roundtrip(tmp_path):
model = MiniMaxH3LatentUpscaler(channels=32, in_blocks=3, out_blocks=2)
path = str(tmp_path / "upscaler.safetensors")
save_file(model.state_dict(), path)
loaded = MiniMaxH3LatentUpscaler.from_checkpoint(path)
assert len(loaded.in_blocks) == len(model.in_blocks) and len(loaded.out_blocks) == len(model.out_blocks)
for key, value in model.state_dict().items():
assert torch.equal(loaded.state_dict()[key], value)
def test_curve_interpolation_hits_table_rows_and_midpoints():
table = torch.arange(1025 * 8, dtype=torch.float32).reshape(1025, 8)
coords = interpolate_curve(table, torch.tensor([0.0, 1.0, 0.5, 1 / 2048]))
assert torch.equal(coords[0], table[0]) and torch.equal(coords[1], table[1024])
assert torch.equal(coords[2], table[512])
assert torch.allclose(coords[3], (table[0] + table[1]) / 2)
with pytest.raises(ValueError):
interpolate_curve(table, torch.tensor([1.5]))
class _Projection(torch.nn.Module):
def __init__(self, out_features):
super().__init__()
self.linear = torch.nn.Linear(4, out_features)
def forward(self, x):
return self.linear(x), None
def _write_curve_release(root, out_features, count):
(root / "lora").mkdir()
(root / "lora" / "adaln_t_table.bin").write_bytes(np.random.rand(1025 * 8).astype(np.float32).tobytes())
tensors = {}
for index in range(count):
tensors[f"transformer.blocks.{index}.proj.curve_A.weight"] = torch.randn(3, 8)
tensors[f"transformer.blocks.{index}.proj.curve_B.weight"] = torch.randn(out_features, 3)
save_file(tensors, str(root / "lora" / "sharp_curve_branches.safetensors"))
def test_sharp_branches_require_all_fifty(tmp_path):
_write_curve_release(tmp_path, 6, 3)
with pytest.raises(ValueError, match="50"):
SharpCurveBranches(tmp_path)
def test_sharp_branch_adds_curve_term_to_projection_output(tmp_path):
_write_curve_release(tmp_path, 6, NUM_CURVE_BRANCHES)
curve = SharpCurveBranches(tmp_path)
model = torch.nn.Module()
model.blocks = torch.nn.ModuleList()
for _ in range(NUM_CURVE_BRANCHES):
block = torch.nn.Module()
block.proj = _Projection(6)
model.blocks.append(block)
curve.attach(model)
curve.attach(model)
timesteps = torch.tensor([0.1, 0.9])
x = torch.randn(2, 4)
base, _ = model.blocks[0].proj.linear(x), None
curve.set_timesteps(timesteps)
out, extra = model.blocks[0].proj(x)
a, b = curve.factors["blocks.0.proj"]
expected = base + torch.nn.functional.linear(torch.nn.functional.linear(curve.coords, a), b)
assert extra is None and torch.allclose(out, expected, atol=1e-6)
def test_sharp_branch_without_timesteps_fails_loudly(tmp_path):
_write_curve_release(tmp_path, 6, NUM_CURVE_BRANCHES)
curve = SharpCurveBranches(tmp_path)
model = torch.nn.Module()
model.blocks = torch.nn.ModuleList()
for _ in range(NUM_CURVE_BRANCHES):
block = torch.nn.Module()
block.proj = _Projection(6)
model.blocks.append(block)
curve.attach(model)
with pytest.raises(RuntimeError, match="timesteps"):
model.blocks[0].proj(torch.randn(2, 4))
def test_ffmpeg_command_cover_scales_and_caps_at_121_frames():
command = draft_video.ffmpeg_decode_command("clip.mp4", 1344, 768)
video_filter = command[command.index("-vf") + 1]
assert "fps=24" in video_filter and "scale=1344:768:force_original_aspect_ratio=increase" in video_filter
assert "crop=1344:768" in video_filter
assert command[command.index("-frames:v") + 1] == "121"
def test_draft_pixels_pad_the_last_frame_to_124(monkeypatch):
width, height = draft_video.DRAFT_SIZES["576p"]
frames = np.arange(121, dtype=np.uint8).reshape(121, 1, 1, 1) * np.ones((121, height, width, 3), np.uint8)
class _Done:
stdout = frames.tobytes()
monkeypatch.setattr(draft_video.subprocess, "run", lambda *a, **k: _Done())
pixels = draft_video.load_draft_pixels("clip.mp4", "576p")
assert pixels.shape == (1, 3, 124, height, width) and pixels.dtype == torch.uint8
assert torch.equal(pixels[:, :, 120], pixels[:, :, 123])
def test_draft_pixels_reject_truncated_decode(monkeypatch):
class _Short:
stdout = b"\x00" * 10
monkeypatch.setattr(draft_video.subprocess, "run", lambda *a, **k: _Short())
with pytest.raises(ValueError, match="decoded to 10 bytes"):
draft_video.load_draft_pixels("clip.mp4")
class _ConstantVelocityTransformer:
"""Predicts a constant video velocity and records what the stage feeds it."""
def __init__(self, velocity):
self.velocity = velocity
self.calls = []
def __call__(self, **kwargs):
self.calls.append(kwargs)
rows = kwargs["hidden_states"].shape[1]
audio_rows = kwargs["audio_hidden_states"].shape[1]
return torch.full((1, rows, 8), self.velocity), torch.zeros(1, audio_rows, 4)
class _RecordingCurve:
def __init__(self):
self.timesteps = []
self.attached = 0
self.targets = []
def attach(self, transformer):
self.attached += 1
self.targets.append(transformer)
def set_timesteps(self, unique):
self.timesteps.append(unique.clone())
def _curve_model(out_features):
model = torch.nn.Module()
model.blocks = torch.nn.ModuleList()
for _ in range(NUM_CURVE_BRANCHES):
block = torch.nn.Module()
block.proj = _Projection(out_features)
model.blocks.append(block)
return model
def test_sharp_branches_reach_a_reloaded_transformer_instance(tmp_path):
_write_curve_release(tmp_path, 6, NUM_CURVE_BRANCHES)
curve = SharpCurveBranches(tmp_path)
curve.set_timesteps(torch.tensor([0.1, 0.9]))
x = torch.randn(2, 4)
for model in (_curve_model(6), _curve_model(6)):
curve.attach(model)
out, _ = model.blocks[0].proj(x)
assert not torch.allclose(out, model.blocks[0].proj.linear(x))
def test_stage_attaches_the_branches_to_each_materialized_lazy_transformer(monkeypatch):
from fastvideo.pipelines.lazy_module import LazyModule
lazy = LazyModule("transformer", lambda: _ConstantVelocityTransformer(0.5))
first = _denoise(monkeypatch, steps=2, transformer=lazy)[2]
loaded_first = first.targets[-1]
assert lazy.release()
second = _denoise(monkeypatch, steps=2, transformer=lazy, curve=first)[2]
assert not isinstance(loaded_first, LazyModule) and not isinstance(second.targets[-1], LazyModule)
assert second.targets[-1] is not loaded_first
def _denoise(monkeypatch, steps, velocity=0.5, transformer=None, curve=None, fastvideo_args=None):
from fastvideo.pipelines.basic.minimax_h3.packing import build_packed_sequence
from fastvideo.pipelines.basic.minimax_h3.stages.minimax_h3_latent_preparation import MINIMAX_H3_LAYOUT_KEY
from fastvideo.pipelines.basic.open_h3_regenerate_2k.stages import denoising
from fastvideo.pipelines.pipeline_batch_info import ForwardBatch
monkeypatch.setattr(denoising, "get_local_torch_device", lambda: torch.device("cpu"))
layout = build_packed_sequence(torch.ones(3, dtype=torch.long), 2, 2, 2, 2, (1, 2, 2))
batch = ForwardBatch(data_type="video")
batch.num_inference_steps = steps
batch.prompt_embeds = [torch.zeros(1, 3, 4)]
batch.latents = torch.zeros(layout.video_indices.numel(), 8)
batch.audio_latents = torch.ones(layout.audio_indices.numel(), 4)
batch.extra[MINIMAX_H3_LAYOUT_KEY] = layout
transformer = transformer if transformer is not None else _ConstantVelocityTransformer(velocity)
curve = curve if curve is not None else _RecordingCurve()
stage = denoising.Regenerate2KDenoisingStage(transformer, curve, None)
return stage.forward(batch, fastvideo_args), transformer, curve
def test_euler_steps_integrate_the_constant_velocity_over_sigma_zero_to_start(monkeypatch):
batch, transformer, curve = _denoise(monkeypatch, steps=3, velocity=0.5)
assert len(transformer.calls) == 3 and curve.attached == 1
assert torch.allclose(batch.latents, torch.full_like(batch.latents, 0.909375 * 0.5))
def test_video_clock_is_one_minus_sigma_and_audio_is_held_clean(monkeypatch):
_, transformer, curve = _denoise(monkeypatch, steps=3)
for call, unique, sigma in zip(transformer.calls, curve.timesteps, [0.909375, 6 / 7, 2 / 3]):
assert torch.allclose(unique.sort().values, torch.tensor([1.0 - sigma, 1.0]).sort().values.float(), atol=1e-6)
assert call["hidden_states"].dtype == torch.bfloat16 and call["audio_hidden_states"].dtype == torch.bfloat16
assert torch.equal(call["audio_hidden_states"].float(), torch.ones_like(call["audio_hidden_states"]).float())
def test_two_step_request_runs_two_forwards(monkeypatch):
batch, transformer, _ = _denoise(monkeypatch, steps=2, velocity=1.0)
assert len(transformer.calls) == 2
assert torch.allclose(batch.latents, torch.full_like(batch.latents, 0.7), atol=1e-6)
def test_registry_resolves_the_release_to_the_regenerate_pipeline(tmp_path):
from fastvideo.configs.pipelines.open_h3_regenerate_2k import OpenH3Regenerate2KPipelineConfig
from fastvideo.registry import get_model_info, get_pipeline_config_cls_from_name, get_preset_selection
hub_id = "Efficient-Large-Model/Open-H3-Regenerate-2K"
(tmp_path / "lora").mkdir()
(tmp_path / "lora" / "open_h3_regenerate_2k_lora.safetensors").write_bytes(b"")
for path in (hub_id, str(tmp_path)):
assert get_pipeline_config_cls_from_name(path) is OpenH3Regenerate2KPipelineConfig
assert get_model_info(path).pipeline_cls.__name__ == "OpenH3Regenerate2KPipeline"
preset, family = get_preset_selection(hub_id)
assert (preset, family) == ("open_h3_regenerate_2k", "open_h3_regenerate_2k")
def test_serve_config_forwards_the_nvfp4_decoder_option(monkeypatch):
from fastvideo import fastvideo_args as fva
from fastvideo.api import ComponentConfig, EngineConfig, GeneratorConfig, PipelineSelection
from fastvideo.api.compat import generator_config_to_fastvideo_args
captured = {}
class _Captured:
def __init__(self, **kwargs):
self.kwargs = kwargs
def _capture(**kwargs):
captured.update(kwargs)
return _Captured(**kwargs)
monkeypatch.setattr(fva.FastVideoArgs, "from_kwargs", _capture)
generator_config_to_fastvideo_args(
GeneratorConfig(model_path="Efficient-Large-Model/Open-H3-Regenerate-2K",
engine=EngineConfig(num_gpus=1),
pipeline=PipelineSelection(components=ComponentConfig(),
experimental={
"draft_size": "768p",
"h3_vae_nvfp4_decoder": "/tmp/decoder-nvfp4.safetensors",
})))
assert captured["h3_vae_nvfp4_decoder"] == "/tmp/decoder-nvfp4.safetensors"
assert captured["draft_size"] == "768p"
def test_generate_dotted_override_sets_the_nvfp4_decoder_option(tmp_path):
from fastvideo.api.compat import generator_config_to_fastvideo_args
from fastvideo.entrypoints.cli.generate import GenerateSubcommand, build_generate_run_config
from fastvideo.utils import FlexibleArgumentParser
config_path = tmp_path / "run.yaml"
config_path.write_text("generator:\n model_path: Efficient-Large-Model/Open-H3-Regenerate-2K\n"
"request:\n prompt: hello\n", encoding="utf-8")
parser = FlexibleArgumentParser()
GenerateSubcommand().subparser_init(parser.add_subparsers(dest="subparser"))
args, unknown = parser.parse_known_args([
"generate", "--config", str(config_path), "--generator.pipeline.experimental.h3_vae_nvfp4_decoder",
"/tmp/decoder-nvfp4.safetensors"
])
config = build_generate_run_config(args, unknown)
assert config.generator.pipeline.experimental["h3_vae_nvfp4_decoder"] == "/tmp/decoder-nvfp4.safetensors"
assert generator_config_to_fastvideo_args(config.generator).h3_vae_nvfp4_decoder == "/tmp/decoder-nvfp4.safetensors"
class _OffloadedTransformer(_ConstantVelocityTransformer):
def __init__(self, velocity, fail=False):
super().__init__(velocity)
self.fail = fail
self.moves = []
def to(self, device):
self.moves.append(str(device))
return self
def __call__(self, **kwargs):
if self.fail:
raise RuntimeError("forward failed")
return super().__call__(**kwargs)
@pytest.mark.parametrize("fail", [False, True])
def test_full_cpu_offload_moves_the_transformer_back_to_cpu_after_the_refine(monkeypatch, fail):
from types import SimpleNamespace
transformer = _OffloadedTransformer(0.5, fail=fail)
args = SimpleNamespace(dit_cpu_offload=True, dit_layerwise_offload=False, use_fsdp_inference=False)
if fail:
with pytest.raises(RuntimeError, match="forward failed"):
_denoise(monkeypatch, steps=2, transformer=transformer, fastvideo_args=args)
else:
_denoise(monkeypatch, steps=2, transformer=transformer, fastvideo_args=args)
assert transformer.moves == ["cpu", "cpu"] # to the stage device (CPU in this test), then back to the host
@pytest.mark.parametrize("variable, value", [("FASTVIDEO_H3_ADALN_CACHE", True),
("FASTVIDEO_H3_ADALN_TABLE", "/tmp/adaln_tables.pt")])
def test_pipeline_refuses_an_adaln_cache_that_would_skip_the_sharp_branches(variable, value):
from fastvideo import envs
from fastvideo.pipelines.basic.open_h3_regenerate_2k.open_h3_regenerate_2k_pipeline import refuse_adaln_cache
refuse_adaln_cache()
with getattr(envs, variable).override(value), pytest.raises(ValueError, match="Sharp curve branches"):
refuse_adaln_cache()
@@ -487,33 +487,19 @@ def _write_deploy_checkpoint(path, student_layers: int, base_layers: int, heads:
return decoder
def _load_h3_vae(vae_dir, cpu: bool = True, decoder: str | None = None, pipeline_config=None):
def _load_h3_vae(vae_dir, cpu: bool = True, decoder: str | None = None):
from fastvideo.configs.models.vaes.minimax_h3_video import MiniMaxH3VideoVAEConfig
from fastvideo.configs.pipelines import PipelineConfig
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.models.loader.component_loader import VAELoader
args = FastVideoArgs(
model_path=str(vae_dir),
pipeline_config=pipeline_config or PipelineConfig(vae_config=MiniMaxH3VideoVAEConfig(), vae_precision="fp32"),
h3_vae_nvfp4_decoder=decoder)
args = FastVideoArgs(model_path=str(vae_dir),
pipeline_config=PipelineConfig(vae_config=MiniMaxH3VideoVAEConfig(), vae_precision="fp32"),
h3_vae_nvfp4_decoder=decoder)
args.vae_cpu_offload = cpu # load on CPU
return VAELoader().load(str(vae_dir), args)
def test_open_h3_regenerate_2k_vae_load_applies_the_exported_decoder(tmp_path):
from fastvideo.configs.pipelines.open_h3_regenerate_2k import OpenH3Regenerate2KPipelineConfig
_write_vae_dir(tmp_path / "vae", decoder_layers=5)
_write_deploy_checkpoint(tmp_path / "d3.pt", student_layers=3, base_layers=5)
config = OpenH3Regenerate2KPipelineConfig()
assert len(_load_h3_vae(tmp_path / "vae", pipeline_config=config).decoder.transformer_blocks) == 5
vae = _load_h3_vae(tmp_path / "vae", decoder=str(tmp_path / "d3.pt"), pipeline_config=config)
assert len(vae.decoder.transformer_blocks) == 3
assert vae.decode_autocast_dtype == torch.bfloat16
assert isinstance(vae.decoder.transformer_blocks[1].ff.net[2], NVFP4DecoderLinear)
def test_loader_applies_exported_nvfp4_decoder(tmp_path):
import fastvideo.envs as envs
@@ -1,62 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
"""Opt-in parity of the ported latent upscaler against the author's ComfyUI node.
Needs the release weights (OPEN_H3_REGEN_WEIGHTS) and a checkout of NVlabs/Sana on the ``sol-engine`` branch
(OPEN_H3_REGEN_REF, the models/Open-H3-Regenerate-2K directory). GPU only: the node runs the network in bf16.
"""
from __future__ import annotations
import importlib.util
import os
import sys
import types
from pathlib import Path
import pytest
import torch
from fastvideo.models.upsamplers.minimax_h3_latent_upscaler import MiniMaxH3LatentUpscaler, upscale_latent
WEIGHTS = os.environ.get("OPEN_H3_REGEN_WEIGHTS")
REFERENCE = os.environ.get("OPEN_H3_REGEN_REF")
CHECKPOINT = "latent_upsampler/minimax_h3_latent_upscaler_3d_conv_v1_bf16.safetensors"
pytestmark = pytest.mark.skipif(not (WEIGHTS and REFERENCE and torch.cuda.is_available()),
reason="needs OPEN_H3_REGEN_WEIGHTS, OPEN_H3_REGEN_REF and a GPU")
def _load_author_node(checkpoint: str):
folder = types.ModuleType("folder_paths")
folder.folder_names_and_paths = {"latent_upscale_models": ([], set())}
folder.get_full_path_or_raise = lambda category, name: checkpoint
sys.modules["folder_paths"] = folder
sys.modules["comfy.model_management"] = None
sys.modules["comfy_api.latest"] = None
spec = importlib.util.spec_from_file_location("author_upscaler",
Path(REFERENCE) / "open_h3" / "upscaler_node.py")
node = importlib.util.module_from_spec(spec)
spec.loader.exec_module(node)
node.io.NodeOutput = lambda value: (value, )
return node
@pytest.mark.parametrize("shape", [(24, 37, 48, 84), (24, 37, 36, 64)])
def test_upscaler_matches_the_author_node(shape):
checkpoint = str(Path(WEIGHTS) / CHECKPOINT)
node = _load_author_node(checkpoint)
torch.manual_seed(0)
latent = torch.randn(shape)
mode = {"mode": node.UpscaleMode.TARGET_DIMENSIONS, "width": 2560, "height": 1440}
with torch.inference_mode():
(expected, ) = node.MinimaxH3LatentUpscaler3D.execute({"samples": latent[None].float()},
"upscaler",
mode,
align=32,
enable_temporal_chunking=False,
force_unload=False,
device="cuda",
precision="bf16")
model = MiniMaxH3LatentUpscaler.from_checkpoint(checkpoint).to("cuda", torch.bfloat16)
actual = upscale_latent(model, latent.cuda(), (1440, 2560)).cpu()
assert torch.equal(actual, expected["samples"].float()[0])