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