[feat] Cookbook serving, hardware catalog, and landing page (#1999)

Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
This commit is contained in:
Aryan Kumar
2026-10-20 09:10:29 -07:00
committed by GitHub
co-authored by Aryan Kumar
parent 137d9110ca
commit 089a5a55a0
105 changed files with 8787 additions and 1153 deletions
+351 -94
View File
@@ -1,121 +1,378 @@
import { useEffect, useId, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import {
backendLabel,
gpuScopeNote,
memoryText,
resolveHardware,
searchHardware,
type CustomHardware,
type HardwareCatalogState,
type HardwareRecord,
type HardwareSelection,
type MemoryType,
type MemoryUnit,
} from "@/lib/hardware";
type Purpose = "target" | "filter";
interface Draft {
name: string;
backend: string;
memory_type: MemoryType;
memory: string;
memory_unit: MemoryUnit;
budget: string;
ram: string;
}
function draftFrom(custom: CustomHardware | undefined, backend: string): Draft {
return {
name: custom?.name ?? "",
backend: custom?.backend ?? (backend === "mlx" ? "mlx" : "cuda"),
memory_type: custom?.memory_type ?? (backend === "mlx" ? "unified" : "dedicated"),
memory: custom?.memory_gb ? String(custom.memory_gb) : "",
memory_unit: custom?.memory_unit ?? "GB",
budget: custom?.usable_budget_gb ? String(custom.usable_budget_gb) : "",
ram: custom?.host_ram_gb ? String(custom.host_ram_gb) : "",
};
}
function amount(value: string): number | undefined {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 4096 ? parsed : undefined;
}
export default function HardwarePicker({
backend,
options,
catalog,
status,
error,
value,
onChange,
gpuCount = 1,
purpose = "target",
reload,
}: {
backend: string;
options: string[][];
value: string;
onChange: (value: string) => void;
purpose?: Purpose;
catalog: HardwareRecord[];
status: HardwareCatalogState["status"];
error?: string;
value: HardwareSelection;
onChange: (selection: HardwareSelection) => void;
gpuCount?: number;
purpose?: "target" | "filter";
reload?: () => void;
}) {
const [search, setSearch] = useState("");
const [adding, setAdding] = useState(false);
const [custom, setCustom] = useState("");
useEffect(() => {
const requested = new URL(window.location.href).searchParams.get("hardware");
setCustom(requested?.startsWith("custom:") ? requested.slice(7) : "");
}, []);
const id = useId();
const known = options.filter((option) => option.join(" ").toLowerCase().includes(search.toLowerCase()));
const selected = value.startsWith("custom:") ? value.slice(7) : options.find(([key]) => key === value)?.[1];
const savedUnknown = !!value && !selected;
function add() {
const name = search.trim();
if (!name) return;
setCustom(name);
onChange(`custom:${name}`);
setSearch("");
const panelId = `${id}-lookup`;
const triggerRef = useRef<HTMLButtonElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState<Draft>(() => draftFrom(value.custom, backend));
const record = resolveHardware(value, catalog);
const unknown = !!value.id && value.id !== "custom" && !record;
const candidates =
purpose === "filter" ? catalog : catalog.filter((item) => item.backend === (backend === "mlx" ? "mlx" : "cuda"));
const matches = searchHardware(candidates, query).slice(0, 20);
const draftMemory = amount(draft.memory);
const draftBudget = amount(draft.budget);
const budgetTooLarge = draftMemory !== undefined && draftBudget !== undefined && draftBudget > draftMemory;
const validDraft = !!draft.name.trim() && !!draftMemory && !budgetTooLarge;
const scope = gpuScopeNote(record, gpuCount);
useEffect(() => {
if (value.custom) setDraft(draftFrom(value.custom, backend));
}, [backend, value.custom]);
useEffect(() => {
if (status === "unavailable") setAdding(true);
}, [status]);
useEffect(() => {
if (open && status === "ready" && !adding) searchRef.current?.focus();
}, [adding, open, status]);
function close() {
setOpen(false);
triggerRef.current?.focus();
}
function choose(recordId: string) {
onChange({ id: recordId });
setOpen(false);
setQuery("");
}
function save(event: React.FormEvent) {
event.preventDefault();
const memory = amount(draft.memory);
if (!validDraft || !memory) return;
onChange({
id: "custom",
custom: {
name: draft.name.trim(),
backend: draft.backend,
memory_type: draft.memory_type,
memory_gb: memory,
memory_unit: draft.memory_unit,
usable_budget_gb: amount(draft.budget),
host_ram_gb: draft.memory_type === "unified" ? undefined : amount(draft.ram),
},
});
setOpen(false);
setAdding(false);
}
function clear() {
setCustom("");
setSearch("");
onChange("");
}
return (
<div className="hardware-picker">
<fieldset className="hardware-matrix">
<legend className="sr-only">Hardware {purpose}</legend>
{known.map(([key, title, detail]) => (
<label key={key} data-selected={value === key}>
<input
type="radio"
name={`hardware-${id}`}
checked={value === key}
onChange={() => onChange(key)}
value={key}
/>
<span>
<strong>{title}</strong>
<small>{detail}</small>
<div
className="hardware-picker"
onKeyDown={(event) => {
if (event.key === "Escape" && open) {
event.stopPropagation();
close();
}
}}
>
{record ? (
<div className="hardware-selected">
<div>
<span className="hardware-selected-name">{record.name}</span>
<span className="hardware-selected-meta">
{memoryText(record)} · {backendLabel(record.backend)}
{record.form_factor ? ` · ${record.form_factor}` : ""}
</span>
</label>
))}
{custom && (
<label data-selected={value === `custom:${custom}`}>
<input
type="radio"
name={`hardware-${id}`}
checked={value === `custom:${custom}`}
onChange={() => onChange(`custom:${custom}`)}
/>
<span>
<strong>{custom}</strong>
<small>Custom device</small>
</span>
</label>
)}
</fieldset>
<div className="hardware-picker-actions">
<button type="button" className="hardware-add" aria-expanded={adding} onClick={() => setAdding(!adding)}>
+ Find or add {backend === "mlx" ? "a Mac" : "a GPU"}
</button>
{(selected || savedUnknown) && (
<button type="button" className="hardware-clear" onClick={clear}>
{record.usable_budget_gb ? (
<span className="hardware-selected-meta">
{value.id === "custom" ? "Your budget" : "Usable budget"}: {record.usable_budget_gb}{" "}
{record.memory_unit}
</span>
) : null}
{record.host_ram_gb && record.memory_type !== "unified" ? (
<span className="hardware-selected-meta">
Host RAM: {record.host_ram_gb} {record.memory_unit}
</span>
) : null}
</div>
<button type="button" className="hardware-clear" onClick={() => onChange({ id: "" })}>
Clear hardware {purpose}
</button>
)}
</div>
{!selected && (
</div>
) : value.id === "custom" ? (
<p className="hardware-current" role="status">
{savedUnknown ? "Saved hardware target is not in this list." : `No hardware ${purpose} selected.`}
Add this device's memory to finish selecting it.
</p>
) : unknown ? (
<p className="hardware-current" role="status">
Saved hardware target ({value.id}) is not in this catalog.
</p>
) : (
<p className="hardware-current" role="status">
No hardware {purpose} selected.
</p>
)}
{adding && (
<form
className="hardware-search"
onSubmit={(event) => {
event.preventDefault();
add();
{record ? (
<p className="hardware-address-note">
{record.memory_type === "unified"
? "Shared with the CPU; not dedicated VRAM."
: record.memory_type === "dedicated"
? "Per device, not a combined pool."
: "Host memory, not GPU VRAM."}
</p>
) : null}
{scope ? <p className="hardware-scope">{scope}</p> : null}
<div className="hardware-picker-actions">
{!record && value.id ? (
<button type="button" className="hardware-clear" onClick={() => onChange({ id: "" })}>
Clear hardware {purpose}
</button>
) : null}
<button
ref={triggerRef}
type="button"
className="hardware-add"
aria-expanded={open}
aria-controls={panelId}
onClick={() => {
setOpen(!open);
setAdding(false);
}}
>
<label htmlFor={id}>Device name</label>
<Input
id={id}
autoFocus
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={backend === "mlx" ? "e.g. M4 Max" : "e.g. RTX 4090"}
maxLength={80}
/>
<p>
{purpose === "filter"
? "Saved in this URL and used to filter by runtime. Fit checks appear only when an estimate covers the device."
: "Saved in this URL as your setup target. Device choice does not change generated configuration."}
</p>
<button type="submit" disabled={!search.trim()}>
Add device
{record
? "Change hardware"
: purpose === "filter"
? "Choose hardware"
: backend === "mlx"
? "+ Find or add a Mac"
: "+ Find or add a GPU"}
</button>
</div>
{record || open ? (
<p className="hardware-picker-caption">
{purpose === "filter"
? "Filters by runtime. Memory fit is not estimated."
: "Single-GPU devices set GPU count to 1. Nothing else in the configuration changes."}
</p>
) : null}
{open ? (
<div className="hardware-lookup" id={panelId} onKeyDown={(event) => event.key === "Escape" && close()}>
{status === "loading" ? <p className="hardware-lookup-status">Loading hardware catalog…</p> : null}
{status === "unavailable" ? (
<div className="hardware-lookup-status">
<p>Hardware catalog unavailable. Add your device manually.</p>
{error ? <span>{error}</span> : null}
{reload ? (
<button type="button" className="hardware-retry" onClick={reload}>
Retry
</button>
) : null}
</div>
) : null}
{status === "ready" ? (
<>
<Input
ref={searchRef}
aria-label="Search hardware"
type="search"
placeholder="Search by name, alias, or memory"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div className="hardware-result-list" role="radiogroup" aria-label="Matching devices">
{matches.map((item) => (
<label key={item.id} className="hardware-result" data-selected={value.id === item.id}>
<input
type="radio"
name={`${id}-hardware`}
checked={value.id === item.id}
onChange={() => choose(item.id)}
/>
<span>
<strong>{item.name}</strong>
<small>
{memoryText(item)} · {backendLabel(item.backend)}
</small>
</span>
</label>
))}
</div>
{!matches.length ? <p className="hardware-lookup-status">No matching devices.</p> : null}
</>
) : null}
<button
type="button"
className="hardware-manual-toggle"
aria-expanded={adding}
onClick={() => setAdding(!adding)}
>
{adding ? "Hide manual entry" : "Can't find it? Add a device"}
</button>
</form>
)}
{adding ? (
<form className="hardware-form" onSubmit={save}>
<p className="hardware-form-title">Add a device</p>
<div className="hardware-form-grid">
<label className="hardware-form-field hardware-form-wide">
<span>Device name</span>
<Input
value={draft.name}
maxLength={80}
placeholder={backend === "mlx" ? "e.g. M4 Max" : "e.g. RTX 4090"}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
/>
</label>
<label className="hardware-form-field">
<span>Backend</span>
<select
value={draft.backend}
onChange={(event) => setDraft({ ...draft, backend: event.target.value })}
>
<option value="cuda">CUDA</option>
<option value="mlx">Apple MLX</option>
</select>
</label>
<label className="hardware-form-field">
<span>Memory type</span>
<select
value={draft.memory_type}
onChange={(event) => setDraft({ ...draft, memory_type: event.target.value as MemoryType })}
>
<option value="dedicated">Dedicated GPU memory (VRAM)</option>
<option value="unified">Unified memory (CPU + GPU)</option>
<option value="system">System memory (host RAM)</option>
</select>
</label>
<label className="hardware-form-field">
<span>Nominal memory</span>
<Input
type="number"
min="1"
step="any"
inputMode="decimal"
value={draft.memory}
onChange={(event) => setDraft({ ...draft, memory: event.target.value })}
/>
</label>
<label className="hardware-form-field">
<span>Memory unit</span>
<select
value={draft.memory_unit}
onChange={(event) => setDraft({ ...draft, memory_unit: event.target.value as MemoryUnit })}
>
<option value="GB">GB</option>
<option value="GiB">GiB</option>
</select>
</label>
<label className="hardware-form-field">
<span>Usable budget (optional)</span>
<Input
type="number"
min="1"
step="any"
inputMode="decimal"
placeholder="Your own limit"
value={draft.budget}
onChange={(event) => setDraft({ ...draft, budget: event.target.value })}
/>
</label>
{draft.memory_type !== "unified" ? (
<label className="hardware-form-field">
<span>Host RAM (optional)</span>
<Input
type="number"
min="1"
step="any"
inputMode="decimal"
value={draft.ram}
onChange={(event) => setDraft({ ...draft, ram: event.target.value })}
/>
</label>
) : null}
</div>
{budgetTooLarge ? (
<p className="hardware-form-error">The usable budget cannot exceed the nominal memory.</p>
) : null}
<p className="hardware-form-note">
Nominal memory is device capacity, not what FastVideo can use. A budget is your own limit and is not
verified. Unified memory is shared with the CPU; it is not dedicated VRAM.
</p>
<div className="hardware-form-actions">
<button type="submit" disabled={!validDraft}>
Use this device
</button>
<button
type="button"
onClick={() => {
setAdding(false);
setDraft(draftFrom(value.custom, backend));
}}
>
Cancel
</button>
</div>
</form>
) : null}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,163 @@
"use client";
import * as React from "react";
import { type HTMLMotionProps, type Variants, motion } from "motion/react";
import { cn } from "cn";
const curtainVariants: Variants = {
visible: {
clipPath: "polygon(0 0,100% 0,100% 100%,0 100%)",
transition: { duration: 0.4, ease: [0.25, 1.5, 0.5, 1] },
},
hidden: {
clipPath: "polygon(50% 0,50% 0,50% 100%,50% 100%)",
transition: { duration: 0.3, ease: [0.25, 1.5, 0.5, 1] },
},
};
interface CardCurtainRevealContextValue {
revealed: boolean;
/** False on touch-only devices: details stay visible and the curtain never draws. */
canHover: boolean;
}
const CardCurtainRevealContext = React.createContext<CardCurtainRevealContextValue | undefined>(undefined);
function useCardCurtainRevealContext() {
const context = React.useContext(CardCurtainRevealContext);
if (!context) throw new Error("useCardCurtainRevealContext must be used within a CardCurtainReveal component");
return context;
}
function useCanHover() {
const [canHover, setCanHover] = React.useState(true);
React.useEffect(() => {
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setCanHover(query.matches);
sync();
query.addEventListener("change", sync);
return () => query.removeEventListener("change", sync);
}, []);
return canHover;
}
interface CardCurtainRevealProps extends React.HTMLAttributes<HTMLDivElement> {
/** Reveals the card without a pointer, for example while its link has keyboard focus. */
active?: boolean;
}
const CardCurtainReveal = React.forwardRef<HTMLDivElement, CardCurtainRevealProps>(
({ children, className, active = false, ...props }, ref) => {
const [isMouseIn, setIsMouseIn] = React.useState(false);
const canHover = useCanHover();
const handleMouseEnter = React.useCallback(() => setIsMouseIn(true), []);
const handleMouseLeave = React.useCallback(() => setIsMouseIn(false), []);
const value = React.useMemo(
() => ({ revealed: !canHover || isMouseIn || active, canHover }),
[active, canHover, isMouseIn],
);
return (
<CardCurtainRevealContext.Provider value={value}>
<div
ref={ref}
className={cn("relative flex flex-col gap-2 overflow-hidden", className)}
data-revealed={value.revealed || undefined}
data-curtain-open={(canHover && value.revealed) || undefined}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
{children}
</div>
</CardCurtainRevealContext.Provider>
);
},
);
CardCurtainReveal.displayName = "CardCurtainReveal";
const CardCurtainRevealFooter = React.forwardRef<HTMLDivElement, HTMLMotionProps<"div">>(
({ className, ...props }, ref) => {
const { revealed } = useCardCurtainRevealContext();
return (
<motion.div
ref={ref}
className={className}
variants={curtainVariants}
initial={false}
animate={revealed ? "visible" : "hidden"}
{...props}
/>
);
},
);
CardCurtainRevealFooter.displayName = "CardCurtainRevealFooter";
const CardCurtainRevealBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex-1 p-6", className)} {...props} />,
);
CardCurtainRevealBody.displayName = "CardCurtainRevealBody";
interface CardCurtainRevealTitleProps extends HTMLMotionProps<"h2"> {
/** Distance in pixels the title rests below its revealed position. */
hiddenOffset?: number;
as?: "h2" | "h3" | "h4";
}
const CardCurtainRevealTitle = React.forwardRef<HTMLHeadingElement, CardCurtainRevealTitleProps>(
({ className, hiddenOffset = 170, as = "h2", ...props }, ref) => {
const { revealed } = useCardCurtainRevealContext();
const Heading = motion[as];
return (
<Heading
ref={ref}
className={className}
initial={false}
animate={{ y: revealed ? 0 : hiddenOffset }}
transition={{ duration: 0.3, ease: "easeOut" }}
{...props}
/>
);
},
);
CardCurtainRevealTitle.displayName = "CardCurtainRevealTitle";
const CardCurtain = React.forwardRef<HTMLDivElement, HTMLMotionProps<"div">>(({ className, ...props }, ref) => {
const { revealed, canHover } = useCardCurtainRevealContext();
if (!canHover) return null;
return (
<motion.div
ref={ref}
aria-hidden="true"
className={cn("pointer-events-none absolute inset-0 size-full mix-blend-difference", className)}
variants={curtainVariants}
initial={false}
animate={revealed ? "visible" : "hidden"}
{...props}
/>
);
});
CardCurtain.displayName = "CardCurtain";
const CardCurtainRevealDescription = React.forwardRef<HTMLDivElement, HTMLMotionProps<"div">>(
({ className, ...props }, ref) => {
const { revealed } = useCardCurtainRevealContext();
return (
<motion.div
ref={ref}
className={className}
variants={curtainVariants}
initial={false}
animate={revealed ? "visible" : "hidden"}
{...props}
/>
);
},
);
CardCurtainRevealDescription.displayName = "CardCurtainRevealDescription";
export {
CardCurtainReveal,
CardCurtainRevealBody,
CardCurtainRevealFooter,
CardCurtainRevealDescription,
CardCurtainRevealTitle,
CardCurtain,
};
@@ -0,0 +1,7 @@
---
title: FastH3 4-Step V1
description: Configure FastH3 4-Step V1 and copy inference or serving commands.
mode: frame
---
<Cookbook modelId="fasth3-4step" />
@@ -0,0 +1,7 @@
---
title: FastH3 OmniRef
description: Configure FastH3 OmniRef reference-to-video serving on an RTX 5090, RTX PRO 6000, or DGX Spark.
mode: frame
---
<Cookbook modelId="fasth3-omniref" />
@@ -0,0 +1,7 @@
---
title: FastH3 Preview V0.2
description: Configure FastH3 Preview V0.2 and copy inference or serving commands.
mode: frame
---
<Cookbook modelId="fasth3-preview" />
+392 -134
View File
@@ -1,4 +1,6 @@
import { useEffect, useId, useState, type ReactNode } from "react";
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 { Badge } from "@/components/ui/badge";
@@ -8,8 +10,24 @@ import { Textarea } from "@/components/ui/textarea";
import SourceRecipe from "./SourceRecipe";
import HardwarePicker from "@/components/cookbook/HardwarePicker";
import ModelHeader from "@/components/cookbook/ModelHeader";
import {
CardCurtain,
CardCurtainReveal,
CardCurtainRevealBody,
CardCurtainRevealDescription,
CardCurtainRevealFooter,
CardCurtainRevealTitle,
} from "@/components/ui/card-curtain-reveal";
import RecipeFacts from "@/components/cookbook/RecipeFacts";
import { configAndLaunch, inferenceOutput, sdkCommand } from "@/lib/cookbook-commands";
import {
gpuCountFrom,
readHardwareSelection,
resolveHardware,
useHardwareCatalog,
writeHardwareSelection,
type HardwareSelection,
} from "@/lib/hardware";
import {
api,
guideUrl,
@@ -33,11 +51,32 @@ function groupTitle(path: string) {
"generator.engine.offload": "CPU offload",
"generator.engine.compile": "Compilation",
"default_request.sampling": "Request defaults",
"generator.pipeline.experimental": "Decoder",
"generator.pipeline.components": "Encoder",
streaming: "Streaming",
};
return titles[path] ?? path.split(".").at(-1)?.replaceAll("_", " ");
}
/** Short chip labels for component choices whose values are file paths. */
const CHOICE_LABELS: Record<string, Record<string, string>> = {
"generator.pipeline.experimental.h3_vae_nvfp4_decoder": {
null: "Original",
"./fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors": "NVFP4 36L · quality",
"./fasth3-nvfp4-vae/fast-8L/decoder-nvfp4.safetensors": "NVFP4 8L · fast",
},
"generator.pipeline.components.text_encoder_weights": {
null: "Bundled",
"./fasth3-nvfp4-encoder/text_encoder": "NVFP4 encoder",
},
};
function choiceLabel(path: string, choice: Value) {
const label = CHOICE_LABELS[path]?.[String(choice)];
if (label) return label;
return typeof choice === "boolean" ? (choice ? "On" : "Off") : String(choice);
}
function errorText(error: unknown) {
return error instanceof Error ? error.message : "Unable to load this recipe.";
}
@@ -103,7 +142,7 @@ function FieldControl({
aria-pressed={value === choice}
onClick={() => update(field.path, JSON.stringify(choice), field)}
>
{typeof choice === "boolean" ? (choice ? "On" : "Off") : String(choice)}
{choiceLabel(field.path, choice)}
</button>
))}
{nullable && !choices.includes(null) && (
@@ -199,10 +238,10 @@ function Editor({
const [showEnv, setShowEnv] = useState(false);
const [showClient, setShowClient] = useState(false);
const [outputTab, setOutputTab] = useState("commands");
const [hardware, setHardware] = useState(() => {
const requested = new URL(window.location.href).searchParams.get("hardware");
return requested && requested !== "unspecified" ? requested : "";
});
const [hardware, setHardware] = useState<HardwareSelection>(() =>
readHardwareSelection(new URL(window.location.href).searchParams),
);
const hardwareCatalog = useHardwareCatalog();
const [edits, setEdits] = useState<Record<string, Value>>({});
const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
const [reset, setReset] = useState(0);
@@ -229,7 +268,7 @@ function Editor({
const groups = [...new Set(fields.map((field) => field.path.split(".").slice(0, -1).join(".")))];
function update(path: string, raw: string | undefined, field: Field) {
setNotice("");
const next = { ...edits };
let next = { ...edits };
const errors = { ...parseErrors };
delete errors[path];
if (raw === undefined) delete next[path];
@@ -240,6 +279,15 @@ function Editor({
errors[path] = "Enter a valid number or JSON value for this field.";
}
}
if (path === GPU_COUNT_PATH && typeof next[path] === "number") {
const fitted = fitParallelism(fields, next, next[path] as number);
if (fitted !== next) {
next = fitted;
for (const key of Object.keys(errors)) if (key.includes("parallelism")) delete errors[key];
setReset(reset + 1);
setNotice(`Parallelism set to automatic for ${next[path]} ${next[path] === 1 ? "GPU" : "GPUs"}.`);
}
}
setEdits(next);
setParseErrors(errors);
}
@@ -262,22 +310,8 @@ function Editor({
setNotice("Downloaded config.yaml.");
}
const mlx = catalog.runtime.backend === "mlx";
const generator = catalog.base_config.generator as { engine?: { quantization?: { transformer_quant?: string } } };
const nvfp4 = generator?.engine?.quantization?.transformer_quant === "NVFP4";
const hardwareOptions = (
mlx
? [["apple", "Apple Silicon", "Unified memory"]]
: [
["b200", "B200", "Data center"],
["gb200", "GB200", "Data center"],
["h100", "H100", "Data center"],
["rtx5090", "RTX 5090", "GeForce"],
["rtx", "RTX PRO 6000", "Workstation"],
["spark", "DGX Spark", "GB10"],
]
).filter(([id]) => !nvfp4 || id !== "h100");
const selectedHardware = hardwareOptions.find(([value]) => value === hardware);
const hardwareLabel = hardware.startsWith("custom:") ? hardware.slice(7) : selectedHardware?.[1];
const hardwareRecord = resolveHardware(hardware, hardwareCatalog.records);
const gpuCount = gpuCountFrom(result?.config);
const model = modelDirectory.find((model) => model.id === catalog.id.split("/")[0])!;
const renderField = (field: Field) => (
<FieldControl
@@ -288,13 +322,25 @@ function Editor({
failure={parseErrors[field.path] || (field.path === failedPath ? failure : undefined)}
/>
);
function chooseHardware(value: string) {
setHardware(value);
function chooseHardware(selection: HardwareSelection) {
setHardware(selection);
const url = new URL(window.location.href);
if (value) url.searchParams.set("hardware", value);
else url.searchParams.delete("hardware");
window.history.replaceState(null, "", url);
writeHardwareSelection(url, selection);
window.history.pushState(null, "", url);
const record = resolveHardware(selection, hardwareCatalog.records);
const countField = fields.find((field) => field.path === GPU_COUNT_PATH);
if (record && countField && singleGpuDevice(record) && gpuCount > 1) {
setEdits(fitParallelism(fields, { ...edits, [GPU_COUNT_PATH]: 1 }, 1));
setParseErrors(Object.fromEntries(Object.entries(parseErrors).filter(([path]) => !path.includes("parallelism"))));
setReset(reset + 1);
setNotice(`Set to 1 GPU for ${record.name}. Raise GPU count if this machine has more.`);
}
}
useEffect(() => {
const restore = () => setHardware(readHardwareSelection(new URL(window.location.href).searchParams));
window.addEventListener("popstate", restore);
return () => window.removeEventListener("popstate", restore);
}, []);
const modes = mlx || catalog.runtime.interface === "websocket" ? ["serving"] : ["sdk", "inference", "serving"];
const modeNote = mlx
? "Apple Silicon runs through the native MLX server in this recipe. There is no CUDA CLI or Python SDK launch for this deployment."
@@ -482,16 +528,20 @@ function Editor({
if (catalog.id !== deployment.id) selectRuntime(deployment.id);
}}
>
{deployment.runtime.includes("mlx") ? "MLX" : "CUDA"}
{deploymentLabel(deployment, deployments)}
</button>
))}
</div>
<div className="hardware-resource-grid">
<HardwarePicker
backend={mlx ? "mlx" : "nvidia"}
options={hardwareOptions}
backend={mlx ? "mlx" : "cuda"}
catalog={hardwareCatalog.records}
status={hardwareCatalog.status}
error={hardwareCatalog.error}
reload={hardwareCatalog.reload}
value={hardware}
onChange={chooseHardware}
gpuCount={gpuCount}
/>
<div className="matrix-resources">
<span className="matrix-resource-title">Resources</span>
@@ -501,7 +551,7 @@ function Editor({
href={sitePath(
mlx
? "/getting_started/installation/mlx"
: hardware === "spark"
: hardwareRecord?.name.toLowerCase().includes("dgx spark")
? "/getting_started/installation/spark"
: "/getting_started/installation/gpu",
)}
@@ -510,13 +560,6 @@ function Editor({
</a>
</div>
</div>
{hardwareLabel ? (
<p className="matrix-note">
{mlx
? `${hardwareLabel} is saved as your target. Apple Silicon uses unified memory; dedicated GPU memory does not apply.`
: `${hardwareLabel} is saved as your target. It does not change the generated YAML; use GPU count and parallelism for the run.`}
</p>
) : null}
<RecipeFacts validity={failure ? "invalid" : result ? "valid" : "loading"} fit="unknown" evidence="none" />
</MatrixRow>
{catalog.workload === "i2v" && runMode !== "serving" && (
@@ -543,7 +586,7 @@ function Editor({
</div>
</MatrixRow>
)}
{model.family === "FastH3" && (
{model.family === "MiniMax H3" && (
<MatrixRow title="Components">
{model.id === "fasth3-trim" && (
<div className="option-chips" aria-label="Recipe components">
@@ -557,7 +600,7 @@ function Editor({
</div>
)}
<p className="matrix-note">
Components come from the maintained recipe. They are not individually selectable in the cookbook yet.
Transformer weights come from the maintained recipe. Swap the text encoder and video decoder below.
</p>
</MatrixRow>
)}
@@ -692,8 +735,20 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
setError("");
const url = new URL(window.location.href);
url.searchParams.set("deployment", value);
window.history.replaceState(null, "", url);
window.history.pushState(null, "", url);
}
useEffect(() => {
const restore = () => {
const requested = new URL(window.location.href).searchParams.get("deployment");
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;
// The deployment loader clears and reloads the catalog only when the deployment changes.
setSelected(next);
};
window.addEventListener("popstate", restore);
return () => window.removeEventListener("popstate", restore);
}, [index, modelId]);
const model = index?.models.find((model) => model.id === modelId);
if (!modelId) return <RecipeDirectory index={index} error={error} retry={() => setRetry(retry + 1)} />;
const directoryModel = modelDirectory.find((model) => model.id === modelId);
@@ -739,7 +794,7 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
aria-pressed={selected === deployment.id}
onClick={() => select(deployment.id)}
>
{deployment.runtime.includes("mlx") ? "MLX" : "CUDA"}
{deploymentLabel(deployment, model?.deployments ?? [])}
</button>
))}
</div>
@@ -750,104 +805,307 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
);
}
function RecipeDirectory({ index, error, retry }: { index?: Index; error: string; retry: () => void }) {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("All models");
const [family, setFamily] = useState("All families");
const visible = modelDirectory.filter(
(model) =>
(category === "All models" || model.category === category) &&
(family === "All families" || model.family === family) &&
`${model.title} ${model.family} ${model.tags.join(" ")}`.toLowerCase().includes(query.trim().toLowerCase()),
);
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 ?? "");
}
/** Returns new edits where every fixed parallel size that no longer divides `gpus` becomes automatic. */
function fitParallelism(fields: Field[], edits: Record<string, Value>, gpus: number): Record<string, Value> {
const stale = fields.filter((field) => {
if (!/\.parallelism\.[a-z_]+_size$/.test(field.path)) return false;
const size = edits[field.path] ?? field.value;
return typeof size === "number" && size > 0 && (size > gpus || gpus % size !== 0);
});
return stale.length ? { ...edits, ...Object.fromEntries(stale.map((field) => [field.path, -1])) } : edits;
}
type DeploymentChoice = { runtime: string; label: string };
/** Backend name when it is unique; the deployment's hardware label when several share a backend. */
function deploymentLabel(deployment: DeploymentChoice, deployments: DeploymentChoice[]) {
const backend = (item: DeploymentChoice) => (item.runtime.includes("mlx") ? "MLX" : "CUDA");
if (deployments.filter((item) => backend(item) === backend(deployment)).length < 2) return backend(deployment);
return deployment.label.replace(/\s*REST$/i, "");
}
/** Mirrors the editor: MLX and WebSocket deployments offer serving only. */
function configurableModes(index: Index | undefined, modelId: string): string[] | undefined {
const deployments = index?.models.find((model) => model.id === modelId)?.deployments;
if (!deployments?.length) return undefined;
const servingOnly = (runtime: string) => runtime.includes("mlx") || runtime.includes("streaming");
return deployments.some((deployment) => !servingOnly(deployment.runtime))
? ["Python SDK", "CLI", "Serving"]
: ["Serving"];
}
/** Mirrors the source recipe page: Python SDK and CLI always, serving when a recipe declares it. */
function exampleModes(model: DirectoryModel): string[] {
return ["Python SDK", "CLI", ...(model.recipes.some((recipe) => recipe.serving) ? ["Serving"] : [])];
}
/** 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[] }) {
const [focused, setFocused] = useState(false);
const details = useRef<HTMLDivElement>(null);
const [restOffset, setRestOffset] = useState(72);
useEffect(() => {
const element = details.current;
if (!element) return;
const measure = () => setRestOffset(element.offsetHeight + 8);
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, []);
return (
<div className="cookbook not-prose recipe-directory">
<div className="recipe-directory-tools">
<div className="recipe-family-filters" aria-label="Filter recipes by category">
{["All models", "Video", "Image", "Audio", "World"].map((value) => (
<button
key={value}
type="button"
aria-pressed={category === value}
onClick={() => {
setCategory(value);
setFamily("All families");
}}
>
{value}
</button>
))}
</div>
<Input
aria-label="Search recipes"
type="search"
placeholder="Search models…"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className="recipe-directory-subbar">
<p role="status">{visible.length} models</p>
<select aria-label="Model family" value={family} onChange={(event) => setFamily(event.target.value)}>
<option>All families</option>
{[
...new Set(
modelDirectory
.filter((model) => category === "All models" || model.category === category)
.map((model) => model.family),
),
].map((family) => (
<option key={family}>{family}</option>
))}
</select>
</div>
{error && (
<div role="alert" className="recipe-error">
<p>{error}</p>
<Button variant="outline" onClick={retry}>
Retry loading recipes
</Button>
</div>
)}
<div className="recipe-card-grid">
{visible.map((model) => (
<a className="recipe-card" key={model.id} href={sitePath(model.path)}>
<div className="recipe-card-top">
<img src={sitePath(`/assets/logos/${model.logo}.webp`)} alt="" width="40" height="40" />
<span className="recipe-card-kicker">{model.provider}</span>
{model.featured && <span className="recipe-featured">Featured</span>}
</div>
<h2>{model.title}</h2>
<a className="recipe-card" href={href} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)}>
<CardCurtainReveal active={focused} className="recipe-card-reveal">
<CardCurtainRevealBody className="recipe-card-body">
<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>
{model.featured ? <span className="recipe-featured">Featured</span> : null}
</div>
<CardCurtainRevealTitle
as={model.configurable ? "h3" : "h4"}
hiddenOffset={restOffset}
className="recipe-card-title"
>
{model.title}
</CardCurtainRevealTitle>
<CardCurtainRevealDescription ref={details} className="recipe-card-details">
<p className="recipe-card-description">{model.description}</p>
<div className="recipe-tags">
<span>{model.category}</span>
{model.tags.slice(0, 2).map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
<footer>
<span>
{model.platforms.map((platform) => (platform === "Apple Silicon" ? "MLX" : platform)).join(" · ")}
</span>
<span>{model.configurable ? "Configure →" : "View recipe →"}</span>
</footer>
</a>
))}
{modes ? <p className="recipe-card-modes">{modes.join(" · ")}</p> : null}
</CardCurtainRevealDescription>
<CardCurtain className="recipe-card-curtain" />
</CardCurtainRevealBody>
<CardCurtainRevealFooter className="recipe-card-footer">
<span>
{model.platforms.map((platform) => (platform === "Apple Silicon" ? "MLX" : platform)).join(" · ")}
</span>
<span className="recipe-card-cta">
{model.configurable ? "Configure" : "View recipe"} <ArrowUpRight aria-hidden="true" />
</span>
</CardCurtainRevealFooter>
</CardCurtainReveal>
</a>
);
}
function RecipeDirectory({ index, error, retry }: { index?: Index; error: string; retry: () => void }) {
const [initial] = useState(() =>
typeof window === "undefined" ? new URLSearchParams() : new URL(window.location.href).searchParams,
);
const [query, setQuery] = useState(initial.get("q") ?? "");
const [category, setCategory] = useState(initial.get("category") ?? "All models");
const [family, setFamily] = useState(initial.get("family") ?? "All families");
const [runtime, setRuntime] = useState(initial.get("runtime") ?? "all");
const [task, setTask] = useState(initial.get("task") ?? "All tasks");
useEffect(() => {
const url = new URL(window.location.href);
const search = url.searchParams;
const set = (key: string, value: string | undefined) => {
if (value) search.set(key, value);
else search.delete(key);
};
set("q", query.trim() || undefined);
set("category", category === "All models" ? undefined : category);
set("family", family === "All families" ? undefined : family);
set("runtime", runtime === "all" ? undefined : runtime);
set("task", task === "All tasks" ? undefined : task);
window.history.replaceState(null, "", url);
}, [category, family, query, runtime, task]);
const runtimeMatches = (platforms: string[]) => {
if (runtime === "cuda") return platforms.includes("NVIDIA");
if (runtime === "mlx") return platforms.includes("Apple Silicon");
return true;
};
const visible = modelDirectory.filter(
(model) =>
(category === "All models" || model.category === category) &&
(family === "All families" || model.family === family) &&
(task === "All tasks" || model.task === task) &&
runtimeMatches(model.platforms) &&
`${model.title} ${model.family} ${model.tags.join(" ")} ${model.description}`
.toLowerCase()
.includes(query.trim().toLowerCase()),
);
function clear() {
setQuery("");
setCategory("All models");
setFamily("All families");
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 filtered =
!!query.trim() ||
category !== "All models" ||
family !== "All families" ||
runtime !== "all" ||
task !== "All tasks";
const categoryCount = (value: string) =>
modelDirectory.filter((model) => value === "All models" || model.category === value).length;
return (
<MotionConfig reducedMotion="user">
<div className="cookbook not-prose recipe-directory">
<div className="recipe-directory-tools">
<div className="recipe-family-filters" aria-label="Filter recipes by category">
{["All models", "Video", "Image", "Audio", "World"].map((value) => (
<button
key={value}
type="button"
aria-pressed={category === value}
onClick={() => {
setCategory(value);
setFamily("All families");
}}
>
{value}
<span className="recipe-filter-count" aria-hidden="true">
{categoryCount(value)}
</span>
</button>
))}
</div>
<Input
aria-label="Search recipes"
type="search"
placeholder="Search models…"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<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 ? (
<button type="button" className="recipe-clear" onClick={clear}>
Reset filters
</button>
) : null}
</p>
<div className="recipe-directory-selects">
<label>
<span>Family</span>
<select aria-label="Model family" value={family} onChange={(event) => setFamily(event.target.value)}>
<option>All families</option>
{[
...new Set(
modelDirectory
.filter((model) => category === "All models" || model.category === category)
.map((model) => model.family),
),
].map((value) => (
<option key={value}>{value}</option>
))}
</select>
</label>
<label>
<span>Task</span>
<select aria-label="Task" value={task} onChange={(event) => setTask(event.target.value)}>
<option>All tasks</option>
{[...new Set(modelDirectory.map((model) => model.task))].sort().map((value) => (
<option key={value}>{value}</option>
))}
</select>
</label>
<label>
<span>Runtime</span>
<select aria-label="Runtime" value={runtime} onChange={(event) => setRuntime(event.target.value)}>
<option value="all">All runtimes</option>
<option value="cuda">CUDA</option>
<option value="mlx">MLX</option>
</select>
</label>
</div>
</div>
{error ? (
<div role="alert" className="recipe-error">
<p>{error}</p>
<Button variant="outline" onClick={retry}>
Retry loading recipes
</Button>
</div>
) : null}
{configurable.length ? (
<section className="recipe-section" aria-labelledby="recipe-configurable-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>
</header>
<div className="recipe-card-grid recipe-card-grid-configurable">
{configurable.map((model) => (
<RecipeCard
key={model.id}
model={model}
href={sitePath(model.path)}
modes={configurableModes(index, model.id)}
/>
))}
</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>
</div>
);
})}
</section>
) : null}
{!visible.length ? (
<div className="recipe-empty">
<h2>No matching recipes</h2>
<p>Clear a filter or choose a different runtime.</p>
<Button variant="outline" onClick={clear}>
Clear filters
</Button>
</div>
) : null}
</div>
{!visible.length && (
<div className="recipe-empty">
<h2>No matching recipes</h2>
<Button
variant="outline"
onClick={() => {
setQuery("");
setCategory("All models");
setFamily("All families");
}}
>
Clear filters
</Button>
</div>
)}
</div>
</MotionConfig>
);
}
+23 -33
View File
@@ -9,6 +9,14 @@ import RecipeFacts from "@/components/cookbook/RecipeFacts";
import { configAndLaunch, fileAndLaunch, sdkCommand, pythonSource } from "@/lib/cookbook-commands";
import { sitePath, type DirectoryModel } from "@/lib/cookbook-pages";
import { api } from "@/lib/cookbook";
import {
gpuCountFrom,
readHardwareSelection,
resolveHardware,
useHardwareCatalog,
writeHardwareSelection,
type HardwareSelection,
} from "@/lib/hardware";
interface Control {
name: string;
@@ -51,14 +59,10 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
const [outputTab, setOutputTab] = useState("commands");
const [error, setError] = useState("");
const [edits, setEdits] = useState<Record<number, string | number | boolean>>({});
const [hardware, setHardware] = useState(() => {
const requested = new URL(window.location.href).searchParams.get("hardware");
return requested && requested !== "unspecified" ? requested : "";
});
useEffect(() => {
const requested = new URL(window.location.href).searchParams.get("hardware");
setHardware(requested && requested !== "unspecified" ? requested : "");
}, []);
const [hardware, setHardware] = useState<HardwareSelection>(() =>
readHardwareSelection(new URL(window.location.href).searchParams),
);
const hardwareCatalog = useHardwareCatalog();
const recipe = model.recipes.find((recipe) => recipe.id === selected)!;
const yamlSource =
mode === "serving" ? recipe.serving?.source : /\.ya?ml$/.test(recipe.source) ? recipe.source : undefined;
@@ -212,23 +216,12 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
.map((match) => match[1])
.join("\n");
const mlx = recipe.gpu_types.includes("Apple Silicon");
const devices = mlx
? [["apple", "Apple Silicon", "Unified memory"]]
: [
["b200", "B200", "Data center"],
["h100", "H100", "Data center"],
["rtx5090", "RTX 5090", "GeForce"],
["rtx", "RTX PRO 6000", "Workstation"],
["spark", "DGX Spark", "GB10"],
];
const hardwareLabel = hardware.startsWith("custom:")
? hardware.slice(7)
: devices.find(([id]) => id === hardware)?.[1];
const chooseHardware = (value: string) => {
setHardware(value);
const hardwareRecord = resolveHardware(hardware, hardwareCatalog.records);
const gpuCount = config ? gpuCountFrom(config) : (recipe.hardware?.gpu_count ?? 1);
const chooseHardware = (selection: HardwareSelection) => {
setHardware(selection);
const url = new URL(window.location.href);
if (value) url.searchParams.set("hardware", value);
else url.searchParams.delete("hardware");
writeHardwareSelection(url, selection);
window.history.replaceState(null, "", url);
};
const control = (field: Control, index: number) => {
@@ -468,10 +461,14 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
</div>
<div className="hardware-resource-grid">
<HardwarePicker
backend={mlx ? "mlx" : "nvidia"}
options={devices}
backend={mlx ? "mlx" : "cuda"}
catalog={hardwareCatalog.records}
status={hardwareCatalog.status}
error={hardwareCatalog.error}
reload={hardwareCatalog.reload}
value={hardware}
onChange={chooseHardware}
gpuCount={gpuCount}
/>
<div className="matrix-resources">
<span className="matrix-resource-title">Resources</span>
@@ -484,13 +481,6 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
</a>
</div>
</div>
{hardwareLabel ? (
<p className="matrix-note">
{mlx
? `${hardwareLabel} is saved as your target. Apple Silicon uses unified memory; dedicated GPU memory does not apply.`
: `${hardwareLabel} is saved as your target. It does not change the example; use GPU count in the source settings.`}
</p>
) : null}
<RecipeFacts
validity={failure ? "invalid" : example ? "valid" : "loading"}
validityText={yamlSource ? "YAML parses" : "Example settings applied"}
+13 -2
View File
@@ -48,8 +48,19 @@ export function inferenceOutput(result: Result, imagePath?: string) {
const generator = result.config.generator as Record<string, Value>;
const pipeline = generator.pipeline as Record<string, Value> | undefined;
const inputs = { ...((defaults.inputs ?? {}) as Record<string, Value>) };
if (imagePath !== undefined) inputs.image_path = imagePath.trim();
const images = Array.isArray(inputs.image_path) ? inputs.image_path : [inputs.image_path];
const components = pipeline?.components as Record<string, Value> | undefined;
// Ref2VA pipelines read ordered references, not a first-frame image.
const ref2va = /ref2va/i.test(String(components?.override_pipeline_cls_name ?? ""));
if (imagePath !== undefined && imagePath.trim()) {
if (ref2va) inputs.references = [{ source: imagePath.trim(), media_type: "image" }];
else inputs.image_path = imagePath.trim();
} else if (imagePath !== undefined && !ref2va) inputs.image_path = imagePath.trim();
const references = Array.isArray(inputs.references) ? (inputs.references as Record<string, Value>[]) : [];
const images = ref2va
? references.map((reference) => reference.source)
: Array.isArray(inputs.image_path)
? inputs.image_path
: [inputs.image_path];
if (pipeline?.workload_type === "i2v" && !images.some((image) => typeof image === "string" && image.trim())) {
throw new Error("Enter a source image path before copying a CLI or Python SDK example.");
}
+54 -5
View File
@@ -4,23 +4,50 @@ import sourceCatalog from "../../docs/assets/cookbook-recipes.json" with { type:
export const recipePages = [
{
id: "fasth3-8step",
family: "FastH3",
family: "MiniMax H3",
title: "FastH3 8-Step V2",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-8step",
description: "Distilled MiniMax-H3 video generation with synchronized audio.",
tags: ["Text to video", "Audio", "8-step"],
featured: true,
},
{
id: "fasth3-trim",
family: "FastH3",
family: "MiniMax H3",
title: "FastH3 Trim",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-trim",
description: "The smaller FastH3 model for eight-step video generation with synchronized audio.",
tags: ["Text to video", "Audio", "NVFP4", "8-step"],
},
{
id: "fasth3-preview",
family: "MiniMax H3",
title: "FastH3 Preview V0.2",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-preview",
description: "The few-step FastH3 preview checkpoint on CUDA or native Apple Silicon MLX.",
tags: ["Text to video", "Audio", "4-step"],
},
{
id: "fasth3-4step",
family: "MiniMax H3",
title: "FastH3 4-Step V1",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-4step",
description: "FastH3 V1 on one DGX Spark with offloaded encoders and lazy module loading.",
tags: ["Text to video", "Audio", "DGX Spark"],
},
{
id: "fasth3-omniref",
family: "MiniMax H3",
title: "FastH3 OmniRef",
mark: "H3",
path: "/cookbook/minimax-h3/fasth3-omniref",
description: "Video and audio from a prompt plus ordered image, video, and audio references.",
tags: ["Reference to video", "Audio", "NVFP4"],
featured: true,
},
{
id: "fastwan21",
family: "Wan",
@@ -93,10 +120,31 @@ const familyInfo: Record<string, { title: string; path: string; logo: string; ca
const backendModels: Record<string, string> = {
"FastVideo/FastVideo-FastH3-8-Step-V2": "fasth3-8step",
"FastVideo/FastVideo-FastH3-Trim-8-Step-NVFP4": "fasth3-trim",
"FastVideo/FastVideo-Minimax-FastH3-Preview-v0.2": "fasth3-preview",
"FastVideo/FastVideo-FastH3-4-step-Preview-v1-VSA-DataFree": "fasth3-4step",
"FastVideo/FastH3-OmniRef-NVFP4-QAD": "fasth3-omniref",
"FastVideo/FastWan2.1-T2V-1.3B-Diffusers": "fastwan21",
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers": "wan21-i2v",
"FastVideo/LTX2-Distilled-Diffusers": "ltx2-distilled",
};
const TASK_GROUPS: Array<[RegExp, string]> = [
[/world/, "Text to world"],
[/video\/text to audio|video.*to audio/, "Video to audio"],
[/text to audio/, "Text to audio"],
[/text to image/, "Text to image"],
[/reference media to video/, "Reference to video"],
[/first\/last frame to video/, "Image to video"],
[/text or image to video/, "Text/image to video"],
[/image to video/, "Image to video"],
[/super-resolution/, "Video upscale"],
[/video/, "Text to video"],
];
/** A small discovery vocabulary derived from the recipe task, not a capability claim. */
export function taskLabel(value: string): string {
const normalized = value.toLowerCase();
return TASK_GROUPS.find(([pattern]) => pattern.test(normalized))?.[1] ?? "Text to video";
}
export const modelDirectory = [...new Set(sourceRecipes.map((recipe) => recipe.model))]
.map((model) => {
const recipes = sourceRecipes.filter((recipe) => recipe.model === model);
@@ -129,7 +177,7 @@ export const modelDirectory = [...new Set(sourceRecipes.map((recipe) => recipe.m
: model === "MiniMaxAI/MiniMax-H3"
? "MiniMax H3"
: first.label.split(" on ")[0]),
family: first.family === "minimax_h3" ? "FastH3" : (backend?.family ?? family.title),
family: backend?.family ?? family.title,
familyPath: `/cookbook/${family.path}`,
path: backend?.path ?? `/cookbook/${family.path}/models/${first.id}`,
logo,
@@ -137,6 +185,7 @@ export const modelDirectory = [...new Set(sourceRecipes.map((recipe) => recipe.m
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,
@@ -145,7 +194,7 @@ export const modelDirectory = [...new Set(sourceRecipes.map((recipe) => recipe.m
.sort(
(a, b) =>
Number(b.featured) - Number(a.featured) ||
Number(b.family === "FastH3") - Number(a.family === "FastH3") ||
Number(b.family === "MiniMax H3") - Number(a.family === "MiniMax H3") ||
Number(b.configurable) - Number(a.configurable),
);
export type DirectoryModel = (typeof modelDirectory)[number];
+14 -2
View File
@@ -2,11 +2,23 @@ export const families = [
{
name: "FastH3",
mark: "H3",
summary: "Distilled MiniMax-H3 video with synchronized audio.",
summary: "Distilled MiniMax-H3 video with synchronized audio, from text or ordered references.",
collection: "https://huggingface.co/collections/FastVideo/fasth3-v2",
guide: "/cookbook/minimax-h3",
detail: "8-step V2 \u00b7 FP8 and NVFP4 exports",
detail: "OmniRef \u00b7 8-step V2 \u00b7 QAD v2 \u00b7 FP8 and NVFP4 exports \u00b7 NVFP4 VAE decoder",
models: [
{
id: "FastH3-OmniRef-NVFP4-QAD",
url: "https://huggingface.co/FastVideo/FastH3-OmniRef-NVFP4-QAD",
},
{
id: "FastH3-QAD-v2",
url: "https://huggingface.co/FastVideo/FastH3-QAD-v2",
},
{
id: "FastH3-NVFP4-VAE-Decoder",
url: "https://huggingface.co/FastVideo/FastH3-NVFP4-VAE-Decoder",
},
{
id: "FastVideo-FastH3-8-Step-V2",
url: "https://huggingface.co/FastVideo/FastVideo-FastH3-8-Step-V2",
+352
View File
@@ -0,0 +1,352 @@
import { useEffect, useState } from "react";
export type MemoryType = "dedicated" | "unified" | "system";
export type MemoryUnit = "GB" | "GiB";
/** One capacity variant from the shared hardware catalog. */
export interface HardwareRecord {
id: string;
name: string;
memory_type: MemoryType;
memory_gb: number;
memory_unit: MemoryUnit;
backend: string;
supported_dtypes?: string[];
sources?: string[];
notes?: string;
ui_id?: string;
aliases?: string[];
vendor?: string;
kind?: string;
family?: string;
variant?: string;
form_factor?: string;
gpu_count?: number;
host_ram_gb?: number;
usable_budget_gb?: number;
}
export interface CustomHardware {
name: string;
backend: string;
memory_type: MemoryType;
memory_gb: number;
memory_unit: MemoryUnit;
usable_budget_gb?: number;
host_ram_gb?: number;
}
export interface HardwareSelection {
id: string;
custom?: CustomHardware;
}
export interface HardwareCatalogState {
status: "loading" | "ready" | "unavailable";
records: HardwareRecord[];
error?: string;
reload: () => void;
}
const HARDWARE_PATH = "/assets/cookbook-config/capacity/hardware.json";
const CUSTOM_PARAMS = ["hwName", "hwBackend", "hwType", "hwMemory", "hwUnit", "hwBudget", "hwRam"] as const;
const MEMORY_TYPES: MemoryType[] = ["dedicated", "unified", "system"];
function text(value: unknown, max = 200): string | undefined {
return typeof value === "string" && value.trim() ? value.trim().slice(0, max) : undefined;
}
function positive(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 4096 ? value : undefined;
}
function numberText(value: string | null): number | undefined {
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 4096 ? parsed : undefined;
}
function memoryType(value: unknown): MemoryType | undefined {
return MEMORY_TYPES.includes(value as MemoryType) ? (value as MemoryType) : undefined;
}
function memoryUnit(value: unknown): MemoryUnit {
return value === "GiB" ? "GiB" : "GB";
}
function backend(value: unknown, fallback = "cuda"): string {
if (value === "mlx" || value === "metal") return "mlx";
if (value === "cuda" || value === "rocm") return value;
return fallback;
}
/** Catalog records keep backends the cookbook cannot run, so they never pose as CUDA targets. */
function catalogBackend(value: unknown): string {
if (value === "sycl" || value === "unknown") return value;
return backend(value, "unknown");
}
/** Accepts the generated `{ version, devices: [] }` asset and the older mapping forms. */
function recordsFrom(payload: unknown): HardwareRecord[] {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return [];
const container = payload as Record<string, unknown>;
let entries: Array<[string, unknown]> = [];
if (Array.isArray(container.devices)) {
entries = container.devices.map((entry) => {
const item = entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
return [text(item.id, 120) ?? "", entry];
});
} else {
const source =
container.hardware && typeof container.hardware === "object" && !Array.isArray(container.hardware)
? (container.hardware as Record<string, unknown>)
: container;
entries = Object.entries(source);
}
const records: HardwareRecord[] = [];
for (const [fallbackId, value] of entries) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const item = value as Record<string, unknown>;
const id = text(item.id, 120) ?? text(fallbackId, 120);
const name = text(item.name, 120);
const memory =
item.memory && typeof item.memory === "object" ? (item.memory as Record<string, unknown>) : undefined;
const nominal =
memory?.nominal && typeof memory.nominal === "object" ? (memory.nominal as Record<string, unknown>) : undefined;
const capacity = positive(nominal?.value) ?? positive(item.memory_gb);
const type = memoryType(memory?.type) ?? memoryType(item.memory_type);
if (!id || !name || !capacity || !type) continue;
const usable =
memory?.usable && typeof memory.usable === "object" ? (memory.usable as Record<string, unknown>) : undefined;
const host =
item.host_memory && typeof item.host_memory === "object"
? (item.host_memory as Record<string, unknown>)
: undefined;
const aliases = Array.isArray(item.aliases)
? item.aliases.map((alias) => text(alias, 80)).filter((alias): alias is string => !!alias)
: undefined;
const sources = Array.isArray(item.sources)
? item.sources
.map((source) =>
typeof source === "string"
? text(source, 500)
: source && typeof source === "object"
? text((source as Record<string, unknown>).url, 500)
: undefined,
)
.filter((url): url is string => !!url)
: undefined;
records.push({
id,
name,
memory_type: type,
memory_gb: capacity,
memory_unit: memoryUnit(nominal?.unit ?? item.memory_unit),
backend: catalogBackend(item.backend),
supported_dtypes: Array.isArray(item.supported_dtypes)
? item.supported_dtypes.filter((dtype): dtype is string => typeof dtype === "string")
: undefined,
sources,
notes: text(item.notes, 600),
ui_id: text(item.ui_id, 40),
aliases,
vendor: text(item.vendor, 80),
kind: text(item.kind, 40),
family: text(item.family, 80),
variant: text(item.variant, 80),
form_factor: text(item.form_factor, 40),
gpu_count: positive(item.gpu_count),
host_ram_gb: positive(host?.value) ?? positive(item.host_memory_gb) ?? positive(item.host_ram_gb),
usable_budget_gb: positive(usable?.value) ?? positive(item.usable_memory_gb) ?? positive(item.usable_budget_gb),
});
}
return records.sort((a, b) => a.name.localeCompare(b.name));
}
/** Loads only the shared lookup data. It does not estimate fit or inspect the browser's hardware. */
export function useHardwareCatalog(): HardwareCatalogState {
const [attempt, setAttempt] = useState(0);
const [state, setState] = useState<Omit<HardwareCatalogState, "reload">>({
status: "loading",
records: [],
});
useEffect(() => {
if (typeof window === "undefined") return;
const controller = new AbortController();
const url = new URL(`${import.meta.env.BASE_URL.replace(/\/$/, "")}${HARDWARE_PATH}`, window.location.origin);
setState({ status: "loading", records: [] });
fetch(url.href, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return recordsFrom(await response.json());
})
.then((records) => {
if (controller.signal.aborted) return;
setState(
records.length
? { status: "ready", records }
: { status: "unavailable", records: [], error: "The hardware catalog is empty." },
);
})
.catch((error) => {
if (!controller.signal.aborted)
setState({
status: "unavailable",
records: [],
error: error instanceof Error ? error.message : "The hardware catalog could not be loaded.",
});
});
return () => controller.abort();
}, [attempt]);
return { ...state, reload: () => setAttempt((current) => current + 1) };
}
export function readHardwareSelection(search: URLSearchParams): HardwareSelection {
const requested = search.get("hardware")?.trim() ?? "";
if (requested.startsWith("custom:")) {
return {
id: "custom",
custom: {
name: requested.slice(7).slice(0, 80),
backend: "cuda",
memory_type: "dedicated",
memory_gb: 0,
memory_unit: "GB",
},
};
}
if (requested !== "custom") return { id: requested };
return {
id: "custom",
custom: {
name: text(search.get("hwName"), 80) ?? "",
backend: backend(search.get("hwBackend")),
memory_type: memoryType(search.get("hwType")) ?? "dedicated",
memory_gb: numberText(search.get("hwMemory")) ?? 0,
memory_unit: memoryUnit(search.get("hwUnit")),
usable_budget_gb: numberText(search.get("hwBudget")),
host_ram_gb: numberText(search.get("hwRam")),
},
};
}
export function writeHardwareSelection(target: URL | URLSearchParams, selection: HardwareSelection) {
const search = target instanceof URL ? target.searchParams : target;
for (const key of CUSTOM_PARAMS) search.delete(key);
if (!selection.id) {
search.delete("hardware");
return;
}
search.set("hardware", selection.id);
if (selection.id !== "custom" || !selection.custom) return;
const custom = selection.custom;
if (custom.name) search.set("hwName", custom.name.slice(0, 80));
search.set("hwBackend", custom.backend);
search.set("hwType", custom.memory_type);
if (custom.memory_gb > 0) search.set("hwMemory", String(custom.memory_gb));
search.set("hwUnit", custom.memory_unit);
if (custom.usable_budget_gb) search.set("hwBudget", String(custom.usable_budget_gb));
if (custom.host_ram_gb) search.set("hwRam", String(custom.host_ram_gb));
}
export function resolveHardware(selection: HardwareSelection, records: HardwareRecord[]): HardwareRecord | undefined {
if (selection.id === "custom") {
const custom = selection.custom;
if (!custom?.name.trim() || !custom.memory_gb) return undefined;
return {
id: "custom",
name: custom.name.trim(),
memory_type: custom.memory_type,
memory_gb: custom.memory_gb,
memory_unit: custom.memory_unit,
backend: custom.backend,
usable_budget_gb:
custom.usable_budget_gb && custom.usable_budget_gb <= custom.memory_gb ? custom.usable_budget_gb : undefined,
host_ram_gb: custom.host_ram_gb,
};
}
return (
records.find((record) => record.id === selection.id) ??
records.find((record) => record.aliases?.includes(selection.id))
);
}
/** A query-only selector shared by the hardware picker and tests. */
export function searchHardware(records: HardwareRecord[], query: string): HardwareRecord[] {
const tokens = query
.toLowerCase()
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean);
if (!tokens.length) return records;
return records.filter((record) => {
const haystack = [
record.name,
record.id,
record.ui_id,
record.vendor,
record.kind,
record.family,
record.variant,
record.form_factor,
record.gpu_count ? `${record.gpu_count}gpu` : undefined,
record.backend,
record.memory_type,
`${record.memory_gb}gb`,
`${record.memory_gb}gib`,
...(record.aliases ?? []),
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return tokens.every((token) => haystack.includes(token));
});
}
export function memoryText(record: HardwareRecord, includeUnit = true): string {
const value = `${record.memory_gb} ${record.memory_unit}`;
if (!includeUnit) return value;
if (record.memory_type === "dedicated") return `${value} VRAM`;
if (record.memory_type === "unified") return `${value} unified memory`;
return `${value} system memory`;
}
export function memoryContext(record: HardwareRecord): string {
if (record.memory_type === "dedicated") return "Dedicated GPU memory. It is per GPU and is not shared with host RAM.";
if (record.memory_type === "unified")
return "Unified memory is shared by the CPU and GPU; the full amount is not available to one process.";
return "System memory is host RAM used by the CPU and any offload.";
}
export function backendLabel(value: string): string {
if (value === "mlx") return "Apple MLX";
if (value === "rocm") return "ROCm";
if (value === "sycl") return "Intel SYCL";
if (value === "unknown") return "Unknown backend";
return "CUDA";
}
/** GPU count never multiplies a catalog capacity into a pool. */
export function gpuScopeNote(record: HardwareRecord | undefined, gpuCount: number): string | undefined {
if (!record || gpuCount <= 1) return undefined;
if (record.memory_type === "unified")
return `${gpuCount} GPUs share unified memory. This catalog entry describes one system; its ${record.memory_gb} ${record.memory_unit} is not multiplied.`;
if (record.memory_type === "system")
return `${gpuCount} GPUs use host system memory. This entry describes one host; the total is not multiplied.`;
return `${gpuCount} GPUs × ${record.memory_gb} ${record.memory_unit} VRAM. Memory is per device, not a combined pool.`;
}
export function gpuCountFrom(config: Record<string, unknown> | undefined): number {
const generator = config?.generator;
if (!generator || typeof generator !== "object" || Array.isArray(generator)) return 1;
const root = generator as Record<string, unknown>;
const engine =
root.engine && typeof root.engine === "object" && !Array.isArray(root.engine)
? (root.engine as Record<string, unknown>)
: undefined;
for (const value of [root.num_gpus, engine?.num_gpus]) {
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
}
return 1;
}
+17 -1
View File
@@ -142,10 +142,18 @@
"href": "/cookbook/openai-api"
},
{
"label": "FastH3",
"label": "MiniMax H3",
"display": "group",
"collapsed": false,
"items": [
{
"label": "MiniMax H3",
"href": "/cookbook/minimax-h3/models/minimax-h3-t2v"
},
{
"label": "FastH3 OmniRef",
"href": "/cookbook/minimax-h3/fasth3-omniref"
},
{
"label": "FastH3 8-Step V2",
"href": "/cookbook/minimax-h3/fasth3-8step"
@@ -154,6 +162,14 @@
"label": "FastH3 Trim",
"href": "/cookbook/minimax-h3/fasth3-trim"
},
{
"label": "FastH3 Preview V0.2",
"href": "/cookbook/minimax-h3/fasth3-preview"
},
{
"label": "FastH3 4-Step V1",
"href": "/cookbook/minimax-h3/fasth3-4step"
},
{
"label": "Recipes and setup",
"href": "/cookbook/minimax-h3"
+77
View File
@@ -9,6 +9,8 @@
"blume": "2.2.3",
"class-variance-authority": "0.7.1",
"cn": "^0.4.0",
"lucide-react": "1.59.0",
"motion": "14.4.0",
"radix-ui": "^1.8.1",
"react": "19.3.1",
"react-dom": "19.3.1",
@@ -8066,6 +8068,29 @@
"node": ">= 0.6"
}
},
"node_modules/framer-motion": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.4.0.tgz",
"integrity": "sha512-HtO1fxord2rnXYRyr1laG4uY7+OtgwDc9Szcg1wlMq/LF7BO3/S7a+KEo691U7pSi+i0ueRuRqaD1UaLEmZHuQ==",
"license": "MIT",
"dependencies": {
"motion-dom": "14.4.0",
"motion-utils": "14.1.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -9109,6 +9134,21 @@
"node": "20 || >=22"
}
},
"node_modules/lucide-react": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.59.0.tgz",
"integrity": "sha512-5qgMyyCPU6OvnKpLS2t693+ZtiqA8oaSweWoUows0BLFKFf6V75m4+CptpmohZHpD5IuOVIBXHBBiv2vpLJmtw==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -10127,6 +10167,43 @@
"node": ">= 18"
}
},
"node_modules/motion": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-14.4.0.tgz",
"integrity": "sha512-JJTIdeNjtF2zi/py0ndRKxQF+8z2pcDUgJYXul7zqbhMvyTLRsPFR9ifE/j4cFCpCMlehQVJkTwd7BZFiPKgQg==",
"license": "MIT",
"dependencies": {
"framer-motion": "14.4.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.4.0.tgz",
"integrity": "sha512-1q8RyHSdr5COpzrv3JgJ9XRjOO88ZN9CwflpkGgyO/SR/SNRP/dD6EU8SEo092kcKY34IzHC5diJPKrQRMA8lQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "14.1.1"
}
},
"node_modules/motion-utils": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.1.1.tgz",
"integrity": "sha512-3VvN5Uvf8B6qG+OfOui6Nht5EWp+vLuU3IqcAj7ItZGKqtmTjCebl5sGZF7/08gEHRnEl1AP/fIwbcEyVrcQGQ==",
"license": "MIT"
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+2
View File
@@ -21,6 +21,8 @@
"blume": "2.2.3",
"class-variance-authority": "0.7.1",
"cn": "^0.4.0",
"lucide-react": "1.59.0",
"motion": "14.4.0",
"radix-ui": "^1.8.1",
"react": "19.3.1",
"react-dom": "19.3.1",
+11 -6
View File
@@ -1,12 +1,17 @@
import { defineConfig } from "@playwright/test";
const externalBase = process.env.PLAYWRIGHT_BASE_URL;
const localBase = "http://127.0.0.1:4327";
export default defineConfig({
testDir: "./tests",
testMatch: "**/*.spec.ts",
use: { baseURL: "http://127.0.0.1:4327", trace: "retain-on-failure" },
webServer: {
command: "npm run preview -- --host 127.0.0.1 --port 4327",
url: "http://127.0.0.1:4327/FastVideo/cookbook",
reuseExistingServer: !process.env.CI,
},
use: { baseURL: externalBase ?? localBase, trace: "retain-on-failure" },
webServer: externalBase
? undefined
: {
command: "npm run preview -- --host 127.0.0.1 --port 4327",
url: `${localBase}/FastVideo/cookbook`,
reuseExistingServer: !process.env.CI,
},
});
+4 -1
View File
@@ -350,7 +350,10 @@ export async function transferDocs() {
} else if (item.items) {
const guide = item.items.find((child) => /Family guide|Recipes and setup/.test(child.label));
if (guide) {
const extras = modelDirectory.filter((model) => !model.configurable && model.familyPath === guide.href);
const listed = new Set(item.items.map((child) => child.href));
const extras = modelDirectory.filter(
(model) => !model.configurable && model.familyPath === guide.href && !listed.has(model.path),
);
item.items.splice(
item.items.indexOf(guide),
0,
+12
View File
@@ -98,3 +98,15 @@ test("Wan I2V inference requires a source image and preserves the serving baseli
const existing = parse(inferenceOutput({ ...result, config: withDefaults }).yaml);
assert.deepEqual(existing.request.inputs, withDefaults.default_request.inputs);
});
test("OmniRef inference writes the source image as an ordered Ref2VA reference", () => {
const config = parse(
readFileSync(new URL("../../examples/serving/openai_fasth3_omniref_rtx5090.yaml", import.meta.url), "utf8"),
);
const result = { config, command: "fastvideo serve --config config.yaml" };
assert.throws(() => inferenceOutput(result), /source image path/);
const saved = parse(inferenceOutput(result, " /data/reference.png ").yaml);
assert.deepEqual(saved.request.inputs.references, [{ source: "/data/reference.png", media_type: "image" }]);
assert.equal(saved.request.inputs.image_path, undefined);
assert.equal(saved.generator.pipeline.components.override_pipeline_cls_name, "MiniMaxH3Ref2VAModularPipeline");
});
+201 -28
View File
@@ -6,9 +6,25 @@ const route = "/FastVideo/cookbook/minimax-h3/fasth3-8step";
// deterministic when GitHub/Trendshift are slow or unreachable.
test.beforeEach(async ({ page }) => {
await page.route(/^https?:\/\/(?!127\.0\.0\.1|localhost)/, (request) => request.abort());
await page.route("**/assets/cookbook-config/capacity/hardware.json", (route) =>
route.fulfill({ path: "tests/fixtures/hardware.json", contentType: "application/json" }),
);
});
async function chooseHardware(page: Page, query: string) {
const search = page.getByLabel("Search hardware", { exact: true });
if ((await search.count()) === 0)
await page
.getByRole("button", { name: /Find or add|Choose hardware|Change hardware/ })
.first()
.click();
await search.fill(query);
await page
.getByRole("radio", { name: new RegExp(query, "i") })
.first()
.click();
}
async function settings(page: Page, section: string) {
await expect(page.locator(".recipe-matrix")).toBeVisible();
await expect(page.locator(".recipe-matrix")).toBeVisible({ timeout: 30000 });
if (section === "Serving" || section === "Hardware") {
const advanced = page.locator(".matrix-advanced");
if ((await advanced.getAttribute("open")) === null) await advanced.locator("summary").first().click();
@@ -100,14 +116,36 @@ test("deployment changes reset edits and support MLX and streaming", async ({ pa
await settings(page, "Serving");
await expect(page.getByLabel("Port", { exact: true })).toHaveValue("8000");
await page.goto("/FastVideo/cookbook/ltx/ltx2-distilled");
await expect(page.locator(".recipe-matrix")).toBeVisible({ timeout: 30000 });
await expect(page.getByLabel("Client command", { exact: true })).toContainText("/health");
await page.locator(".recipe-request-code > summary").click();
await expect(page.getByText(/WebSocket URL:/)).toBeVisible();
await page.goto("/FastVideo/cookbook/wan/wan21-i2v");
await expect(page.locator(".recipe-matrix")).toBeVisible({ timeout: 30000 });
await expect(page.getByLabel("Client command", { exact: true })).toContainText("input_reference");
await expect(page.getByRole("heading", { level: 1 })).toHaveText("Wan2.1 I2V 14B");
});
test("runtime and hardware choices support browser back", async ({ page }) => {
await page.goto(route);
await expect(page.locator(".recipe-matrix")).toBeVisible({ timeout: 30000 });
await backend(page, "MLX");
await expect(page).toHaveURL(/deployment=fasth3-8step%2Fmlx-rest/);
await backend(page, "CUDA");
await expect(page).toHaveURL(/deployment=fasth3-8step%2Fcuda-rest/);
await page.goBack();
await expect(page).toHaveURL(/deployment=fasth3-8step%2Fmlx-rest/);
await expect(page.getByRole("button", { name: "MLX", exact: true })).toHaveAttribute("aria-pressed", "true");
await page.goForward();
await expect(page).toHaveURL(/deployment=fasth3-8step%2Fcuda-rest/);
await chooseHardware(page, "RTX 5090");
await expect(page).toHaveURL(/hardware=geforce-rtx-5090-32gb/);
await page.goBack();
await expect(page).not.toHaveURL(/hardware=/);
await page.goForward();
await expect(page).toHaveURL(/hardware=geforce-rtx-5090-32gb/);
});
test("failed loads offer retry without keeping stale commands", async ({ page }) => {
await page.route("**/recipes/fasth3-8step/cuda-rest.json", (route) =>
route.fulfill({ status: 503, body: "unavailable" }),
@@ -185,8 +223,8 @@ 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(34);
await expect(cards.first()).toContainText("FastH3 8-Step V2");
await expect(cards).toHaveCount(35);
await expect(cards.first()).toContainText("FastH3 OmniRef");
await page.getByLabel("Model family", { exact: true }).selectOption("Wan");
await page.getByLabel("Search recipes").fill("Wan2.1 I2V");
await expect(cards).toHaveCount(1);
@@ -202,8 +240,9 @@ test("catalog discovery opens dedicated model recipes and filters by family and
test("hardware preferences stay distinct from schema-backed GPU count and topology", async ({ page }) => {
await page.goto(route);
await page.getByRole("radio", { name: "RTX PRO 6000" }).check();
await expect(page).toHaveURL(/hardware=rtx/);
await chooseHardware(page, "RTX PRO 6000");
await expect(page).toHaveURL(/hardware=rtx-pro-6000-blackwell-workstation-96gb/);
await expect(page.locator(".hardware-selected")).toContainText("96 GB VRAM");
await settings(page, "Hardware");
await page.getByLabel("Tensor parallelism", { exact: true }).fill("2");
await page.getByRole("button", { name: "Use automatic parallelism" }).click();
@@ -238,7 +277,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(34);
await expect(page.locator(".recipe-card")).toHaveCount(35);
});
test("cookbook cards and controls remain readable in dark mode and reflow at 320px", async ({ page }) => {
@@ -303,9 +342,9 @@ test("FastH3 Trim edits drive inference, serving, and complete copyable launch s
});
});
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-trim");
await page.getByRole("radio", { name: "DGX Spark GB10", exact: true }).check();
await expect(page.getByRole("radio", { name: "DGX Spark GB10", exact: true })).toBeChecked();
await expect(page.getByRole("radio", { name: "H100 Data center", exact: true })).toHaveCount(0);
await chooseHardware(page, "DGX Spark");
await expect(page).toHaveURL(/hardware=dgx-spark-gb10-128gb/);
await expect(page.getByText(/128 GB unified memory/)).toBeVisible();
await page.getByLabel("Prompt", { exact: true }).fill("A potter finishes a blue bowl.");
await page.getByLabel("Seed", { exact: true }).fill("0");
await page.getByRole("button", { name: "Copy config + command", exact: true }).click();
@@ -387,50 +426,93 @@ test("source-backed serving pages expose their actual server settings", async ({
test("source model hardware preferences survive hydration and reload", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/FastVideo/cookbook/flux/models/flux2-klein-t2i?hardware=custom%3ARTX%204090");
await expect(page.getByRole("radio", { name: "RTX 4090 Custom device", exact: true })).toBeChecked();
await page.goto(
"/FastVideo/cookbook/flux/models/flux2-klein-t2i?hardware=custom&hwName=RTX+4090&hwType=dedicated&hwMemory=24&hwUnit=GB",
);
await expect(page.getByText("RTX 4090", { exact: true })).toBeVisible();
await expect(page.locator(".hardware-selected")).toContainText("24 GB VRAM");
await page.reload();
await expect(page.getByRole("radio", { name: "RTX 4090 Custom device", exact: true })).toBeChecked();
await expect(page.getByText("RTX 4090", { exact: true })).toBeVisible();
expect(errors).toEqual([]);
});
test("custom devices persist and no preview control can block generated YAML", async ({ page }) => {
await page.goto(route);
await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click();
await page.getByRole("button", { name: "Can't find it? Add a device", exact: true }).click();
await page.getByLabel("Device name", { exact: true }).fill("RTX 4090");
await page.getByRole("button", { name: "Add device", exact: true }).click();
await expect(page.getByRole("radio", { name: "RTX 4090 Custom device", exact: true })).toBeChecked();
await page.getByRole("combobox", { name: "Memory type", exact: true }).selectOption("dedicated");
await page.getByRole("spinbutton", { name: "Nominal memory", exact: true }).fill("24");
await page.getByRole("spinbutton", { name: "Usable budget (optional)", exact: true }).fill("32");
await expect(page.getByText("The usable budget cannot exceed the nominal memory.", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Use this device", exact: true })).toBeDisabled();
await page.getByRole("spinbutton", { name: "Usable budget (optional)", exact: true }).fill("22");
await page.getByRole("spinbutton", { name: "Host RAM (optional)", exact: true }).fill("64");
await page.getByRole("button", { name: "Use this device", exact: true }).click();
await expect(page.getByText("RTX 4090", { exact: true })).toBeVisible();
await expect(page.locator(".hardware-selected")).toContainText("24 GB VRAM");
await expect(page.locator(".hardware-selected")).toContainText("Your budget: 22 GB");
await expect(page.locator(".hardware-selected")).toContainText("Host RAM: 64 GB");
await expect(page).toHaveURL(/hardware=custom/);
await expect(page).toHaveURL(/hwName=RTX\+4090/);
await page.reload();
await expect(page.getByRole("radio", { name: "RTX 4090 Custom device", exact: true })).toBeChecked();
await expect(page.getByText("RTX 4090", { exact: true })).toBeVisible();
await expect(page.locator(".hardware-selected")).toContainText("24 GB VRAM");
await expect(page.locator(".component-plan")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
await expect(page.getByRole("radio", { name: "RTX 4090 Custom device", exact: true })).toHaveCount(0);
await expect(page.getByText("RTX 4090", { exact: true })).toHaveCount(0);
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
});
test("hardware is never preselected and a target does not change generated YAML", async ({ page }) => {
test("hardware is never preselected; a single-GPU device sets one GPU with valid parallelism", async ({ page }) => {
await page.goto(route);
await expect(page.locator(".hardware-picker")).toBeVisible({ timeout: 30000 });
await expect(page.locator('input[type="radio"]:checked')).toHaveCount(0);
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
await output(page, "config.yaml");
const yaml = page.getByLabel("Generated YAML", { exact: true });
const baseline = await yaml.innerText();
await page.getByRole("radio", { name: /RTX 5090/ }).check();
await expect(page.getByRole("radio", { name: /RTX 5090/ })).toBeChecked();
await expect(page.getByText(/RTX 5090 is saved as your target/)).toBeVisible();
await expect(yaml).toHaveText(baseline);
await expect(yaml).toContainText('"num_gpus": 4');
await chooseHardware(page, "RTX 5090");
await expect(page).toHaveURL(/hardware=geforce-rtx-5090-32gb/);
await expect(page.getByText(/GeForce RTX 5090 .*32 GB VRAM/)).toBeVisible();
await expect(page.getByText(/Set to 1 GPU for .*RTX 5090/)).toBeVisible();
await expect(yaml).toContainText('"num_gpus": 1');
await expect(yaml).toContainText('"sp_size": -1');
await expect(yaml).not.toContainText('"hardware"');
await expect(page.getByText("Valid configuration", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
await expect(page.locator('input[type="radio"]:checked')).toHaveCount(0);
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
});
test("a saved target missing from the list stays visible and clearable", async ({ page }) => {
await page.goto(`${route}?hardware=retired-gpu`);
await expect(page.getByText("Saved hardware target is not in this list.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
test("lowering the GPU count resets parallelism that no longer fits", async ({ page }) => {
await page.goto(route);
await output(page, "config.yaml");
const yaml = page.getByLabel("Generated YAML", { exact: true });
await page.getByLabel("GPU count", { exact: true }).fill("2");
await expect(page.getByText("Parallelism set to automatic for 2 GPUs.")).toBeVisible();
await expect(yaml).toContainText('"num_gpus": 2');
await expect(yaml).toContainText('"sp_size": -1');
await expect(page.getByText("Valid configuration", { exact: true })).toBeVisible();
});
test("browser back after a hardware-only change keeps the editor loaded", async ({ page }) => {
await page.goto(route);
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
await chooseHardware(page, "RTX 5090");
await expect(page).toHaveURL(/hardware=geforce-rtx-5090-32gb/);
await page.goBack();
await expect(page).not.toHaveURL(/hardware=/);
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
await expect(page.getByText("Loading deployment settings…")).toHaveCount(0);
});
test("hardware links resolve catalog aliases and non-CUDA devices are not CUDA targets", async ({ page }) => {
await page.goto(`${route}?hardware=5090`);
await expect(page.locator(".hardware-selected")).toContainText("RTX 5090");
await page.getByRole("button", { name: "Change hardware", exact: true }).click();
await page.getByLabel("Search hardware", { exact: true }).fill("Arc B580");
await expect(page.getByRole("radio", { name: /Arc B580/ })).toHaveCount(0);
});
test("recipe facts separate validity, fit, and run evidence with inspectable assumptions", async ({ page }) => {
@@ -444,6 +526,97 @@ test("recipe facts separate validity, fit, and run evidence with inspectable ass
await expect(page.getByText(/do not establish this configuration/)).toBeVisible();
});
test("catalog lookup resolves aliases, variants, and unified memory without pooling", async ({ page }) => {
await page.goto(route);
await chooseHardware(page, "PRO 6000");
await expect(page).toHaveURL(/hardware=rtx-pro-6000-blackwell-workstation-96gb/);
await expect(page.locator(".hardware-selected")).toContainText("96 GB VRAM");
await expect(page.locator(".hardware-address-note")).toContainText("Per device, not a combined pool");
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click();
await page.getByLabel("Search hardware", { exact: true }).fill("GB10");
await page.getByRole("radio", { name: /DGX Spark/ }).click();
await expect(page.locator(".hardware-selected")).toContainText("128 GB unified memory");
await expect(page.locator(".hardware-selected")).toContainText("CUDA");
await expect(page.locator(".hardware-address-note")).toContainText("not dedicated VRAM");
await settings(page, "Hardware");
await page.getByLabel("GPU count", { exact: true }).fill("8");
await expect(page.getByText(/8 GPUs share unified memory/)).toBeVisible();
await expect(page.getByText(/its 128 GB is not multiplied/)).toBeVisible();
});
test("MLX lookup uses unified memory and a custom Mac stays reusable", async ({ page }) => {
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-8step?deployment=fasth3-8step%2Fmlx-rest");
await page.getByRole("button", { name: "+ Find or add a Mac", exact: true }).click();
await page.getByLabel("Search hardware", { exact: true }).fill("M4 Max");
await page.getByRole("radio", { name: /Apple M4 Max/ }).click();
await expect(page.locator(".hardware-selected")).toContainText("128 GB unified memory");
await expect(page.locator(".hardware-selected")).toContainText("Apple MLX");
await expect(page.locator(".hardware-selected")).toContainText("Usable budget: 96 GB");
await expect(page.locator(".hardware-address-note")).toContainText("not dedicated VRAM");
await expect(page.getByRole("button", { name: "Clear hardware target", exact: true })).toBeVisible();
});
test("the directory filters by runtime without a hardware picker", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await expect(page.locator(".recipe-directory .hardware-picker")).toHaveCount(0);
await page.getByLabel("Runtime", { exact: true }).selectOption("mlx");
await expect(page).toHaveURL(/runtime=mlx/);
const card = page.locator(".recipe-card").filter({ hasText: "FastMetal 1.3B" });
await expect(card).toBeVisible();
await expect(card).toHaveAttribute("href", /\/cookbook\/wan\/models\/[^?]+$/);
await expect(page.locator(".recipe-card").filter({ hasText: "FastWan2.1 1.3B" })).toHaveCount(0);
});
test("task discovery filters the directory and round-trips through the URL", async ({ page }) => {
await page.goto("/FastVideo/cookbook");
await page.getByLabel("Task", { exact: true }).selectOption("Image to video");
await expect(page).toHaveURL(/task=Image\+to\+video/);
await expect(page.locator(".recipe-card").filter({ hasText: "Wan2.1 I2V 14B" })).toBeVisible();
await expect(page.locator(".recipe-card").filter({ hasText: "FastWan2.1 1.3B" })).toHaveCount(0);
await page.reload();
await expect(page.getByLabel("Task", { exact: true })).toHaveValue("Image to video");
await page.getByLabel("Task", { exact: true }).selectOption("All tasks");
await expect(page).not.toHaveURL(/task=/);
});
test("directory filters round-trip through the URL", async ({ page }) => {
await page.goto("/FastVideo/cookbook?category=Image&runtime=cuda");
await expect(page.getByRole("button", { name: "Image", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(page.getByLabel("Runtime", { exact: true })).toHaveValue("cuda");
await expect(page.locator(".recipe-card")).toHaveCount(4);
await page.reload();
await expect(page.getByRole("button", { name: "Image", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(page.getByLabel("Runtime", { exact: true })).toHaveValue("cuda");
await expect(page.locator(".recipe-card")).toHaveCount(4);
});
test("a saved target missing from the catalog stays visible and clearable", async ({ page }) => {
await page.goto(`${route}?hardware=retired-gpu`);
await expect(page.getByText(/Saved hardware target \(retired-gpu\) is not in this catalog/)).toBeVisible();
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
await expect(page).not.toHaveURL(/hardware=/);
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
});
test("catalog search renders one plain no-results message", async ({ page }) => {
await page.goto(route);
await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click();
await page.getByLabel("Search hardware", { exact: true }).fill("no-such-device");
await expect(page.getByText("No matching devices.", { exact: true })).toHaveCount(1);
await page.getByRole("button", { name: "Can't find it? Add a device", exact: true }).click();
await expect(page.getByLabel("Device name", { exact: true })).toBeVisible();
});
test("hardware lookup closes with Escape and returns focus", async ({ page }) => {
await page.goto(route);
await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click();
await expect(page.getByLabel("Search hardware", { exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(page.getByLabel("Search hardware", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "+ Find or add a GPU", exact: true })).toBeFocused();
});
test("WebSocket deployments expose only the serving interface", async ({ page }) => {
await page.goto("/FastVideo/cookbook/ltx/ltx2-distilled");
const modes = page.getByRole("group", { name: "Run mode" });
+7 -6
View File
@@ -33,8 +33,8 @@ test("documentation navigation opens transferred guides", async ({ page }) => {
await page.goto("/FastVideo");
await expect(page.getByRole("heading", { name: "Welcome to FastVideo", exact: true })).toBeVisible();
await page.locator("#blume-nav-drawer").getByRole("link", { name: "Installation", exact: true }).click();
await expect(page).toHaveURL(/getting_started\/installation/);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expect(page).toHaveURL(/getting_started\/installation/, { timeout: 30000 });
await expect(page.getByRole("heading", { level: 1 })).toBeVisible({ timeout: 30000 });
});
test("the welcome page introduces the project and opens the cookbook", async ({ page }) => {
@@ -133,13 +133,14 @@ test("generated module links stay inside the transferred Python reference", asyn
});
test("search finds a transferred guide and opens it by keyboard", async ({ page }) => {
test.setTimeout(180000);
await page.goto("/FastVideo");
await page.getByRole("button", { name: "Search", exact: true }).click();
const query = page.getByRole("combobox", { name: "Search docs", exact: true });
await query.fill("offloading");
await expect(page.getByRole("option", { name: /^Offloading/ }).first()).toBeVisible();
await expect(page.getByRole("option", { name: /^Offloading/ }).first()).toBeVisible({ timeout: 120000 });
await query.press("Enter");
await expect(page).toHaveURL(/FastVideo\/inference\/offloading/);
await expect(page).toHaveURL(/FastVideo\/inference\/offloading/, { timeout: 30000 });
});
test("the welcome cards reflow on mobile without horizontal scrolling", async ({ page }) => {
@@ -153,7 +154,7 @@ test("the welcome cards reflow on mobile without horizontal scrolling", async ({
test("the model catalog filters by family and checkpoint name", async ({ page }) => {
await page.goto("/FastVideo/fast-models");
const catalog = page.locator(".model-catalog");
await expect(catalog.getByRole("status")).toHaveText("18 checkpoints");
await expect(catalog.getByRole("status")).toHaveText("21 checkpoints");
await page.getByRole("button", { name: "FastMetal", exact: true }).click();
await expect(catalog.getByRole("status")).toHaveText("3 checkpoints");
await page.getByRole("searchbox", { name: "Search checkpoints", exact: true }).fill("14B");
@@ -161,7 +162,7 @@ test("the model catalog filters by family and checkpoint name", async ({ page })
await page.getByRole("searchbox").fill("no-such-model");
await expect(catalog.getByRole("status")).toHaveText("0 checkpoints");
await page.getByRole("button", { name: "Clear filters", exact: true }).click();
await expect(catalog.getByRole("status")).toHaveText("18 checkpoints");
await expect(catalog.getByRole("status")).toHaveText("21 checkpoints");
});
test("hardware guides are grouped below general inference tasks", async ({ page }) => {
@@ -0,0 +1,17 @@
# Hardware catalog picker evidence
Captured 2026-10-19 against `blume build` + `blume preview` on Chromium 1248.
Third-party requests were aborted. The test fixture mirrors the generated
`capacity/hardware.json` shape (`{version, devices:[...]}`) with representative
NVIDIA and Apple entries; production data comes from Lane A's catalog.
| State | Capture |
| -------------------------------------------------------------------- | ---------------------------- |
| Directory filtered to Apple MLX with a selected M4 Max target | `after/directory-m4-max.png` |
| Model page with a selected unified-memory device and its lookup open | `after/model-unified.png` |
| Lookup with no matching devices | `after/no-results.png` |
| Narrow lookup | `after/narrow-lookup.png` |
| Directory task filter (image to video) | `after/directory-task.png` |
The captures show one selected target, separate dedicated/unified wording, and
one plain empty-state line. They are not memory-fit or performance evidence.
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+214
View File
@@ -0,0 +1,214 @@
{
"version": 1,
"devices": [
{
"id": "h100-sxm-80gb",
"name": "NVIDIA H100 SXM 80 GB",
"vendor": "nvidia",
"kind": "gpu",
"family": "nvidia-h100",
"variant": "SXM 80 GB",
"form_factor": "sxm",
"memory": {
"type": "dedicated",
"nominal": {
"value": 80,
"unit": "GB"
}
},
"backend": "cuda",
"gpu_count": 1,
"partitionable": true,
"partitions": [
{
"id": "1g.10gb",
"label": "1g.10gb",
"memory": {
"value": 10,
"unit": "GB"
}
},
{
"id": "3g.40gb",
"label": "3g.40gb",
"memory": {
"value": 40,
"unit": "GB"
}
}
],
"aliases": ["h100"],
"supported_dtypes": ["fp32", "fp16", "bf16", "fp8"],
"sources": [
{
"url": "https://www.nvidia.com/en-us/data-center/h100/",
"checked": "2026-10-19"
}
],
"memory_gb": 80
},
{
"id": "geforce-rtx-5090-32gb",
"name": "NVIDIA GeForce RTX 5090 32 GB (desktop)",
"vendor": "nvidia",
"kind": "gpu",
"family": "geforce-rtx-50",
"variant": "5090 desktop",
"form_factor": "desktop",
"memory": {
"type": "dedicated",
"nominal": {
"value": 32,
"unit": "GB"
}
},
"backend": "cuda",
"gpu_count": 1,
"aliases": ["rtx 5090", "5090"],
"supported_dtypes": ["fp32", "fp16", "bf16"],
"sources": [
{
"url": "https://www.nvidia.com/en-us/geforce/graphics-cards/50-series/rtx-5090/",
"checked": "2026-10-19"
}
],
"memory_gb": 32
},
{
"id": "rtx-pro-6000-blackwell-workstation-96gb",
"name": "NVIDIA RTX PRO 6000 Blackwell Workstation Edition 96 GB",
"vendor": "nvidia",
"kind": "gpu",
"family": "rtx-pro-6000",
"variant": "Blackwell Workstation Edition",
"form_factor": "workstation",
"memory": {
"type": "dedicated",
"nominal": {
"value": 96,
"unit": "GB"
}
},
"backend": "cuda",
"gpu_count": 1,
"aliases": ["pro 6000", "rtx pro 6000"],
"supported_dtypes": ["fp32", "fp16", "bf16"],
"sources": [
{
"url": "https://www.nvidia.com/en-us/design-visualization/desktop-graphics/",
"checked": "2026-10-19"
}
],
"memory_gb": 96
},
{
"id": "dgx-spark-gb10-128gb",
"name": "NVIDIA DGX Spark GB10 128 GB",
"vendor": "nvidia",
"kind": "system",
"family": "dgx-spark",
"variant": "GB10",
"form_factor": "desktop",
"memory": {
"type": "unified",
"nominal": {
"value": 128,
"unit": "GB"
}
},
"backend": "cuda",
"gpu_count": 1,
"aliases": ["spark", "gb10"],
"supported_dtypes": ["fp32", "fp16", "bf16"],
"sources": [
{
"url": "https://www.nvidia.com/en-us/products/workstations/dgx-spark/",
"checked": "2026-10-19"
}
],
"memory_gb": 128
},
{
"id": "apple-m4-max-128gb",
"name": "Apple M4 Max 128 GB",
"vendor": "apple",
"kind": "soc",
"family": "apple-m4",
"variant": "M4 Max",
"form_factor": "laptop or desktop",
"memory": {
"type": "unified",
"nominal": {
"value": 128,
"unit": "GB"
}
},
"backend": "metal",
"gpu_count": 1,
"aliases": ["m4 max", "macbook m4 max"],
"supported_dtypes": ["fp32", "fp16", "bf16"],
"sources": [
{
"url": "https://www.apple.com/macbook-pro/specs/",
"checked": "2026-10-19"
}
],
"memory_gb": 128,
"usable_memory_gb": 96
},
{
"id": "apple-m3-ultra-192gb",
"name": "Apple M3 Ultra 192 GB",
"vendor": "apple",
"kind": "soc",
"family": "apple-m3",
"variant": "M3 Ultra",
"form_factor": "desktop",
"memory": {
"type": "unified",
"nominal": {
"value": 192,
"unit": "GB"
}
},
"backend": "metal",
"gpu_count": 1,
"aliases": ["m3 ultra", "mac studio m3 ultra"],
"supported_dtypes": ["fp32", "fp16", "bf16"],
"sources": [
{
"url": "https://www.apple.com/mac-studio/specs/",
"checked": "2026-10-19"
}
],
"memory_gb": 192
},
{
"id": "intel-arc-b580-12gb",
"name": "Intel Arc B580 12 GB",
"vendor": "intel",
"kind": "gpu",
"family": "arc-b",
"variant": "B580",
"form_factor": "desktop",
"memory": {
"type": "dedicated",
"nominal": {
"value": 12,
"unit": "GB"
}
},
"backend": "sycl",
"gpu_count": 1,
"aliases": ["arc b580", "b580"],
"supported_dtypes": ["fp32", "fp16", "bf16"],
"sources": [
{
"url": "https://www.intel.com/content/www/us/en/products/sku/241598/intel-arc-b580-graphics/specifications.html",
"checked": "2026-10-19"
}
],
"memory_gb": 12
}
]
}
+397 -75
View File
@@ -523,42 +523,150 @@
background: var(--blume-muted);
font-weight: 500;
}
.recipe-filter-count {
margin-inline-start: 0.375rem;
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
font-variant-numeric: tabular-nums;
font-weight: 400;
}
.recipe-directory-subbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding-block: 1.25rem 0.75rem;
align-items: flex-end;
gap: 0.75rem 1.5rem;
padding-block: 1.25rem 0.25rem;
color: var(--blume-muted-foreground);
font-size: 0.75rem;
}
.recipe-directory-subbar select {
.recipe-directory-subbar p {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.75rem;
margin: 0;
}
.recipe-directory-selects {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.recipe-directory-selects label {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.recipe-directory-selects label > span {
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
font-weight: 500;
white-space: nowrap;
}
.recipe-directory-selects select {
min-inline-size: 9.5rem;
max-inline-size: 12rem;
min-block-size: 2.25rem;
background: var(--blume-background);
padding: 0.5rem;
padding: 0.375rem 0.5rem;
border: 1px solid var(--recipe-line);
border-radius: 0.375rem;
font-size: 0.75rem;
}
.recipe-clear {
color: var(--blume-foreground);
font-size: 0.75rem;
text-decoration: underline;
text-underline-offset: 0.2em;
cursor: pointer;
}
.recipe-section {
margin-block-start: 2rem;
}
.recipe-section-head {
margin-block-end: 1rem;
}
.recipe-section-head h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.recipe-section-head p {
margin-block: 0.25rem 0;
color: var(--blume-muted-foreground);
font-size: 0.8125rem;
}
.recipe-family-group + .recipe-family-group {
margin-block-start: 1.75rem;
}
.recipe-family-head {
display: flex;
align-items: baseline;
gap: 0.625rem;
margin-block-end: 0.75rem;
padding-block-end: 0.5rem;
border-block-end: 1px solid var(--recipe-line);
}
.recipe-family-head h3 {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
}
.recipe-family-head span {
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
}
.recipe-family-head a {
margin-inline-start: auto;
color: var(--blume-muted-foreground);
font-size: 0.75rem;
text-decoration: none;
}
.recipe-family-head a:hover {
color: var(--blume-foreground);
}
.recipe-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr));
gap: 1rem;
}
/* Curtain cards: identity at rest; hover or focus inverts the card and reveals the details. */
.recipe-card {
display: flex;
flex-direction: column;
display: block;
position: relative;
min-inline-size: 0;
padding: 1.25rem;
overflow: hidden;
border: 1px solid var(--recipe-line);
border-radius: 0.75rem;
background: var(--blume-background);
text-decoration: none;
color: inherit;
isolation: isolate;
}
.recipe-card-reveal {
min-block-size: 15.5rem;
block-size: 100%;
gap: 0;
}
.recipe-family-group .recipe-card-reveal {
min-block-size: 13.5rem;
}
.recipe-card-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.25rem 1.25rem 0.5rem;
}
.recipe-card-top {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.625rem;
margin-block-end: 1.25rem;
margin-block-end: auto;
padding-block-end: 0.75rem;
}
.recipe-card-top img {
inline-size: 2.5rem;
@@ -568,29 +676,48 @@
background: white;
outline: 1px solid oklch(0 0 0 / 0.1);
}
.recipe-family-group .recipe-card-top img {
inline-size: 2.25rem;
block-size: 2.25rem;
}
.recipe-card-kicker {
flex: 1;
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
flex: 1;
}
.recipe-featured {
border: 1px solid var(--recipe-line);
border-radius: 0.375rem;
border-radius: 999px;
font-size: 0.625rem;
padding: 0.25rem 0.375rem;
padding: 0.125rem 0.5rem;
}
.recipe-card h2 {
.recipe-card-title {
margin: 0;
font-size: 0.9375rem;
line-height: 1.5;
font-size: 1rem;
line-height: 1.35;
font-weight: 600;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
.recipe-card-details {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.recipe-card-description {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
margin: 0;
color: var(--blume-muted-foreground);
font-size: 0.75rem;
line-height: 1.55;
}
.recipe-tags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-block: 0.75rem 1.25rem;
}
.recipe-tags span {
font-size: 0.625rem;
@@ -599,19 +726,78 @@
background: var(--recipe-soft);
color: var(--blume-muted-foreground);
}
.recipe-card footer {
.recipe-card-modes {
margin: 0;
color: var(--blume-foreground);
font-size: 0.6875rem;
font-weight: 500;
}
/* Open curtain: the card inverts (black in light mode, white in dark) and every text color switches with it. */
.recipe-card-curtain {
z-index: 0;
mix-blend-mode: normal;
background: var(--blume-foreground);
}
.recipe-card-body > :not(.recipe-card-curtain),
.recipe-card-footer {
position: relative;
z-index: 1;
}
.recipe-card
:is(.recipe-card-kicker, .recipe-card-title, .recipe-card-description, .recipe-card-modes, .recipe-card-cta),
.recipe-card .recipe-card-footer span:first-child,
.recipe-card .recipe-tags span,
.recipe-card .recipe-featured {
transition:
color 0.2s ease-out,
background-color 0.2s ease-out,
border-color 0.2s ease-out;
}
.recipe-card-reveal[data-curtain-open] :is(.recipe-card-title, .recipe-card-modes, .recipe-card-cta) {
color: var(--blume-background);
}
.recipe-card-reveal[data-curtain-open] :is(.recipe-card-kicker, .recipe-card-description),
.recipe-card-reveal[data-curtain-open] .recipe-card-footer span:first-child {
color: color-mix(in srgb, var(--blume-background) 72%, var(--blume-foreground));
}
.recipe-card-reveal[data-curtain-open] .recipe-tags span {
background: color-mix(in srgb, var(--blume-background) 14%, var(--blume-foreground));
color: color-mix(in srgb, var(--blume-background) 80%, var(--blume-foreground));
}
.recipe-card-reveal[data-curtain-open] .recipe-card-footer {
border-color: color-mix(in srgb, var(--blume-background) 25%, var(--blume-foreground));
}
.recipe-card-reveal[data-curtain-open] .recipe-featured {
border-color: color-mix(in srgb, var(--blume-background) 40%, var(--blume-foreground));
color: var(--blume-background);
}
.recipe-card-footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-block-start: auto;
padding-block-start: 0.875rem;
margin-inline: 1.25rem;
padding-block: 0.75rem 1rem;
border-block-start: 1px solid var(--recipe-line);
font-size: 0.6875rem;
}
.recipe-card footer span:first-child {
.recipe-card-footer span:first-child {
color: var(--blume-muted-foreground);
}
.recipe-card-cta {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-weight: 500;
}
.recipe-card-cta svg {
inline-size: 0.875rem;
block-size: 0.875rem;
}
.recipe-card-grid-configurable .recipe-card {
border-block-start: 2px solid var(--blume-accent);
}
.recipe-empty {
padding-block: 2rem;
text-align: center;
@@ -946,48 +1132,46 @@
gap: 1.5rem;
align-items: start;
}
.hardware-matrix {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.25rem;
background: var(--recipe-soft);
.hardware-selected {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--recipe-line);
border-radius: 0.625rem;
padding: 0.25rem;
min-inline-size: 0;
background: var(--recipe-soft);
padding: 0.75rem;
}
.hardware-matrix label {
position: relative;
padding: 0.625rem 0.5rem;
border: 1px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
}
.hardware-matrix label[data-selected="true"] {
background: var(--blume-background);
border-color: var(--recipe-control);
}
.hardware-matrix label:has(:focus-visible) {
outline: 2px solid var(--blume-accent);
outline-offset: 1px;
}
.hardware-matrix input {
position: absolute;
opacity: 0;
inline-size: 1px;
block-size: 1px;
}
.hardware-matrix strong {
.hardware-selected-name {
display: block;
font-size: 0.75rem;
line-height: 1.4;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.4;
}
.hardware-matrix small {
.hardware-selected-meta {
display: block;
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
line-height: 1.5;
margin-block-start: 0.15rem;
}
.hardware-address-note {
margin-block: 0.35rem 0;
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
line-height: 1.5;
}
.hardware-picker-caption {
margin-block: 0.35rem 0;
color: var(--blume-muted-foreground);
font-size: 0.625rem;
margin-block-start: 0.25rem;
line-height: 1.5;
}
.hardware-scope {
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
line-height: 1.6;
margin-block: 0.5rem 0;
}
.matrix-resource-title {
display: block;
@@ -1179,9 +1363,6 @@
margin-block-start: 0.5rem;
display: block;
}
.hardware-matrix {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.matrix-advanced summary span {
display: block;
margin-inline-start: 0;
@@ -1191,7 +1372,6 @@
@media (hover: hover) {
.recipe-card:hover {
border-color: var(--blume-muted-foreground);
background: var(--recipe-soft);
}
.recipe-family-filters button:hover,
.option-chips button:hover {
@@ -1199,7 +1379,7 @@
}
}
/* Compact component experiments and custom hardware stay inside the recipe matrix. */
/* Custom hardware and catalog lookup stay inside the recipe matrix. */
.recipe-detail [data-variant="outline"] {
color: var(--blume-foreground);
background: var(--blume-background);
@@ -1219,40 +1399,182 @@
color: var(--blume-muted-foreground);
cursor: pointer;
}
.hardware-add {
border: 1px solid var(--recipe-control);
border-radius: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--blume-background);
}
.hardware-add:hover {
color: var(--blume-foreground);
border-color: var(--blume-muted-foreground);
}
.hardware-clear {
flex: none;
text-decoration: underline;
text-underline-offset: 3px;
}
.hardware-current {
margin-block-start: 0.5rem;
margin: 0;
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
}
.hardware-search {
.hardware-lookup {
display: grid;
gap: 0.5rem;
gap: 0.625rem;
margin-block-start: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--recipe-line);
border-radius: 0.625rem;
background: var(--blume-background);
}
.hardware-lookup > input {
font-size: 0.75rem;
}
.hardware-search p {
font-size: 0.6875rem;
color: var(--blume-muted-foreground);
line-height: 1.6;
.hardware-result-list {
display: grid;
gap: 0.25rem;
max-block-size: 18rem;
overflow-y: auto;
}
.hardware-search button {
justify-self: start;
min-block-size: 2.25rem;
border: 1px solid var(--recipe-control);
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
.hardware-result {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
}
.hardware-search button:disabled {
.hardware-result:hover {
background: var(--recipe-soft);
}
.hardware-result[data-selected="true"] {
border-color: var(--recipe-control);
background: var(--recipe-soft);
}
.hardware-result:has(:focus-visible) {
outline: 2px solid var(--blume-accent);
outline-offset: 1px;
}
.hardware-result input {
margin-block-start: 0.15rem;
accent-color: var(--blume-foreground);
}
.hardware-result strong {
display: block;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.4;
overflow-wrap: anywhere;
}
.hardware-result small {
display: block;
color: var(--blume-muted-foreground);
font-size: 0.625rem;
line-height: 1.5;
margin-block-start: 0.125rem;
}
.hardware-lookup-status {
display: grid;
gap: 0.25rem;
margin: 0;
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
line-height: 1.6;
}
.hardware-lookup-status span {
font-size: 0.625rem;
}
.hardware-retry,
.hardware-manual-toggle {
justify-self: start;
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.hardware-form {
display: grid;
gap: 0.75rem;
padding-block-start: 0.75rem;
border-block-start: 1px solid var(--recipe-line);
}
.hardware-form-title {
margin: 0;
font-size: 0.75rem;
font-weight: 500;
}
.hardware-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.625rem;
}
.hardware-form-wide {
grid-column: 1 / -1;
}
.hardware-form-field {
display: grid;
gap: 0.25rem;
min-inline-size: 0;
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
}
.hardware-form-field select {
min-block-size: 2.25rem;
max-inline-size: 100%;
padding: 0.375rem 0.5rem;
border: 1px solid var(--recipe-control);
border-radius: 0.5rem;
background: var(--blume-background);
color: var(--blume-foreground);
font-size: 0.75rem;
}
.hardware-form-error {
margin-block: 0.5rem 0;
color: var(--blume-destructive, #b42318);
font-size: 0.6875rem;
line-height: 1.5;
}
.hardware-form-note {
margin: 0;
color: var(--blume-muted-foreground);
font-size: 0.625rem;
line-height: 1.6;
}
.hardware-form-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.hardware-form-actions button {
min-block-size: 2.25rem;
padding: 0.375rem 0.75rem;
border: 1px solid var(--recipe-control);
border-radius: 0.5rem;
background: var(--blume-background);
color: var(--blume-foreground);
font-size: 0.6875rem;
cursor: pointer;
}
.hardware-form-actions button[type="submit"] {
border-color: var(--blume-foreground);
background: var(--blume-foreground);
color: var(--blume-background);
}
.hardware-form-actions button:disabled {
opacity: 0.5;
cursor: default;
}
.hardware-matrix strong {
overflow-wrap: anywhere;
@media (max-width: 40rem) {
.hardware-selected {
flex-direction: column;
gap: 0.5rem;
}
.hardware-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.release-intro {
font-size: 0.875rem;
+61
View File
@@ -858,6 +858,67 @@
"install": "Install FastVideo following the DGX Spark installation guide.",
"audio": true
}
},
{
"id": "fasth3-omniref-rtx5090",
"family": "minimax_h3",
"stage": "inference",
"task": "Reference media to video with audio",
"label": "FastH3 OmniRef on RTX 5090",
"summary": "Eight-forward video and audio from a prompt plus ordered image, video, and audio references, with the NVFP4 stack.",
"model": "FastVideo/FastH3-OmniRef-NVFP4-QAD",
"source": "examples/inference/basic/basic_fasth3_omniref_pdd.py",
"command": "python examples/inference/basic/basic_fasth3_omniref_pdd.py --model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --profile rtx-5090 --image assets/images/couple.jpg --prompt 'A person on a sofa, lit by a warm lamp, speaking to camera.'",
"gpu_types": [
"NVIDIA"
],
"hardware": {
"platform": "cuda",
"gpu_count": 1,
"evidence": "source-configured"
},
"evidence": "Source-backed"
},
{
"id": "fasth3-omniref-rtx-pro6000",
"family": "minimax_h3",
"stage": "inference",
"task": "Reference media to video with audio",
"label": "FastH3 OmniRef on RTX PRO 6000",
"summary": "Eight-forward video and audio from a prompt plus ordered image, video, and audio references, with the NVFP4 stack.",
"model": "FastVideo/FastH3-OmniRef-NVFP4-QAD",
"source": "examples/inference/basic/basic_fasth3_omniref_pdd.py",
"command": "python examples/inference/basic/basic_fasth3_omniref_pdd.py --model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --profile rtx-pro-6000 --image assets/images/couple.jpg --prompt 'A person on a sofa, lit by a warm lamp, speaking to camera.'",
"gpu_types": [
"NVIDIA"
],
"hardware": {
"platform": "cuda",
"gpu_count": 1,
"evidence": "source-configured"
},
"evidence": "Source-backed"
},
{
"id": "fasth3-omniref-spark",
"family": "minimax_h3",
"stage": "inference",
"task": "Reference media to video with audio",
"label": "FastH3 OmniRef on DGX Spark",
"summary": "Eight-forward video and audio from a prompt plus ordered image, video, and audio references, with the NVFP4 stack.",
"model": "FastVideo/FastH3-OmniRef-NVFP4-QAD",
"source": "examples/inference/basic/basic_fasth3_omniref_pdd.py",
"command": "python examples/inference/basic/basic_fasth3_omniref_pdd.py --model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --profile spark --image assets/images/couple.jpg --prompt 'A person on a sofa, lit by a warm lamp, speaking to camera.'",
"gpu_types": [
"NVIDIA"
],
"hardware": {
"platform": "cuda",
"gpu_count": 1,
"device": "spark",
"evidence": "source-configured"
},
"evidence": "Source-backed"
}
]
}
+112 -10
View File
@@ -49,7 +49,7 @@ node docs/js/build-cookbook-capacity.mjs --check
node docs/js/build-cookbook-capacity.mjs --preview fastwan21
```
The check currently prints `Validated 1 capacity model(s) and 4 hardware variant(s).`
The check currently prints `Validated 1 capacity model(s) and 50 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
@@ -68,7 +68,7 @@ Commit authored sources; generated assets are build output and must not be hand-
| Model `capacity` | `memory_rules`, nonempty `components` | `sources`, `reference_runs` |
| `components.<role>` | `ref` | `alternatives` (other registered refs) |
| Shared component | `name`, `parameter_count`, `sources` | `block_count` + `block_parameter_count`, `loaded_subset`, `geometry`; VAE-only `decode_feature_multiplier` |
| Hardware variant | `name`, `memory_type`, `memory_gb`, `backend`, `sources` | `ui_id`, `supported_dtypes`, `notes` |
| Hardware variant | `name`, `vendor`, `kind`, `memory`, `backend`, `sources` | `aliases`, `family` + `variant`, `form_factor`, `gpu_count`, `host_memory`, `partitionable`, `partitions`, `supported_dtypes`, `notes` |
Roles are `transformer`, `transformer_2`, `text_encoder`, `text_encoder_2`,
`image_encoder`, `vae`, or `audio_vae`; refs must match the role's component type.
@@ -214,12 +214,14 @@ a fixed memory discount from `vae_tiling: true`, or assume this mode uses less
memory than the default cached decoder. Other model families can use different
tiling controls. This setting flow was checked in code, not by GPU generation.
Hardware IDs describe variants such as H100 SXM 80 GB. `memory_gb` is nominal
**decimal GB per device** (`1 GB = 1e9 bytes`); convert to GiB with
`memory_gb * 1e9 / 1024**3`. Use `memory_type: dedicated` for discrete VRAM or
Hardware IDs describe variants such as H100 SXM 80 GB. The authored
`memory.nominal` preserves `GB` or `GiB`; the build exports normalized decimal
`memory_gb` (`1 GB = 1e9 bytes`), converted to GiB with
`memory_gb * 1e9 / 1024**3`. Use `memory.type: dedicated` for discrete VRAM or
`unified` for capacity shared with the CPU. Neither means measured free memory.
`supported_dtypes` describes hardware format capability, not FastVideo kernel
support; omission means unknown. `ui_id` connects a variant to an existing UI choice.
support; omission means unknown. `aliases` connect a variant to existing UI IDs
and search names.
Use checkpoint `model_index.json`/component `config.json` files, tensor headers
or indexes, and the FastVideo loader to establish composition, counts, and geometry.
@@ -232,8 +234,105 @@ names do not establish that components can be shared. Offline scripts or agents
may prepare candidate metadata for review; builds and browser loaders do not
run them, import model code, or download checkpoints.
## Hardware catalog
[`hardware.yaml`](../cookbook/capacity/hardware.yaml) is the authored lookup
catalog. It records device specifications, not a runtime support matrix: a
searchable variant can still be unsupported by a model, backend, or kernel.
Every entry requires a vendor source URL and the date it was checked. The
[coverage checklist](../cookbook/capacity/hardware-coverage.md) tracks what is
present, what is next, and what is deliberately excluded. The catalog is for
discovery and reuse, not a prerequisite for running a model: a missing variant
uses the custom-device path, and `unknown` means no estimate, not that FastVideo
cannot attempt the recipe.
```yaml
h100-sxm-80gb:
name: NVIDIA H100 SXM 80 GB
vendor: nvidia
kind: gpu
family: nvidia-h100
variant: SXM 80 GB
form_factor: sxm
memory:
type: dedicated
nominal: { value: 80, unit: GB }
backend: cuda
partitionable: true
partitions:
- id: 1g.10gb
label: 1g.10gb
memory: { value: 10, unit: GB }
supported_dtypes: [fp32, fp16, bf16, fp8]
aliases: [h100]
sources:
- url: https://www.nvidia.com/en-us/data-center/h100/
checked: 2026-10-19
```
`memory.type` is `dedicated` or `unified`. `memory.nominal` preserves the
vendor's value and unit (`GB` or `GiB`). Unified memory is shared system memory,
not VRAM; do not describe it as a GPU-only allocation. Add `memory.usable` plus a
`memory.policy` only when a documented policy derives an application budget.
Without that policy the estimator uses a labeled 75% application default
(`DEFAULT_UNIFIED_BUDGET_FRACTION`); `memoryBudgetGiB` overrides it. Nominal
unified memory is never treated as available VRAM, and the assumption is
reported in `memory.budgetSource` and the result notes.
`family` groups exact `variant` records, so board, form-factor, and capacity
differences stay separate entries. `partitions` list selectable MIG/vGPU-style
allocations; selecting one never silently inherits full-card capacity. `gpu_count`
describes the device record and does not authorize summing GPUs into one pool.
`host_memory` is host RAM for CPU offload, separate from dedicated VRAM and from
unified memory. `aliases` preserve old links and search names; canonical IDs stay
stable.
Custom devices use the same memory fields and must declare `backend` and
`supported_dtypes`. They are not catalog-verified:
```javascript
const custom = {
id: "custom",
name: "Custom accelerator",
custom: true,
memory: { type: "dedicated", nominal: { value: 48, unit: "GB" } },
backend: "cuda",
supported_dtypes: ["fp32", "fp16", "bf16", "fp8"],
};
const result = estimateMemory({
config,
workload,
model,
hardware: custom,
memoryBudgetGiB: 40, // optional application cap, in GiB
partitionId: "1g.10gb", // optional catalog partition
allocationMemoryGb: 10, // optional provisioned allocation, in GB
hostMemoryGb: 64, // optional host RAM for offload
});
```
An unknown backend or missing memory type/capacity returns `unknown`. A missing
format list is accepted for fp32/fp16/bf16 storage and called out as an
assumption; fp8 or NVFP4 storage still returns `unknown` because capability was
not cataloged. The estimate
reports `memory` (type, nominal, budget, partition) and `host` (required,
capacity, status) separately from `capacityGiB` and `status`.
The generated `hardware.json` is versioned:
```json
{ "version": 1, "devices": [{ "id": "h100-sxm-80gb", "...": "..." }] }
```
Values keep authored aliases, normalized decimal `memory_gb`, optional
`usable_memory_gb` / `host_memory_gb`, and normalized partition capacities.
## Optional successful-run evidence
HTTP/OpenAPI schema compatibility is a separate contract; see
[serving documentation](serving_documentation_contract.md). A capacity estimate
and a completed generation do not establish HTTP client compatibility.
Add records inline under the manifest's `capacity.reference_runs`. One completed
generation is useful; benchmarking every configuration is unnecessary. Follow the
[serving guide](../cookbook/openai-api.md) with the FastWan baseline and set the
@@ -246,6 +345,7 @@ load alone is insufficient. Record the effective request, including inherited de
| --- | --- |
| `id`, `date` | Unique run ID within the model; date as `YYYY-MM-DD` |
| `hardware`, `gpu_count` | Hardware catalog ID and actual device count |
| `hardware_partition`, `hardware_allocation_gb` | Optional MIG/vGPU partition ID and provisioned allocation in decimal GB; a full-card run does not apply to a partition |
| `outcome`, `execution` | `completed`; `first_request` or `warm_request` |
| `evidence_url` | Accessible HTTP(S) logs or benchmark evidence |
| `context.runtime`, `context.deployment` | Serving runtime ID and the cookbook deployment ID used for the run; the deployment must exist and use that runtime |
@@ -313,7 +413,7 @@ also re-exported by the shared cookbook API/bundle:
| --- | --- |
| `loadCapacityIndex(url, options?)` | Index with model summaries, `hardware_url`, and resolved source `url` |
| `loadCapacityModel(index, id, options?)` | Model with components mapped by role, examples, and `reference_runs` |
| `loadCapacityHardware(index, options?)` | Hardware mapping keyed by ID |
| `loadCapacityHardware(index, options?)` | `{version: 1, devices, url}` with stable IDs and aliases |
Start with the generated index URL, then pass the returned index to the other
loaders. Each selected component is expanded with its `ref` and geometry;
@@ -328,6 +428,8 @@ required structure/identity, or unknown model IDs. There is no module-level cach
These loaders fetch static facts; the calculator must resolve execution settings
and check rule coverage before estimating memory or recommending hardware.
For detailed return shapes and the proposed PR #1978 adapter, see the
[temporary API contract](../cookbook/capacity-api-contract.review.md).
Delete that handoff and this link when integration lands, retaining stable API behavior here.
`assessCapacity()` combines the analytical estimate with applicable measured
evidence; `matchEvidence()` reports applicable, unverified, and rejected runs;
`recommendCapacity()` returns measured or analytical fits and keeps unknown
results explicit. No successful generation is treated as a minimum-VRAM
measurement, and allocator-only peaks never establish device fit.
+58 -21
View File
@@ -332,56 +332,93 @@ response alone does not establish generation readiness.
## Memory estimates
`estimateMemory()` in `docs/js/cookbook-memory.mjs` estimates one request's peak
memory on one GPU from a resolved configuration. It is analytical: a result is
not a measurement and never marks a recipe as verified.
memory on one device from a resolved configuration. It is analytical: a result
is not a measurement and never marks a recipe as verified.
```javascript
const hardware = await api.loadCapacityHardware(index);
const { config } = api.resolveConfig(catalog, edits);
const result = api.estimateMemory({ config, workload: catalog.workload, model, hardware });
const result = api.estimateMemory({
config,
workload: catalog.workload,
model,
hardware: hardware.devices.find((device) => device.id === "rtx-5090-32gb"),
memoryBudgetGiB: 28, // optional override; unified memory otherwise uses a labeled 75% default
});
```
Each phase (encoding, denoising, decoding) adds the weights resident on the GPU
to that phase's working memory. The largest phase plus headroom (10% and 1 GiB
by default) is compared with the device memory. `status` is `fits`, `exceeds`,
`unsupported` when the device lacks a weight type, or `unknown` with a `reason`.
The result also reports each phase's weights and working memory in GiB.
Each phase (`startup`, `encoding`, `denoising`, `decoding`) adds the weights
resident on the device to that phase's working memory. The largest phase plus
headroom (10% and 1 GiB by default) is compared with the device budget. `status`
is `fits`, `exceeds`, `unsupported` when the device lacks a weight type, or
`unknown` with a `reason`. The result reports each phase's weights and working
memory in GiB, plus `capacityGiB`, `memory`, `host`, and `hardware`.
| Model field | Meaning |
| --- | --- |
| `memory_rules` | Working-memory rule; `wan` is the only rule |
| `geometry` | For `wan`: `temporal_compression`, `spatial_compression`, `patch_size`, `hidden_size`, `ffn_size` and `vae_decoder_channels` |
| `components[].role` | `text_encoder`, `transformer` or `vae` |
| Shared transformer geometry | For `wan`: `hidden_size`, `ffn_size`, and `patch_size` |
| Shared VAE geometry | `temporal_compression`, `spatial_compression`, and `vae_decoder_channels` |
| `components[].role` | `text_encoder`, `transformer`, `image_encoder`, or `vae` |
| `components[].parameter_count` | Parameters of the loaded subset |
| `components[].dtype` | Weight precision; optional `activation_dtype` sets the compute precision |
| `components[].block_count`, `block_parameter_count` | Repeated blocks and their total parameters, for streaming offload |
| `components[].streams_when_offloaded` | The text encoder streams its blocks through FSDP when offloaded |
| `components[].quantized_parameter_count` | Transformer parameters stored in the requested quantized format |
A hardware entry has `name`, `type: discrete_gpu`, `memory_per_device` with a
`value` and a `GiB` or `GB` unit, and `supported_dtypes`.
Shared component records contain no selected dtype or offload setting; those
come from the resolved serving configuration. A model manifest references
shared entries instead of copying counts:
```yaml
capacity:
memory_rules: wan
components:
transformer: { ref: wan2.1-t2v-1.3b-dit }
text_encoder: { ref: umt5-xxl-encoder }
vae: { ref: wan2.1-vae-decoder }
```
The hardware lookup is versioned and source-backed; see the
[capacity guide](capacity_estimation.md#hardware-catalog). Dedicated memory uses
nominal or selected-partition capacity. Unified memory is shared with the CPU and
OS, so the estimator uses `memoryBudgetGiB`, a catalogued `memory.usable` policy,
or a labeled 75% default, and never treats nominal system memory as VRAM.
`host_memory` and `hostMemoryGb` describe separate host RAM for CPU offload; the
result reports a
host requirement and status independently from device fit. Custom devices use
the same fields and should set `custom: true`; they are not catalog-verified.
Offload follows the native runtime:
- Unset offload switches are on (`OffloadConfig` in `fastvideo/api/schema.py`).
Layerwise DiT offload replaces whole-model DiT offload.
- An offloaded component leaves the GPU outside its own phase and is complete
- An offloaded component leaves the device outside its own phase and is complete
during it. Layerwise DiT offload and FSDP text-encoder offload instead keep
the weights outside the blocks plus two blocks.
- Without FSDP, whole-model DiT offload leaves the transformer on the GPU.
- Without FSDP, whole-model DiT offload leaves the transformer on the device in
every request phase, not only denoising.
- `FASTVIDEO_LAYERWISE_RESIDENT_BLOCKS` keeps that many transformer blocks on
the device; the estimate counts them in addition to two streamed blocks during
denoising and as resident outside it.
- Unified-memory CUDA devices disable every CPU-offload flag at runtime; the
estimate applies that policy instead of subtracting offloaded weights.
- Packed NVFP4 weights are buffers, so layerwise offload keeps them resident.
- `lazy_module_load` defers only the components a pipeline lists in
`_lazy_module_names`. Wan lists none, so the switch changes nothing.
Multiple GPUs, FSDP, unified memory, image-to-video, more than one video per
prompt, missing parameter counts, text-encoder quantization and loading peaks
return `unknown`. With VAE tiling enabled, a note says the
decode estimate assumes untiled decoding.
Multiple GPUs, FSDP, VSA, compilation, quantization, lazy loading, image-to-video,
more than one video per prompt, missing parameter counts, and unsupported
attention backends return `unknown`. A disabled VAE feature cache also returns
`unknown` because the full-clip decode path has no rule. Unified memory without
an application budget uses the labeled 75% default. With VAE tiling enabled, a
note says the decode estimate assumes untiled decoding.
`WAN_RULE` holds the uncalibrated tensor counts. To calibrate them, run with
`FASTVIDEO_STAGE_LOGGING=1`: each stage then records `peak_allocated_mb` and
`peak_reserved_mb` and resets the peak counter. The request-level
`peak_memory_mb` is read after those resets, so it does not cover earlier
stages. Allocator figures exclude the CUDA context, which the headroom covers.
Successful-run evidence and analytical fit stay separate; see
`assessCapacity()`, `matchEvidence()`, and `recommendCapacity()` in the
[capacity guide](capacity_estimation.md#optional-successful-run-evidence).
To add a model family, write a function that returns working-memory bytes per
phase, register it in `WORKING_MEMORY` with its required geometry fields, and
@@ -0,0 +1,123 @@
# Serving documentation contract
This page defines the contributor-facing boundary between the HTTP schema, runtime
capabilities, and evidence. It is a handoff contract for the server and docs-site
owners; it is not an HTTP reference and does not restate endpoint behavior.
## Ownership
- **Server owner:** FastAPI routes, Pydantic request/response models, error
envelopes, status codes, authentication, streaming, and the generated OpenAPI
document.
- **Docs-site owner:** the HTTP reference route, copyable client examples, and
reference navigation. Keep it separate from the Python SDK/CLI reference.
- **Capacity owner:** hardware identity, memory estimates, and evidence
applicability. These fields never prove HTTP or model capability.
OpenAPI is a schema description. It does not establish OpenAI API or SDK
behavioral compatibility. Claim compatibility only for an exact endpoint,
client and version, payload, response, and error behavior that has a test or a
recorded run.
## Generated schema
Generate the HTTP schema from the application's actual routes and Pydantic
models and commit a deterministic export for review. Do not maintain a parallel
hand-written schema.
The export/check path must not start the application lifespan, load model
weights, initialize CUDA/MLX, require a GPU, bind a port, or fetch a remote
source. Inspect imports before claiming that property; moving an import can
reintroduce a lifespan or hardware dependency. Do not publish local paths,
credentials, or private deployment URLs in the export or examples.
The current server returns request-validation failures as HTTP 400 with its
OpenAI-style error envelope. Document those actual responses. Do not copy
FastAPI's default 422 schema when the mounted application does not return it.
## Workload IDs
Use these shared IDs in runtime `workloads`, recipe/deployment `workload`, and
capability rows. The shared media map lives in
`docs/js/cookbook-workloads.mjs`; the catalog builder and sample client use it as
the single workload allow-list. The server owner implements endpoints and the
docs-site owner renders from the declared input/output media rather than
assuming video.
| ID | Input | Output | Minimal semantics |
| --- | --- | --- | --- |
| `t2v` | text | video | Text prompt to a video clip |
| `i2v` | image + text | video | Image reference drives the first frame or motion |
| `t2i` | text | image | Text prompt to one still image |
| `t2a` | text | audio | Text prompt to an audio clip |
| `v2a` | video + text | audio | Video soundtrack, speech, or audio response |
The map records the canonical sample endpoint, extension, and media type used
by cookbook examples; it does not prove that a model or deployment supports the
workload. Do not infer support from an ID. The capability row must name the
actual operation and media type, and unsupported combinations stay
`unsupported` or `unknown` rather than falling back to a video route.
## Capability matrix
Schema presence does not mean every model supports an operation. Serving docs
must publish a matrix with stable fields:
| Field | Meaning |
| --- | --- |
| `model_id` | Checkpoint identity used by the deployment |
| `deployment_id` | Canonical cookbook deployment (`<model>/<deployment>`) |
| `workload` | Shared workload ID and its input/output media |
| `runtime` | FastVideo runtime/adapter identifier |
| `endpoint` | Method and path |
| `operation` | Create, poll, download, cancel, list, or delete |
| `status` | `supported`, `unsupported`, or `unknown` |
| `constraint` | Required option, model limitation, or admission rule |
| `evidence` | Link and kind, with revisions; see below |
Mark FastVideo extensions explicitly. State unsupported and unknown operations
instead of implying parity. Cover success and error envelopes, multipart and
binary responses, and the implemented job lifecycle (create, poll, download,
cancel/delete). Document authentication as configured. Call out streaming and
WebSocket limitations rather than describing a capability the server does not
mount.
## Evidence
Use three distinct labels:
- `schema`: the route and shapes exist in the generated OpenAPI document.
- `contract_test`: a focused test exercises the request/response or client path.
- `measured_generation`: a recorded run produced the media on named hardware.
Only the last label supports a GPU-validated generation claim. A schema check,
unit test, or successful process start does not establish output quality, model
capability, or memory fit. Link the revision and the exact client version with
any compatibility claim.
Capacity evidence follows the same rule: a completed generation is
compatibility evidence, while a device-wide memory measurement is required for a
measured fit. See [capacity estimation](capacity_estimation.md#optional-successful-run-evidence)
for the memory-evidence schema.
## User documentation
Start from a maintained cookbook recipe and a selected hardware/runtime
combination, then show the first generation with a matching cURL, OpenAI Python,
or OpenAI JavaScript example where that path is tested. Follow with polling,
download, and troubleshooting. Keep in-process FastVideo SDK/CLI examples
separate so readers do not confuse them with HTTP clients.
A static documentation site must remain useful without a running inference
server. A browser "Try it" control is optional and may only target a
user-selected reachable server within that server's actual CORS, authentication,
and HTTPS policy. Do not add a shared public inference endpoint, a hosted
request proxy, a broad CORS exception, or an authentication bypass for a
tutorial.
## Contributor checks
Update the model/deployment/runtime capability row when adding serving support.
Include the generated schema, focused contract/client tests, link checks, and
the evidence links. CI should detect a stale export and check meaningful
request, response, and client behavior independently of the schema diff.
@@ -1,261 +0,0 @@
# Temporary capacity API contract for calculator integration
**Temporary review handoff.** This document describes the current static loaders
for authors integrating the calculator proposal in
[PR #1978](https://github.com/hao-ai-lab/FastVideo/pull/1978/changes).
It adds no calculator API. **TODO: delete this exact file,
`docs/cookbook/capacity-api-contract.review.md`, and its discoverability link in
`docs/contributing/capacity_estimation.md` when integration lands and essential
stable API behavior has moved into that contributor guide.** Keep the API
modules, authored metadata, and catalog generation.
The [contributor guide](../contributing/capacity_estimation.md) owns authoring and
recorded-run requirements. The implementation is
[`cookbook-capacity.mjs`](../js/cookbook-capacity.mjs); generated JSON is build
output, not a second source to edit.
## Load index → model → hardware
The capacity module is pure ESM with no DOM, inference/runtime, or estimator
imports. From the repository root in an ESM consumer:
```javascript
import {
loadCapacityIndex,
loadCapacityModel,
loadCapacityHardware,
} from "./docs/js/cookbook-capacity.mjs";
const options = { signal: new AbortController().signal };
const index = await loadCapacityIndex(
"https://haoailab.com/FastVideo/assets/cookbook-config/capacity/index.json",
options,
);
const model = await loadCapacityModel(index, "fastwan21", options);
const hardware = await loadCapacityHardware(index, options);
const h100 = hardware["h100-sxm-80gb"];
```
These three exports are also re-exported by
[`cookbook-config.mjs`](../js/cookbook-config.mjs). For a browser using the shared
bundle, load `/FastVideo/assets/cookbook-config/cookbook-config.js`, then use
`FastVideoConfigCookbook.loadCapacityIndex`, `.loadCapacityModel`, and
`.loadCapacityHardware`. Adjust the site prefix when hosted elsewhere.
Generate assets before serving them; the loaders do not build missing files.
Use your preview site's origin while these assets are not yet published.
| Function | Promise result |
| --- | --- |
| `loadCapacityIndex(url, {fetcher, signal} = {})` | Index JSON with a resolved source `url` added |
| `loadCapacityModel(index, id, {fetcher, signal} = {})` | One self-contained model record |
| `loadCapacityHardware(index, {fetcher, signal} = {})` | Hardware mapping keyed by hardware ID |
`fetcher` defaults to `globalThis.fetch`; it is called as
`fetcher(url, {signal})`. A custom fetcher must return a response with `ok`,
`status` for errors, `json()`, and preferably `url`. Each call receives its own
options; options passed to the index call are not saved for later calls.
The index's `url` uses `response.url` when present, including after redirects.
Otherwise it resolves the requested URL against `globalThis.location?.href`.
Use an absolute request URL outside a browser when a custom response lacks
`url`. Model and hardware paths resolve relative to `index.url` with `new URL`.
Pass the returned index to later loaders; raw index JSON lacks this source URL.
Failures reject the promise. A non-OK response throws
`Could not load capacity metadata: HTTP <status>`; fetch and JSON errors
propagate. The signal is forwarded to the fetcher and checked after fetching
and after parsing JSON, preserving cancellation even with a custom fetcher.
Invalid shapes/identities also reject. There is no module-level cache, retry,
or fallback; ordinary fetch cache behavior still applies.
Browser validation checks the loader's required structure; full authoring
validation belongs to the catalog build.
## Returned data
The generated [index](../assets/cookbook-config/capacity/index.json) currently
contains one participating model:
```javascript
{
hardware_url: "hardware.json",
models: [{
id: "fastwan21",
name: "FastWan2.1 1.3B",
model_id: "FastVideo/FastWan2.1-T2V-1.3B-Diffusers",
catalog_url: "models/fastwan21.json",
}],
url: "https://haoailab.com/FastVideo/assets/cookbook-config/capacity/index.json",
}
```
`id` is the cookbook manifest key; `model_id` is the checkpoint identity.
Models whose manifests omit optional `capacity` remain valid cookbook entries
but do not appear in this index. An unknown `id` rejects with
`Unknown capacity model <id>` before fetching a model.
The [FastWan model](../assets/cookbook-config/capacity/models/fastwan21.json)
returns `id`, `name`, `model_id`, `memory_rules`, `components`, `examples`,
`reference_runs`, and optional model `sources`. `components` is a **mapping by
execution role**, not an array. Each selected shared component has already
been expanded, retaining its `ref`:
| Role | `ref` | `kind` | `parameter_count` |
| --- | --- | --- | ---: |
| `transformer` | `fastwan2.1-t2v-1.3b-vsa-dit` | `transformer` | 1,489,821,760 |
| `text_encoder` | `umt5-xxl-encoder` | `encoder` | 5,680,910,336 |
| `vae` | `wan2.1-vae-decoder` | `vae` | 73,296,659 |
Components also have `name`, `sources`, and optional `block_count`,
`block_parameter_count`, `loaded_subset`, `geometry`, and VAE-only
`decode_feature_multiplier`. Counts are numbers
for the loaded subset. The transformer has 30 blocks with 1,464,046,080 block
parameters; its geometry includes `hidden_size: 1536`, `ffn_size: 8960`,
`patch_size: [1, 2, 2]`, and `gated_ffn: false`. The text encoder records weight
and block counts and its loaded subset, with no geometry object. Detailed
encoder geometry remains optional for future activation rules. VAE geometry is:
```javascript
{
temporal_compression: 4,
spatial_compression: 8,
latent_channels: 16,
output_channels: 3,
vae_decoder_channels: 96,
}
```
Compression determines the latent grid. Optional `latent_channels` sizes the
compressed buffer; `output_channels` counts final decoded channels, not raw
convolution channels before any unpatching. Neither changes video-token count.
Decoder width scales decode working memory. The VAE exports an optional
top-level `decode_feature_multiplier: 48`, a positive finite number supplying
an **uncalibrated feature/cache heuristic** borrowed from PR #1978. It means
equivalent internal feature buffers, not 48 RGB frames or an architecture-config
fact. The builder preserves omission rather than supplying a default. See the
[decode feature multiplier](../contributing/capacity_estimation.md#vae-decode-feature-multiplier)
for its approximate formula and limits. Output/merge buffers and resident
weights remain separate. Actual batch, size, precision, cache/tiling mode,
buffer placement, and workspace still need execution-specific rules; catalog
loading does not resolve them or establish full VAE peak coverage.
See the
[authoring reference](../contributing/capacity_estimation.md#authoring-reference)
for field meanings and validation. Tile/cache/precision selections remain
execution inputs from native configuration and runtime defaults, not component
geometry. Optional `alternatives`, when authored, are expanded component objects;
the loader does not select one.
Each manifest deployment becomes an example. Its `defaults` YAML is resolved
into `base_config`; its launch environment becomes `env`. FastWan currently has:
```javascript
model.examples[0] // selected fields; base_config remains the nested native config
// {
// id: "cuda-rest",
// deployment_id: "fastwan21/cuda-rest",
// config: "examples/serving/openai_fastwan21_1_3b.yaml",
// runtime: "fastvideo-cuda-rest",
// env: { FASTVIDEO_ATTENTION_BACKEND: "VIDEO_SPARSE_ATTN" },
// base_config: { generator: {...}, server: {...}, default_request: {...} }
// }
```
The exported baseline includes `generator.engine.compile.enabled: true`,
`generator.pipeline.experimental.VSA_sparsity: 0.8`, and
`generator.pipeline.experimental.dmd_denoising_steps: [1000, 757, 522]`.
`base_config` preserves the authored baseline; it is **not a fully
runtime-resolved configuration** or a completed-generation measurement.
`reference_runs` is optional when authoring; the builder normalizes omission to
`[]`, and model loader results always require an array. FastWan currently has
`reference_runs: []`. Future records can carry completed-generation evidence,
execution context, and optional per-rank memory measurements. Use the
contributor guide's schema and applicability rules; absence means no recorded
evidence, not zero memory usage.
The [hardware catalog](../assets/cookbook-config/capacity/hardware.json) is a
mapping, for example:
```javascript
hardware["h100-sxm-80gb"]
// {
// name: "NVIDIA H100 SXM 80 GB", ui_id: "h100",
// memory_type: "dedicated", memory_gb: 80, backend: "cuda",
// supported_dtypes: ["fp32", "fp16", "bf16", "fp8"],
// sources: [...], notes: "..."
// }
```
`memory_gb` is nominal **decimal GB per device** (`1 GB = 1e9 bytes`), not free
memory. Convert to GiB using `memory_gb * 1e9 / 1024**3`. Recorded-run memory
uses `unit: "GiB"`; its `torch_peak_allocated`, `torch_peak_reserved`, and
`device_used` metrics have different scopes. `memory_type` is `dedicated` or
`unified`; unified capacity is shared with the CPU. Optional
`supported_dtypes` describes hardware format capability, **not FastVideo kernel
support**. Missing capability metadata means unknown.
## Illustrative bridge to PR #1978
The following is **consumer-side illustration, not an implemented/exported
adapter**. It illustrates the proposal's flat geometry/component array and hardware
shape at [revision 349cc0b](https://github.com/Ishxn20/FastVideo/blob/349cc0b184ffcb10027234bd86b53316a93f580b/docs/js/cookbook-memory.mjs#L168),
passing through optional component metadata for later rules. This does not
implement consumption of `decode_feature_multiplier` in that proposal.
Do not pass this output to an estimator until its coverage has been checked.
`runtimePrecision` must be supplied from actual execution resolution; never
infer weight or activation precision from parameter counts or geometry.
```javascript
function illustrateInputs(model, device, runtimePrecision) {
if (device.memory_type !== "dedicated")
throw new Error("Unified memory requires an explicit calculator policy");
const components = Object.entries(model.components).map(([role, component]) => {
const precision = runtimePrecision[role];
if (!precision?.dtype || !precision?.activation_dtype)
throw new Error(`Resolve actual storage and activation precision for ${role}`);
return {
role,
parameter_count: component.parameter_count,
block_count: component.block_count,
block_parameter_count: component.block_parameter_count,
geometry: component.geometry,
decode_feature_multiplier: component.decode_feature_multiplier,
dtype: precision.dtype,
activation_dtype: precision.activation_dtype,
};
});
return {
model: {
memory_rules: model.memory_rules,
geometry: { ...model.components.transformer.geometry, ...model.components.vae.geometry },
components,
},
hardware: {
name: device.name,
type: "discrete_gpu",
memory_per_device: { value: device.memory_gb, unit: "GB" },
supported_dtypes: device.supported_dtypes,
},
};
}
```
When supplied, per-component geometry preserves encoder dimensions without
colliding with transformer dimensions in the flat model geometry. The initial
encoder inventory contains weight counts only; an approximate encoding-stage
allowance must be explicit rather than deriving zero activations from absent
geometry. Detailed encoder rules can consume optional geometry later.
This illustration is specific to the shown Wan composition. Quantized parameter
subsets, retained original weights, streaming/offload behavior, batching,
topology, cache/tiling modes, backend workspaces, and actual tensor lifetimes
require execution-specific accounting. A selected dtype alone does not cover
these allocations. The later calculator also needs effective configuration,
request/workload, and launch environment; this bridge does not resolve them.
`memory_rules: "wan"` identifies a rule family; it does not validate kernels or
prove that a particular estimator covers this variant. FastWan VSA has extra
gate weights beyond ordinary Wan, already included in the transformer count.
Its VSA workspace, compile buffers, and DMD execution schedule also need explicit
coverage. The initial ordinary-Wan rule must return unsupported/unknown for
uncovered FastWan execution; matching geometry is insufficient. No working
calculator integration, hardware recommendation, or fit guarantee is added here.
@@ -0,0 +1,88 @@
# Hardware coverage checklist
This checklist tracks the hardware lookup catalog, not FastVideo runtime or kernel
support. A searchable device can still be unsupported by a model, backend, or
kernel. Capacity is never inferred from a marketing family name.
Batch 1 is the reviewable foundation in
[hardware.yaml](hardware.yaml): **52 variants across 40 families** — 31 NVIDIA,
6 AMD, 3 Intel, and 12 Apple entries; 38 dedicated-memory devices and 14
unified-memory systems. Every entry has a vendor source URL and a checked date.
## Batch 1 — shipped in this change
Priority order: devices named by existing recipes/baselines and the previous
picker, then common consumer, workstation, and data-center variants.
| Priority | Family | Variants | Memory | Why now |
| --- | --- | --- | --- | --- |
| 1 | NVIDIA H100 | SXM 80 GB, NVL 94 GB | Dedicated | Recipe/baseline anchor; SXM MIG profiles |
| 1 | NVIDIA H200 | SXM 141 GB | Dedicated | Common data-center target |
| 1 | NVIDIA B200 | 180 GB | Dedicated | Previous picker |
| 1 | NVIDIA GB200 | Blackwell GPU 186 GB | Dedicated | Previous picker; per-GPU, not pooled |
| 1 | NVIDIA A100 | SXM 80 GB | Dedicated | Common data-center target; MIG profiles |
| 1 | NVIDIA L40S / L4 | 48 GB / 24 GB | Dedicated | Common serving cards |
| 1 | NVIDIA RTX 5090 | 32 GB | Dedicated | FastH3 consumer baseline |
| 1 | NVIDIA RTX PRO 6000 Blackwell | Workstation 96 GB | Dedicated | FastH3 consumer baseline |
| 1 | NVIDIA RTX 4090 | 24 GB | Dedicated | FastH3 FP8 baseline |
| 1 | NVIDIA DGX Spark GB10 | 128 GB, 64 GB OEM | Unified | FastH3 Spark baseline |
| 1 | Apple M4 Max | 36, 48, 64, 128 GB | Unified | FastMetal tested 36 GB recipe |
| 1 | Apple M5 Max / M5 Ultra | 48/64/128 GB; 96/256/512 GB | Unified | Current Mac Studio/MacBook Pro options |
| 2 | Apple M1 Pro / M2 | 16 GB / 8 GB | Unified | M1 Pro FastMetal evidence; low-memory Apple systems |
| 2 | NVIDIA RTX 50 series | 5080 16, 5070 Ti 16, 5070 12, 5060 Ti 16/8 | Dedicated | Common consumer cards |
| 2 | NVIDIA RTX 40 series | 4080 Super 16, 4070 Ti Super 16, 4060 Ti 16/8 | Dedicated | Common consumer cards |
| 2 | NVIDIA RTX 30 series | 3090 24, 3080 12/10 | Dedicated | Widely installed |
| 2 | NVIDIA RTX 6000 Ada / A6000 / A5000 / A4000 | 48 / 48 / 24 / 16 GB | Dedicated | Workstations |
| 2 | NVIDIA RTX 5000 Ada / 4000 Ada | 32 / 20 GB | Dedicated | Workstations |
| 2 | AMD Instinct MI300X / MI325X / MI355X | 192 / 256 / 288 GB | Dedicated | Data-center alternatives |
| 2 | AMD Radeon RX 7900 XTX / RX 9070 XT | 24 / 16 GB | Dedicated | Consumer alternatives |
| 2 | AMD Radeon PRO W7900 | 48 GB | Dedicated | Workstation alternative |
| 2 | Intel Arc A770 / B580 | 16 / 12 GB | Dedicated | Consumer alternatives |
| 2 | Intel Data Center GPU Max 1550 | 128 GB | Dedicated | Data-center alternative |
## Batch 2 — next reviewable additions
These are deliberately not asserted in the catalog yet. Add them only with a
vendor source and checked date.
- [ ] NVIDIA H100 PCIe 80 GB, H200 NVL, B300/GB300 variants.
- [ ] NVIDIA A100 40 GB, A40, A10, A30, T4, V100 16/32 GB.
- [ ] NVIDIA laptop GPUs, separated by laptop power/board variant and VRAM.
- [ ] NVIDIA RTX 5070 laptop, 5060/5050 desktop, 4070/4060 desktop variants.
- [ ] NVIDIA RTX PRO 6000 Max-Q and Server Edition (separate from Workstation).
- [ ] NVIDIA DGX Station / OEM Grace-Blackwell systems.
- [ ] AMD Instinct MI250X, MI210, MI300A; Radeon RX 9060 XT, W6800.
- [ ] Intel Arc B570, Flex 170/140.
- [ ] Apple M3 Max/Ultra, M2 Max/Ultra, M4 Pro, and remaining older configurable sizes.
- [ ] Additional MIG/vGPU profiles where a vendor documents the partition size.
- [ ] Systems with a documented host-RAM configuration for offload estimates.
## Validation rules
The build fails on duplicate IDs, names, aliases, or family variants; duplicate
or malformed sources; missing checked dates; unknown units, vendors, kinds,
backends, or dtypes; usable budgets above nominal capacity; partitions above
the full device; and unified entries that hide capacity in a generic `memory_gb`
field.
- `memory.nominal` and `memory.usable` keep the authored value/unit (`GB` or
`GiB`); the build also exports normalized decimal `memory_gb` and
`usable_memory_gb`.
- Dedicated records expose VRAM. Unified records expose shared system memory and
never expose it as VRAM; the estimator uses a labeled 75% application default
and accepts an explicit `memoryBudgetGiB` override.
- `partitions` are separate selectable allocations. Selecting one never silently
inherits the full-card capacity; a vendor-documented profile may explicitly
span the full memory. If no partition is selected, the full device is used.
- `aliases` preserve old UI links and search names; canonical IDs remain stable.
- `sources[].checked` records when the vendor page was verified, not when the
device was released.
## Deliberately out of scope
- Claiming kernel, dtype, backend, or model support from catalog presence.
- Browser-side detection of the remote generation host. Lookup is user input.
- Summing per-GPU capacity into a pool. GPU count and topology are separate.
- Inventing usable-memory reserves for macOS, Windows, or OEM systems. A budget
must come from a documented policy or the user.
- Treating a successful generation as a measured minimum-VRAM result.
File diff suppressed because it is too large Load Diff
+21 -7
View File
@@ -74,9 +74,10 @@ to documentation, not embedded HTML.
## Shared JavaScript API
`docs/js/cookbook-config.mjs` exports the six functions below. The generated
`assets/cookbook-config/cookbook-config.js` bundles them with Ajv and exposes
the browser global `FastVideoConfigCookbook` and CommonJS exports:
`docs/js/cookbook-config.mjs` exports the recipe, capacity, and assessment
helpers below. The generated `assets/cookbook-config/cookbook-config.js` bundles
them with Ajv and exposes the browser global `FastVideoConfigCookbook` and
CommonJS exports:
| Function | Result |
| --- | --- |
@@ -85,12 +86,22 @@ the browser global `FastVideoConfigCookbook` and CommonJS exports:
| `getOptions(catalog, config = catalog.base_config, edits = {})` | `[{path, schema, value}]`, including GPU-dependent degree limits |
| `resolveConfig(catalog, edits = {})` | `config`, `yaml`, `argv`, `command`, `clientRequest`, `clientCommand`, optional `websocketUrl` |
| `sampleRequest(metadata, config)` | `{body, command}` and optional `websocketUrl` for a native serving baseline |
| `estimateMemory({config, workload, model, hardware, headroom})` | Analytical one-GPU memory estimate; see [memory estimates](../contributing/cookbook_configuration.md#memory-estimates) |
| `WORKLOADS` | Frozen `t2v`/`i2v`/`t2i`/`t2a`/`v2a` input/output/endpoint/media metadata used by the builder and sample client |
| `estimateMemory({config, workload, model, hardware, memoryBudgetGiB, partitionId, hostMemoryGb, headroom})` | Analytical one-device estimate with separate device and host budgets |
| `assessCapacity({model, example, hardware, config, request, env, revisions, ...})` | `{status, basis, analytical, evidence, compatibility, notes}` combining analysis and applicable evidence |
| `matchEvidence({model, example, config, request, env, hardware, revisions})` | `{applicable, unverified, rejected}` measured runs |
| `recommendCapacity({models, hardware, task, request, env, revisions, limit})` | Candidates with model/example, assessment, reasons, and lower-memory alternatives; unknown remains explicit |
| `resolveExecution({model, config, env})` | Effective topology, offload, precision, attention, and unsupported switches |
| `loadCapacityIndex(url, options)` | Promise of the model-summary index and resolved source URL |
| `loadCapacityModel(index, id, options)` | Promise of one expanded model, examples, and reference runs |
| `loadCapacityHardware(index, options)` | Promise of `{version, devices, url}` for the source-backed hardware lookup |
`sampleRequest()` is also used by model pages backed by a checked-in serving
YAML rather than a full editable catalog. Its metadata supplies `model.id`,
`workload`, and `runtime.interface`/`runtime.server_defaults`. It builds client
instructions only; it does not validate the configuration or run a request.
The shared workload map selects the canonical REST endpoint and output
extension; it does not establish that a model supports that workload.
For WebSocket deployments, `clientRequest` is `null` and `websocketUrl` is
present. REST deployments return a request object and omit `websocketUrl`.
@@ -217,9 +228,12 @@ shared hardware/component facts and optional recorded runs through separate
static loaders. Optional `capacity` sections extend existing model manifests;
model identity and example configurations are derived from their deployments
and native baseline YAML. `estimateMemory()` is analytical and never verifies a
recipe. Measured-hardware UI, format versioning and HTTP cache freshness remain
separate follow-ups. No general arithmetic validator or CLI override workflow is
introduced here.
recipe. `assessCapacity()` keeps analytical fit, measured device peaks, and
applicable evidence distinct; allocator-only peaks and successful generations
do not establish device fit or a minimum-VRAM requirement. The hardware asset is
versioned and source-backed; the UI owns catalog search and the custom-device
form, while the shared API owns lookup, normalization, and estimation. No general
arithmetic validator or CLI override workflow is introduced here.
## Cookbook interface
+30
View File
@@ -97,6 +97,36 @@ runtimes:
title: Compile transformer
type: boolean
default: false
# MiniMax-H3 only (fastvideo_args.video_decode_backend). TAEH3 fetches its own SHA-pinned weights.
generator.pipeline.experimental.video_decode_backend:
title: Video decoder
description: >-
h3-vae is the full decoder. taeh3 is the tiny preview decoder: faster and lighter, lower quality,
text-to-video only.
type: string
enum: [h3-vae, taeh3]
default: h3-vae
# MiniMax-H3 only (fastvideo_args.h3_vae_nvfp4_decoder). Files come from FastVideo/FastH3-NVFP4-VAE-Decoder.
generator.pipeline.experimental.h3_vae_nvfp4_decoder:
title: NVFP4 video decoder
description: >-
Replaces the video decoder with an NVFP4 decoder on Blackwell GPUs. The 36-block quality tier needs a
full-depth VAE; the 8-block fast tier works with any H3 VAE.
type: [string, "null"]
enum:
- null
- ./fasth3-nvfp4-vae/quality-36L/decoder-nvfp4.safetensors
- ./fasth3-nvfp4-vae/fast-8L/decoder-nvfp4.safetensors
default: null
# A local text-encoder folder; a serialized NVFP4 encoder selects its quantization from its own config.
generator.pipeline.components.text_encoder_weights:
title: Text encoder
description: Loads a different text-encoder checkpoint. The NVFP4 encoder needs a Blackwell GPU.
type: [string, "null"]
enum:
- null
- ./fasth3-nvfp4-encoder/text_encoder
default: null
default_request.sampling.num_frames:
title: Frames
type: integer
+47
View File
@@ -0,0 +1,47 @@
title: FastH3 4-Step V1
summary: Four-step video and audio on one DGX Spark, with lazy module loading and offloaded encoders.
guide: docs/getting_started/installation/spark.md
deployments:
spark-rest:
label: DGX Spark REST
runtime: fastvideo-cuda-rest
workload: t2v
defaults: examples/serving/openai_fasth3_spark.yaml
env:
FASTVIDEO_VSA_PTX_BLACKWELL: "0"
FASTVIDEO_FA4: "0"
FASTVIDEO_ATTENTION_BACKEND: VIDEO_SPARSE_ATTN_H3
requirements:
- Install FastVideo using the DGX Spark guide before running these commands.
- GB10 has no FA4 or ptx_blackwell VSA kernel; the environment above selects the Triton path.
- A 345-frame request on one Spark can run out of memory. Prefer 124 or 243 frames.
- For two Sparks, use the inference recipe examples/inference/basic/basic_fasth3_spark_pair.yaml; it has no serving baseline yet.
- "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
- generator.engine.parallelism.tp_size
- generator.engine.parallelism.sp_size
- generator.engine.offload.text_encoder
- 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.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.seed
overrides:
default_request.sampling.num_frames:
enum: [124, 243, 345]
default: 124
description: Native H3 frame counts. 345 frames can exceed one Spark's memory.
default_request.sampling.height:
minimum: 32
multipleOf: 32
default: 768
default_request.sampling.width:
minimum: 32
multipleOf: 32
default: 1344
+5
View File
@@ -13,6 +13,8 @@ deployments:
# Existing FastH3 V2 installation command: docs/cookbook/openai-api.md.
requirements:
- 'UV_TORCH_BACKEND=cu130 uv pip install -e ".[fasth3]"'
- "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
@@ -20,6 +22,9 @@ deployments:
- "!generator.engine.parallelism.hsdp_*"
- generator.engine.offload.text_encoder
- 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.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
+141
View File
@@ -0,0 +1,141 @@
title: FastH3 OmniRef
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:
rtx5090-rest:
label: RTX 5090 REST
runtime: fastvideo-cuda-rest
workload: i2v
defaults: examples/serving/openai_fasth3_omniref_rtx5090.yaml
env: &omniref_env
FASTVIDEO_ATTENTION_BACKEND: VIDEO_SPARSE_ATTN_H3
FASTVIDEO_H3_VSA_FP4: "1"
FASTVIDEO_H3_VSA_FP4_PREFIX: fp8
FASTVIDEO_H3_VSA_FP4_TWO_LEVEL_P: "1"
FASTVIDEO_H3_VSA_FP4_K_SMOOTH: "1"
FASTVIDEO_H3_GATE_CHUNK_ROWS: "8192"
FASTVIDEO_ULYSSES_A2A: auto
FASTVIDEO_MINIMAX_H3_EXACT_KERNELS: all
FASTVIDEO_H3_VSA_HEADS_FIRST_TILE: "1"
FASTVIDEO_H3_VAE_TILE_PARALLEL: "1"
FASTVIDEO_H3_REF2VA_MEMO_ENTRIES: "16"
FASTVIDEO_MINIMAX_H3_FUSIONS: all
FASTVIDEO_VSA_TRITON: "1"
FASTVIDEO_VSA_PTX_BLACKWELL: "0"
FASTVIDEO_FA4: "0"
FASTVIDEO_H3_ADALN_TABLE: ./adaln_table.pt
FASTVIDEO_H3_VAE_TILE_BATCH: "4"
requirements: &omniref_requirements
- 'UV_TORCH_BACKEND=cu130 uv pip install -e ".[fasth3]"'
- >-
Compose the model directory once: python examples/inference/basic/basic_fasth3_omniref_pdd.py
--model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --composed-dir ./FastH3-OmniRef-Composed
--image assets/images/couple.jpg --prompt "a person speaking to camera"
- >-
The 32 GB card needs ./adaln_table.pt. Write it once with FASTVIDEO_H3_ADALN_DUMP=./adaln_table.pt
on a run that loads the AdaLN projections, such as the RTX PRO 6000 or DGX Spark profile.
- Requests carry ordered image_reference, video_reference, and audio references; list local media
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"
options: &omniref_options
- server.*
- generator.engine.offload.text_encoder
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.seed
overrides: &omniref_overrides
default_request.sampling.num_frames:
enum: [124, 243, 362]
default: 124
description: Five, ten, or fifteen seconds at 24 fps.
default_request.sampling.height:
minimum: 32
multipleOf: 32
default: 480
default_request.sampling.width:
minimum: 32
multipleOf: 32
default: 832
rtx-pro6000-rest:
label: RTX PRO 6000 REST
runtime: fastvideo-cuda-rest
workload: i2v
defaults: examples/serving/openai_fasth3_omniref_rtx_pro6000.yaml
env:
FASTVIDEO_ATTENTION_BACKEND: VIDEO_SPARSE_ATTN_H3
FASTVIDEO_H3_VSA_FP4: "1"
FASTVIDEO_H3_VSA_FP4_PREFIX: fp8
FASTVIDEO_H3_VSA_FP4_TWO_LEVEL_P: "1"
FASTVIDEO_H3_VSA_FP4_K_SMOOTH: "1"
FASTVIDEO_H3_GATE_CHUNK_ROWS: "8192"
FASTVIDEO_MINIMAX_H3_FUSIONS: all
FASTVIDEO_VSA_TRITON: "1"
FASTVIDEO_VSA_PTX_BLACKWELL: "0"
FASTVIDEO_FA4: "0"
FASTVIDEO_H3_VAE_TILE_BATCH: "8"
requirements:
- 'UV_TORCH_BACKEND=cu130 uv pip install -e ".[fasth3]"'
- >-
Compose the model directory once: python examples/inference/basic/basic_fasth3_omniref_pdd.py
--model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --composed-dir ./FastH3-OmniRef-Composed
--image assets/images/couple.jpg --prompt "a person speaking to camera"
- Requests carry ordered image_reference, video_reference, and audio references; list local media
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"
options: *omniref_options
overrides:
default_request.sampling.num_frames:
enum: [124, 243, 362]
default: 124
description: Five, ten, or fifteen seconds at 24 fps.
default_request.sampling.height:
minimum: 32
multipleOf: 32
default: 768
default_request.sampling.width:
minimum: 32
multipleOf: 32
default: 1344
spark-rest:
label: DGX Spark REST
runtime: fastvideo-cuda-rest
workload: i2v
defaults: examples/serving/openai_fasth3_omniref_spark.yaml
env:
FASTVIDEO_ATTENTION_BACKEND: VIDEO_SPARSE_ATTN_H3
FASTVIDEO_H3_VSA_FP4: "1"
FASTVIDEO_H3_VSA_FP4_PREFIX: fp8
FASTVIDEO_H3_VSA_FP4_TWO_LEVEL_P: "1"
FASTVIDEO_H3_VSA_FP4_K_SMOOTH: "1"
FASTVIDEO_H3_GATE_CHUNK_ROWS: "8192"
FASTVIDEO_ULYSSES_A2A: auto
FASTVIDEO_MINIMAX_H3_EXACT_KERNELS: all
FASTVIDEO_H3_VSA_HEADS_FIRST_TILE: "1"
FASTVIDEO_H3_VAE_TILE_PARALLEL: "1"
FASTVIDEO_H3_REF2VA_MEMO_ENTRIES: "16"
FASTVIDEO_MINIMAX_H3_FUSIONS: all
FASTVIDEO_VSA_TRITON: "1"
FASTVIDEO_VSA_PTX_BLACKWELL: "0"
FASTVIDEO_FA4: "0"
FASTVIDEO_NVFP4_MM_BACKEND: cudnn
FASTVIDEO_NVFP4_MM_ROW_SPLIT: table
FASTVIDEO_H3_VAE_TILE_BATCH: "12"
FASTVIDEO_H3_VAE_INT8_OVERLAY: "0"
requirements:
- Install FastVideo using the DGX Spark guide before running these commands.
- >-
Compose the model directory once: python examples/inference/basic/basic_fasth3_omniref_pdd.py
--model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --composed-dir ./FastH3-OmniRef-Composed
--image assets/images/couple.jpg --prompt "a person speaking to camera"
- Requests carry ordered image_reference, video_reference, and audio references; list local media
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"
options: *omniref_options
overrides: *omniref_overrides
+64
View File
@@ -0,0 +1,64 @@
title: FastH3 Preview V0.2
summary: Few-step video and audio from the FastH3 preview checkpoint on CUDA or native Apple Silicon MLX.
deployments:
cuda-rest:
runtime: fastvideo-cuda-rest
guide: docs/cookbook/openai-api.md
workload: t2v
defaults: examples/serving/openai_fasth3.yaml
requirements:
- 'UV_TORCH_BACKEND=cu130 uv pip install -e ".[fasth3]"'
- "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
- generator.engine.parallelism.tp_size
- generator.engine.parallelism.sp_size
- generator.engine.offload.text_encoder
- 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.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.seed
overrides:
default_request.sampling.num_frames:
minimum: 108
maximum: 362
description: Configured frame counts align upward to the next 17n+5 value, up to 362.
default_request.sampling.height:
minimum: 32
multipleOf: 32
default: 768
description: H3 canvas dimensions use multiples of 32; the runtime's canvas-area limit also applies.
default_request.sampling.width:
minimum: 32
multipleOf: 32
mlx-rest:
runtime: fastvideo-mlx-rest
guide: docs/cookbook/openai-api.md
workload: t2v
defaults: examples/serving/mlx_fasth3.yaml
requirements:
- 'uv pip install -e ".[mlx]"'
- Download the preview checkpoint and convert its transformer to an MLX int6 export before starting the server.
- Paths are relative to the directory where you launch the server.
options:
- server.*
- generator.model_root
- generator.mlx_checkpoint
- generator.prompt_cache_dir
- generator.vae_dtype
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.seed
overrides:
generator.vae_dtype:
title: VAE compute precision
description: Precision used by the native MLX VAE; the serving baseline uses fp32.
+8
View File
@@ -20,6 +20,7 @@ deployments:
- Install FastVideo using the DGX Spark guide before running these commands.
- 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"
options:
- server.*
- generator.engine.num_gpus
@@ -27,12 +28,19 @@ deployments:
- generator.engine.parallelism.sp_size
- generator.engine.offload.text_encoder
- generator.engine.offload.vae
- generator.pipeline.experimental.video_decode_backend
- generator.pipeline.experimental.h3_vae_nvfp4_decoder
- default_request.prompt
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.seed
overrides:
generator.pipeline.experimental.h3_vae_nvfp4_decoder:
enum:
- null
- ./fasth3-nvfp4-vae/fast-8L/decoder-nvfp4.safetensors
description: The bundled VAE has 26 blocks, so only the 8-block fast tier fits.
default_request.prompt:
title: Prompt
type: string
@@ -0,0 +1,30 @@
title: HunyuanVideo 1.5 I2V 480p
summary: First-frame-conditioned video generation with the step-distilled HunyuanVideo 1.5 model.
deployments:
cuda-rest:
runtime: fastvideo-cuda-rest
workload: i2v
defaults: examples/serving/openai_hunyuan15_i2v_480p.yaml
requirements:
- >-
Each request must supply its own first frame through image_reference or
input_reference; the server does not define a default image.
options:
- server.*
- generator.engine.num_gpus
- generator.engine.parallelism.*
- "!generator.engine.parallelism.hsdp_*"
- generator.engine.offload.text_encoder
- generator.engine.offload.vae
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.fps
overrides:
default_request.sampling.height:
minimum: 16
multipleOf: 16
default_request.sampling.width:
minimum: 16
multipleOf: 16
@@ -0,0 +1,26 @@
title: HunyuanVideo 1.5 T2V 480p
summary: Text-to-video generation at 480p with HunyuanVideo 1.5.
deployments:
cuda-rest:
runtime: fastvideo-cuda-rest
workload: t2v
defaults: examples/serving/openai_hunyuan15_t2v_480p.yaml
options:
- server.*
- generator.engine.num_gpus
- generator.engine.parallelism.*
- "!generator.engine.parallelism.hsdp_*"
- generator.engine.offload.text_encoder
- generator.engine.offload.vae
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.fps
overrides:
default_request.sampling.height:
minimum: 16
multipleOf: 16
default_request.sampling.width:
minimum: 16
multipleOf: 16
+26
View File
@@ -0,0 +1,26 @@
title: Kandinsky 5 T2V Lite
summary: Text-to-video generation with the Kandinsky-5.0 Lite 5-second model.
deployments:
cuda-rest:
runtime: fastvideo-cuda-rest
workload: t2v
defaults: examples/serving/openai_kandinsky5_t2v.yaml
options:
- server.*
- generator.engine.num_gpus
- generator.engine.parallelism.*
- "!generator.engine.parallelism.hsdp_*"
- generator.engine.offload.text_encoder
- generator.engine.offload.vae
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.fps
overrides:
default_request.sampling.height:
minimum: 16
multipleOf: 16
default_request.sampling.width:
minimum: 16
multipleOf: 16
@@ -0,0 +1,26 @@
title: Kandinsky 6 TI2VA Distilled
summary: Ten-step pi-Flow Kandinsky 6 Pro video with synchronized audio.
deployments:
cuda-rest:
runtime: fastvideo-cuda-rest
workload: t2v
defaults: examples/serving/openai_kandinsky6_ti2va_distilled.yaml
options:
- server.*
- generator.engine.num_gpus
- generator.engine.parallelism.*
- "!generator.engine.parallelism.hsdp_*"
- generator.engine.offload.text_encoder
- generator.engine.offload.vae
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.fps
overrides:
default_request.sampling.height:
minimum: 16
multipleOf: 16
default_request.sampling.width:
minimum: 16
multipleOf: 16
@@ -0,0 +1,26 @@
title: Kandinsky 6 TI2VA
summary: Text- or image-conditioned video with synchronized audio from Kandinsky 6 Pro.
deployments:
cuda-rest:
runtime: fastvideo-cuda-rest
workload: t2v
defaults: examples/serving/openai_kandinsky6_ti2va.yaml
options:
- server.*
- generator.engine.num_gpus
- generator.engine.parallelism.*
- "!generator.engine.parallelism.hsdp_*"
- generator.engine.offload.text_encoder
- generator.engine.offload.vae
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.fps
overrides:
default_request.sampling.height:
minimum: 16
multipleOf: 16
default_request.sampling.width:
minimum: 16
multipleOf: 16
+26 -1
View File
@@ -2,7 +2,7 @@
# selected options, schema types/ranges, and baseline values for each deployment.
# TODO: Recheck field paths and choices when the typed API in PR #1913 is finalized.
title: LTX2 Distilled
summary: Incremental video delivery over the native streaming WebSocket interface.
summary: LTX2 video generation with synchronized audio over REST or incremental WebSocket streaming.
deployments:
cuda-streaming:
@@ -33,3 +33,28 @@ deployments:
default_request.sampling.width:
minimum: 64
multipleOf: 64
cuda-rest:
runtime: fastvideo-cuda-rest
workload: t2v
defaults: examples/serving/openai_ltx2_distilled.yaml
options:
- server.*
- generator.engine.num_gpus
- generator.engine.parallelism.sp_size
- generator.engine.offload.text_encoder
- generator.engine.offload.vae
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.fps
- default_request.sampling.seed
overrides:
default_request.sampling.height:
minimum: 64
multipleOf: 32
default: 1024
default_request.sampling.width:
minimum: 64
multipleOf: 32
default: 1536
+26
View File
@@ -0,0 +1,26 @@
title: Wan2.1 T2V 14B
summary: Text-to-video generation with the Wan2.1 14B base model at 720p.
deployments:
cuda-rest:
runtime: fastvideo-cuda-rest
workload: t2v
defaults: examples/serving/openai_wan21_t2v_14b.yaml
options:
- server.*
- generator.engine.num_gpus
- generator.engine.parallelism.*
- "!generator.engine.parallelism.hsdp_*"
- generator.engine.offload.text_encoder
- generator.engine.offload.vae
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.fps
overrides:
default_request.sampling.height:
minimum: 16
multipleOf: 16
default_request.sampling.width:
minimum: 16
multipleOf: 16
+30
View File
@@ -0,0 +1,30 @@
title: Wan2.2 I2V A14B
summary: Image-conditioned video generation with the Wan2.2 mixture-of-experts model.
deployments:
cuda-rest:
runtime: fastvideo-cuda-rest
workload: i2v
defaults: examples/serving/openai_wan22_i2v_a14b.yaml
requirements:
- >-
Each request must supply its own first frame through image_reference or
input_reference; the server does not define a default image.
options:
- server.*
- generator.engine.num_gpus
- generator.engine.parallelism.*
- "!generator.engine.parallelism.hsdp_*"
- generator.engine.offload.text_encoder
- generator.engine.offload.vae
- default_request.sampling.num_frames
- default_request.sampling.height
- default_request.sampling.width
- default_request.sampling.fps
overrides:
default_request.sampling.height:
minimum: 16
multipleOf: 16
default_request.sampling.width:
minimum: 16
multipleOf: 16
+134
View File
@@ -0,0 +1,134 @@
# Serving coverage and hardware evidence
This ledger tracks what the repository can actually launch today. A checked-in
configuration means the model path, workload and request fields resolve; it does
not mean the recipe has been run on a particular GPU. Hardware catalog entries
are owned by the shared cookbook catalog, not by this page.
Last source audit: 2026-10-19.
## Runtime surface
| Runtime | Interface | Workloads accepted by the server | Output |
| --- | --- | --- | --- |
| `fastvideo-cuda-rest` | OpenAI-compatible REST | `t2v`, `i2v`; `t2i` through `/v1/images`; `t2a`, `v2a` through the media-job routes | `.mp4`, `.png`, `.wav` |
| `fastvideo-cuda-streaming` | WebSocket streaming | `t2v` | fragmented MP4 |
| `fastvideo-mlx-rest` | MLX REST | `t2v` | `.mp4` |
Audio uses `/v1/videos` and `/v1/videos/sync` because the job store and polling
contract are already shared. The response `media_type` is `audio/wav` and the
downloaded file is WAV; clients must branch on `media_type`, not on the endpoint
name. There is no separate audio job store.
Media references are limited by the serve config. Local paths must sit under
`server.media_roots` (empty by default), and private or loopback URLs need
`server.allow_private_media_urls`. The server downloads at most
`server.media_reference_max_bytes` (default 2 GiB) per reference and removes
its copy when the job finishes, fails, or is cancelled; caller-owned files are
never deleted. See `examples/serving/README.md`.
## Maintained serving baselines
| Baseline | Model | Workload | Runtime | Cookbook deployment | Evidence |
| --- | --- | --- | --- | --- | --- |
| `openai_fasth3.yaml` | FastH3 Preview v0.2 | `t2v` | CUDA REST | none (legacy catalog only) | Source-backed; H100/GB200 benchmark notes exist, not a per-GPU fit claim |
| `openai_fasth3_8step.yaml` | FastH3 8-Step V2 | `t2v` | CUDA REST | `fasth3-8step/cuda-rest` | Source-backed |
| `openai_fasth3_lora.yaml` | MiniMax-H3 + LoRA | `t2v` | CUDA REST | none | Source-backed |
| `openai_fasth3_spark.yaml` | FastH3 V1 | `t2v` | CUDA REST | none (legacy catalog only) | Source-backed; one GB10 |
| `openai_fasth3_trim_spark.yaml` | FastH3 Trim | `t2v` | CUDA REST | `fasth3-trim/cuda-rest` | Source-backed; baseline says not GPU-validated |
| `openai_compacth3_rtx5090.yaml` | CompactH3 NVFP4 | `t2v` | CUDA REST | none | Source-backed; one RTX 5090 |
| `openai_compacth3_rtx_pro6000.yaml` | CompactH3 NVFP4 | `t2v` | CUDA REST | none | Source-backed; one RTX PRO 6000 |
| `openai_fastwan21_1_3b.yaml` | FastWan2.1 1.3B | `t2v` | CUDA REST | `fastwan21` | Source-backed |
| `openai_wan21_i2v_14b.yaml` | Wan2.1 I2V 14B | `i2v` | CUDA REST | `wan21-i2v` | Source-backed |
| `openai_wan21_t2v_14b.yaml` | Wan2.1 T2V 14B | `t2v` | CUDA REST | `wan21-t2v-14b` | Source-backed; 720p base model |
| `openai_wan22_t2v_a14b.yaml` | Wan2.2 T2V A14B | `t2v` | CUDA REST | none | Source-backed |
| `openai_wan22_i2v_a14b.yaml` | Wan2.2 I2V A14B | `i2v` | CUDA REST | `wan22-i2v-a14b` | Source-backed |
| `openai_wan22_ti2v_5b.yaml` | Wan2.2 TI2V 5B | `t2v` | CUDA REST | none | Source-backed |
| `openai_hunyuan15_t2v_480p.yaml` | HunyuanVideo 1.5 T2V 480p | `t2v` | CUDA REST | `hunyuan15-t2v-480p` | Source-backed |
| `openai_kandinsky5_t2v.yaml` | Kandinsky-5.0 T2V Lite | `t2v` | CUDA REST | `kandinsky5-t2v` | Source-backed |
| `openai_hunyuan15_i2v_480p.yaml` | HunyuanVideo 1.5 I2V 480p | `i2v` | CUDA REST | `hunyuan15-i2v-480p` | Source-backed; preset renders 720x1280 |
| `openai_kandinsky6_ti2va.yaml` | Kandinsky 6 Pro TI2VA | `t2v` | CUDA REST | `kandinsky6-ti2va` | Source-backed |
| `openai_kandinsky6_ti2va_distilled.yaml` | Kandinsky 6 Pro-distill TI2VA | `t2v` | CUDA REST | `kandinsky6-ti2va-distilled` | Source-backed |
| `openai_ltx2_distilled.yaml` | LTX2 Distilled | `t2v` | CUDA REST | `ltx2-distilled/cuda-rest` | Source-backed |
| `streaming_demo.yaml` | LTX2 Distilled | `t2v` | CUDA streaming | `ltx2-distilled/cuda-streaming` | Source-backed |
| `mlx_fasth3.yaml` | FastH3 V1 converted | `t2v` | MLX REST | none (legacy catalog only) | Source runtime; T2VA only |
| `mlx_fasth3_8step.yaml` | FastH3 V2 converted | `t2v` | MLX REST | `fasth3-8step/mlx-rest` | Source runtime; T2VA only |
| `openai_flux1_dev.yaml` | FLUX.1-dev | `t2i` | CUDA REST `/v1/images` | none | Source-backed; no image manifest yet |
| `openai_flux2_dev.yaml` | FLUX.2-dev | `t2i` | CUDA REST `/v1/images` | none | Source-backed; no image manifest yet |
| `openai_flux2_klein.yaml` | FLUX.2-klein-4B | `t2i` | CUDA REST `/v1/images` | none | Source-backed; no image manifest yet |
| `openai_sd35.yaml` | SD3.5 Medium | `t2i` | CUDA REST `/v1/images` | none | Source-backed; no image manifest yet |
| `openai_stable_audio.yaml` | Stable Audio Open 1.0 | `t2a` | CUDA REST media jobs | none | Source-backed; optional audio dependencies |
| `openai_mmaudio.yaml` | MMAudio large-44k-v2 | `v2a` | CUDA REST media jobs | none | Source-backed; video reference required |
## Registry families without a maintained baseline
The registry recognizes more model IDs than this table lists. These families
still resolve through the typed API, but they do not have a checked-in serving
configuration in this checkout:
- Kandinsky 5 I2V and other checkpoints, Kandinsky 6 VSR, Kandinsky 5 SR.
- Cosmos, Cosmos 2.5, GEN3C, Helios, HyWorld, LingBot Video/World/World2.
- MatrixGame 2/3 interactive generation.
- Wan Fun InP/Control, Wan Animate, Wan S2V, Lucy Edit, Self-Forcing variants.
- MMAudio small/medium variants, Stable Audio Open Small.
- MagiHuman pipelines have no registry entry.
Adding a baseline for one of these is a code and evidence task, not a catalog
edit. The next reviewable batches are image manifests (after the shared runtime
workload contract admits `t2i`), audio manifests, then the interactive and
super-resolution families.
## Hardware evidence found in recipes
| Device identity in source | Memory type | Capacity stated by source | Recipe or test evidence | Source |
| --- | --- | --- | --- | --- |
| NVIDIA GB200 | Dedicated HBM | Not stated per device | FastH3 four-device benchmarks and measured latency table | `examples/inference/basic/README.md`, `docs/inference/support_matrix.md` |
| NVIDIA H100 (sm_90) | Dedicated HBM | 80 GB per device | FastH3 four- and eight-device benchmark rows | `examples/inference/basic/basic_fasth3_h100.yaml`, `examples/inference/basic/README.md` |
| NVIDIA B200 | Dedicated HBM | Not stated per device | Kandinsky 5 and Stable Audio recorded single-GPU runs | `docs/cookbook/kandinsky5.md`, `docs/cookbook/stable-audio.md` |
| NVIDIA RTX 5090 | Dedicated GDDR | 32 GB | CompactH3 and OmniRef profiles | `examples/serving/openai_compacth3_rtx5090.yaml`, `examples/inference/basic/basic_fasth3_omniref_rtx5090.yaml` |
| NVIDIA RTX PRO 6000 Blackwell | Dedicated GDDR | 96 GB | CompactH3, FastH3 OmniRef profiles | `examples/serving/openai_compacth3_rtx_pro6000.yaml`, `examples/inference/basic/basic_fasth3_omniref_rtx_pro6000.yaml` |
| NVIDIA RTX 4090 | Dedicated GDDR | 24 GB | FastH3 OmniRef FP8 profile, no FP4 | `examples/inference/basic/basic_fasth3_omniref_rtx4090.yaml` |
| NVIDIA DGX Spark (GB10) | Unified LPDDR | 128 GB system memory | FastH3 and OmniRef one- and two-Spark recipes | `examples/serving/openai_fasth3_spark.yaml`, `examples/inference/basic/basic_fasth3_omniref_pdd.py` |
| Apple M4 Max | Unified | 16 GB or 36 GB system memory | FastMetal and MLX support rows | `docs/inference/support_matrix.md` |
| Apple M1 Pro | Unified | 16 GB system memory | PR #1802 real 1.3B run, 256x256, 17 frames | PR #1802, not merged into this checkout |
| Apple M2 (MacBook Air) | Unified | 8 GB system memory | Local harness host only; no successful 1.3B run recorded | Session hardware, not a recipe |
The source often records a GPU count but not a device model. Wan2.1 I2V, Wan2.2
A14B, HunyuanVideo 1.5, Kandinsky 6, FLUX and SD3.5 currently fall in that
category. A GPU-count match is not evidence that a device fits.
## Hardware handoff to the catalog and UI
- Exact identities above need canonical catalog variants. `RTX 5090 32 GB`,
`RTX PRO 6000 Blackwell 96 GB`, `H100 80 GB`, `GB10 128 GB unified` and
`M4 Max 16/36 GB unified` must not collapse to marketing names without
capacity or memory type.
- Dedicated VRAM and unified memory are separate estimator inputs. The Spark
and Apple rows are system memory shared with the OS; none of them is a
dedicated VRAM budget. A usable unified budget needs a platform policy or a
user-entered application budget.
- Host RAM is a third input. FastH3 and CompactH3 offload text/image encoders
and pin CPU memory; no recipe records the host RAM used, so offload host
requirements remain unknown.
- Multi-GPU rows are topology, not pooled memory. FastH3 runs four or eight
devices with sequence parallelism; one device's capacity is the relevant
per-device fact.
- Restrictions such as MIG, vGPU or a user-set memory limit must not inherit
the full card capacity.
## Verification
The declaration checks run without model weights:
```bash
pytest fastvideo/tests/entrypoints/test_openai_serving_engine.py \
fastvideo/tests/entrypoints/test_serving_family_baselines.py
cd docs && npm run test:cookbook
```
Audio clients are in `examples/serving/clients/audio.py`, `audio.mjs` and
`audio.sh`; they read `media_type` before choosing the `.wav` extension.
Runtime evidence is recorded only when a generation actually completes with
the exact configuration, request, environment, revisions, hardware and metric
scope. A source-backed row above stays unverified until then.
@@ -88,6 +88,7 @@ surfaces:
video_decode_backend: "MiniMax-H3 video decoder selection (full VAE vs TAEH3 preview); model-specific optimization not yet represented in the typed public schema."
taeh3_checkpoint: "Optional local TAEH3 safetensors path; model-specific optimization not yet represented in the typed public schema."
taeh3_chunk_size: "TAEH3 temporal chunk length; model-specific optimization not yet represented in the typed public schema."
h3_vae_nvfp4_decoder: "Exported NVFP4 MiniMax-H3 video decoder file that replaces the VAE decoder at load; model-specific optimization not yet represented in the typed public schema."
vae_parallel_encode: "MiniMax-H3 sequence-parallel reference VAE encode opt-in; model-specific optimization not yet represented in the typed public schema."
vae_parallel_decode_strategy: "Chunk-transport collective for vae_parallel_decode; model-specific optimization not yet represented in the typed public schema."
attention_backend: "Process-wide default attention-backend request applied per component at load time; kernel-selection knob not yet represented in the typed public schema."
@@ -620,6 +621,8 @@ surfaces:
enable_teacache: request.runtime.enable_teacache
max_sequence_length: request.sampling.max_sequence_length
boundary_ratio: request.sampling.boundary_ratio
audio_start_in_s: request.sampling.audio_start_in_s
audio_end_in_s: request.sampling.audio_end_in_s
extra_params: request.extensions
compatibility_only:
seconds:
+34
View File
@@ -12,6 +12,18 @@ This page covers running the NVFP4 stack on one consumer Blackwell device
(RTX 5090, RTX PRO 6000, DGX Spark) and the closest equivalent on an RTX 4090,
which has no FP4 hardware.
## At a glance
| | |
|---|---|
| Checkpoint | [FastVideo/FastH3-OmniRef-NVFP4-QAD](https://huggingface.co/FastVideo/FastH3-OmniRef-NVFP4-QAD) |
| Inputs | A prompt plus ordered references: up to 9 images, 3 videos and 3 audio clips, 12 in total |
| Output | Video with synchronized stereo audio at 24 fps |
| Clip length | 124, 243 or 362 frames (5, 10 or 15 seconds) |
| Sampling | 8 fused transformer forwards, guidance 1.0 |
| Local hardware | RTX 5090, RTX PRO 6000 and DGX Spark (NVFP4); RTX 4090 (FP8) |
| Cookbook | [MiniMax H3 cookbook](../cookbook/minimax-h3.md) |
## Composing the model directory
The NVFP4 checkpoint is
@@ -114,6 +126,28 @@ sits 4.3 dB farther from the bf16 attention reference on every frame (20.96 vs
Use the 36L decoder for the best-quality clip and the 8L decoder on memory-bound
devices; both run the FP8 split.
## Serving
`examples/serving/openai_fasth3_omniref_{rtx5090,rtx_pro6000,spark}.yaml` serve
the same stacks through the OpenAI-compatible API; their `generator` blocks match
the inference recipes above. Start one with the environment from its header:
```bash
fastvideo serve --config examples/serving/openai_fasth3_omniref_spark.yaml
```
Requests send ordered `image_reference`, `video_reference` and audio references.
The server reads local paths only under `server.media_roots`. These serving
configurations follow the maintained inference recipes and have not yet been
validated as servers on each device.
Select the NVFP4 decoder per configuration with
`generator.pipeline.experimental.h3_vae_nvfp4_decoder`, pointing at a file from
[FastVideo/FastH3-NVFP4-VAE-Decoder](https://huggingface.co/FastVideo/FastH3-NVFP4-VAE-Decoder);
`FASTVIDEO_H3_VAE_NVFP4_DECODER` remains the fallback. The composed directory
holds base MiniMax-H3's full 36-block VAE, so both the 36-block quality tier and
the 8-block fast tier apply.
## Environment
The profiles set these; set them yourself when driving the YAML recipes.
+154 -9
View File
@@ -57,22 +57,114 @@ function count(value, context) {
return value;
}
const HARDWARE_VENDORS = new Set(["nvidia", "amd", "intel", "apple"]);
const HARDWARE_KINDS = new Set(["gpu", "accelerator", "soc", "system"]);
const HARDWARE_BACKENDS = new Set(["cuda", "rocm", "metal", "sycl", "unknown"]);
const FORM_FACTORS = new Set(["pcie", "sxm", "oam", "desktop", "laptop", "soc", "system", "unknown"]);
const MEMORY_TYPES = new Set(["dedicated", "unified"]);
const MEMORY_UNITS = { GB: 1, GiB: 1.073741824 };
function memoryQuantity(value, context) {
keys(value, ["value", "unit"], [], context);
requireValue(positive(value.value), `${context}: value must be positive and finite`);
requireValue(Object.hasOwn(MEMORY_UNITS, value.unit), `${context}: unit must be GB or GiB`);
return value;
}
function memorySizeGb(value, context) {
memoryQuantity(value, context);
return value.value * MEMORY_UNITS[value.unit];
}
function checkedSources(values, context) {
requireValue(Array.isArray(values) && values.length > 0, `${context}: provide source URLs`);
const seen = new Set();
for (const source of values) {
keys(source, ["url", "checked"], [], context);
url(source.url, `${context}.url`);
requireValue(!seen.has(source.url), `${context}: duplicate source ${source.url}`);
seen.add(source.url);
const date =
typeof source.checked === "string" &&
/^\d{4}-\d{2}-\d{2}$/.test(source.checked) &&
Date.parse(`${source.checked}T00:00:00Z`);
requireValue(
date !== false && Number.isFinite(date) && new Date(date).toISOString().slice(0, 10) === source.checked,
`${context}.checked: expected a YYYY-MM-DD date`,
);
}
return values;
}
function hardwareRecords(document, context) {
keys(document, ["hardware"], [], context);
requireValue(object(document.hardware) && Object.keys(document.hardware).length > 0, `${context}: no hardware`);
const names = new Set();
const identities = new Map();
const families = new Map();
const records = {};
for (const [id, hardware] of Object.entries(document.hardware)) {
const at = `${context}: hardware.${id}`;
identifier(id, at);
keys(
hardware,
["name", "memory_type", "memory_gb", "backend", "sources"],
["ui_id", "supported_dtypes", "notes"],
["name", "vendor", "kind", "memory", "backend", "sources"],
[
"aliases",
"family",
"variant",
"form_factor",
"gpu_count",
"host_memory",
"partitionable",
"partitions",
"supported_dtypes",
"notes",
"ui_id",
],
at,
);
requireValue(text(hardware.name) && text(hardware.backend), `${at}: name and backend must be text`);
requireValue(["dedicated", "unified"].includes(hardware.memory_type), `${at}: invalid memory_type`);
requireValue(positive(hardware.memory_gb), `${at}: memory_gb must be positive and finite`);
if (hardware.ui_id !== undefined) identifier(hardware.ui_id, `${at}.ui_id`);
requireValue(text(hardware.name), `${at}: name must be text`);
const foldedName = hardware.name.toLocaleLowerCase();
requireValue(!names.has(foldedName), `${at}: duplicate hardware name ${hardware.name}`);
names.add(foldedName);
requireValue(HARDWARE_VENDORS.has(hardware.vendor), `${at}: unknown vendor`);
requireValue(HARDWARE_KINDS.has(hardware.kind), `${at}: unknown kind`);
requireValue(HARDWARE_BACKENDS.has(hardware.backend), `${at}: unknown backend`);
if (hardware.form_factor !== undefined)
requireValue(FORM_FACTORS.has(hardware.form_factor), `${at}: unknown form_factor`);
keys(hardware.memory, ["type", "nominal"], ["usable", "policy"], `${at}.memory`);
requireValue(MEMORY_TYPES.has(hardware.memory.type), `${at}.memory: invalid type`);
const nominalGb = memorySizeGb(hardware.memory.nominal, `${at}.memory.nominal`);
if (hardware.memory.usable !== undefined) {
const usableGb = memorySizeGb(hardware.memory.usable, `${at}.memory.usable`);
requireValue(usableGb <= nominalGb, `${at}.memory.usable: exceeds nominal capacity`);
requireValue(text(hardware.memory.policy), `${at}.memory.policy: explain how the usable budget is derived`);
} else requireValue(hardware.memory.policy === undefined, `${at}.memory.policy: needs a usable budget`);
if (hardware.host_memory !== undefined) memorySizeGb(hardware.host_memory, `${at}.host_memory`);
if (hardware.gpu_count !== undefined) requireValue(integer(hardware.gpu_count), `${at}.gpu_count: invalid`);
if (hardware.partitionable !== undefined)
requireValue(typeof hardware.partitionable === "boolean", `${at}.partitionable: expected a boolean`);
const partitions = [];
if (hardware.partitions !== undefined) {
requireValue(
Array.isArray(hardware.partitions) && hardware.partitions.length > 0,
`${at}.partitions: expected a list`,
);
requireValue(hardware.memory.type === "dedicated", `${at}.partitions: only dedicated devices can be partitioned`);
requireValue(hardware.partitionable !== false, `${at}.partitions: conflicts with partitionable: false`);
const ids = new Set();
for (const partition of hardware.partitions) {
keys(partition, ["id", "label", "memory"], [], `${at}.partitions`);
identifier(partition.id, `${at}.partitions.id`);
requireValue(!ids.has(partition.id), `${at}.partitions: duplicate partition ${partition.id}`);
ids.add(partition.id);
requireValue(text(partition.label), `${at}.partitions.${partition.id}.label: expected text`);
const partitionGb = memorySizeGb(partition.memory, `${at}.partitions.${partition.id}.memory`);
requireValue(partitionGb <= nominalGb, `${at}.partitions.${partition.id}: must not exceed the full device`);
partitions.push({ ...structuredClone(partition), memory_gb: partitionGb });
}
}
if (hardware.notes !== undefined) requireValue(text(hardware.notes), `${at}: notes must be text`);
if (hardware.supported_dtypes !== undefined)
requireValue(
@@ -82,9 +174,59 @@ function hardwareRecords(document, context) {
new Set(hardware.supported_dtypes).size === hardware.supported_dtypes.length,
`${at}: supported_dtypes must be a nonempty unique list of ${[...WEIGHT_FORMATS].join(", ")}`,
);
sources(hardware.sources, `${at}.sources`);
if (hardware.family !== undefined) {
identifier(hardware.family, `${at}.family`);
requireValue(text(hardware.variant), `${at}.variant: required with family`);
const variants = families.get(hardware.family) ?? new Set();
const foldedVariant = hardware.variant.toLocaleLowerCase();
requireValue(!variants.has(foldedVariant), `${at}: duplicate variant in family ${hardware.family}`);
variants.add(foldedVariant);
families.set(hardware.family, variants);
} else requireValue(hardware.variant === undefined, `${at}.variant: needs a family`);
requireValue(hardware.aliases === undefined || Array.isArray(hardware.aliases), `${at}.aliases: expected a list`);
const aliases = [...(hardware.aliases ?? [])];
if (hardware.ui_id !== undefined) {
identifier(hardware.ui_id, `${at}.ui_id`);
aliases.push(hardware.ui_id);
}
requireValue(
aliases.every((alias) => text(alias)),
`${at}.aliases: expected nonempty text`,
);
const record = {
id,
...structuredClone(hardware),
aliases,
gpu_count: hardware.gpu_count ?? 1,
memory_gb: nominalGb,
};
delete record.ui_id;
if (partitions.length) record.partitions = partitions;
if (hardware.memory.usable !== undefined)
record.usable_memory_gb = memorySizeGb(hardware.memory.usable, `${at}.memory.usable`);
if (hardware.host_memory !== undefined)
record.host_memory_gb = memorySizeGb(hardware.host_memory, `${at}.host_memory`);
checkedSources(hardware.sources, `${at}.sources`);
records[id] = record;
}
return document.hardware;
for (const [id, hardware] of Object.entries(records)) {
const local = new Set([id.toLocaleLowerCase()]);
for (const identity of [id, ...hardware.aliases]) {
const folded = identity.toLocaleLowerCase();
const owner = identities.get(folded);
requireValue(
owner === undefined || owner === id,
`${context}: hardware ${id} reuses identity ${identity} from ${owner}`,
);
requireValue(
!(identity !== id && local.has(folded) && folded !== id),
`${context}: hardware ${id} repeats its own identity ${identity}`,
);
local.add(folded);
identities.set(folded, id);
}
}
return records;
}
function componentRecords(root, catalogDir) {
@@ -260,7 +402,10 @@ export function exportCapacityCatalog({
write(join(outputDir, "models", filename), model);
index.models.push({ id: model.id, name: model.name, model_id: model.model_id, catalog_url: `models/${filename}` });
}
write(join(outputDir, "hardware.json"), hardware);
write(join(outputDir, "hardware.json"), {
version: 1,
devices: Object.values(hardware).sort((left, right) => left.id.localeCompare(right.id)),
});
write(join(outputDir, "index.json"), index);
for (const name of readdirSync(join(outputDir, "models"))) {
if (name.endsWith(".json") && ID.test(name.slice(0, -5)) && !expected.has(name))
+3 -3
View File
@@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url";
import { parseDocument, stringify } from "yaml";
import { createValidator } from "./cookbook-validator.mjs";
import { resolveConfig } from "./cookbook-config.mjs";
import { WORKLOAD_IDS } from "./cookbook-workloads.mjs";
import { validateCapacityMetadata } from "./cookbook-capacity-schema.mjs";
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
@@ -348,9 +349,8 @@ export function buildCatalogs({
validateRuntime(runtime, id, `${context}.metadata`);
expected(
definition.workloads,
(value) =>
Array.isArray(value) && value.length > 0 && value.every((workload) => ["t2v", "i2v"].includes(workload)),
"nonempty list of t2v or i2v workloads",
(value) => Array.isArray(value) && value.length > 0 && value.every((workload) => WORKLOAD_IDS.includes(workload)),
"nonempty list of supported workloads",
`${context}.workloads`,
);
const options = mergeOptions(declarations.options, definition.options, context);
+4 -1
View File
@@ -103,7 +103,7 @@ function referenceRuns(model, hardware, examples) {
keys(
run,
["id", "date", "hardware", "gpu_count", "outcome", "execution", "evidence_url", "context"],
["memory"],
["memory", "hardware_partition", "hardware_allocation_gb"],
at,
);
identifier(run.id, at);
@@ -120,6 +120,9 @@ function referenceRuns(model, hardware, examples) {
hardware === undefined || Object.hasOwn(hardware, run.hardware),
`${at}: unknown hardware ${run.hardware}`,
);
if (run.hardware_partition !== undefined) identifier(run.hardware_partition, `${at}.hardware_partition`);
if (run.hardware_allocation_gb !== undefined)
requireValue(positive(run.hardware_allocation_gb), `${at}.hardware_allocation_gb must be positive`);
requireValue(integer(run.gpu_count), `${at}: gpu_count must be a positive safe integer`);
requireValue(run.outcome === "completed", `${at}: outcome must describe a completed generation`);
requireValue(["first_request", "warm_request"].includes(run.execution), `${at}: unknown execution condition`);
+60 -12
View File
@@ -53,20 +53,68 @@ export async function loadCapacityModel(index, id, options = {}) {
return data;
}
function validateHardwareDevice(hardware) {
requireValue(
object(hardware) &&
text(hardware.id) &&
text(hardware.name) &&
object(hardware.memory) &&
["dedicated", "unified"].includes(hardware.memory.type) &&
object(hardware.memory.nominal) &&
Number.isFinite(hardware.memory.nominal.value) &&
["GB", "GiB"].includes(hardware.memory.nominal.unit) &&
Number.isFinite(hardware.memory_gb) &&
hardware.memory_gb > 0 &&
Number.isSafeInteger(hardware.gpu_count) &&
hardware.gpu_count > 0 &&
["cuda", "rocm", "metal", "sycl", "unknown"].includes(hardware.backend) &&
Array.isArray(hardware.aliases) &&
hardware.aliases.every(text),
`Invalid capacity hardware ${hardware?.id ?? "(missing id)"}`,
);
if (hardware.usable_memory_gb !== undefined)
requireValue(
Number.isFinite(hardware.usable_memory_gb) &&
hardware.usable_memory_gb > 0 &&
hardware.usable_memory_gb <= hardware.memory_gb,
`Invalid usable memory for ${hardware.id}`,
);
if (hardware.host_memory_gb !== undefined)
requireValue(
Number.isFinite(hardware.host_memory_gb) && hardware.host_memory_gb > 0,
`Invalid host memory for ${hardware.id}`,
);
if (hardware.partitions !== undefined) {
requireValue(
Array.isArray(hardware.partitions) &&
hardware.partitions.length > 0 &&
hardware.partitions.every(
(partition) =>
object(partition) &&
text(partition.id) &&
text(partition.label) &&
Number.isFinite(partition.memory_gb) &&
partition.memory_gb > 0 &&
// A full-card profile such as A100 7g.80gb equals the card; none may exceed it.
partition.memory_gb <= hardware.memory_gb,
),
`Invalid partitions for ${hardware.id}`,
);
}
}
/** Hardware specifications are separate from model-specific successful run evidence. */
export async function loadCapacityHardware(index, options = {}) {
validateIndex(index);
requireValue(text(index.url), "Capacity index requires its source URL");
const { data } = await fetchJson(new URL(index.hardware_url, index.url).href, options);
requireValue(object(data) && Object.keys(data).length > 0, "Invalid capacity hardware catalog");
for (const [id, hardware] of Object.entries(data))
requireValue(
object(hardware) &&
text(hardware.name) &&
["dedicated", "unified"].includes(hardware.memory_type) &&
Number.isFinite(hardware.memory_gb) &&
hardware.memory_gb > 0,
`Invalid capacity hardware ${id}`,
);
return data;
const { data, url } = await fetchJson(new URL(index.hardware_url, index.url).href, options);
requireValue(object(data) && data.version === 1 && Array.isArray(data.devices), "Invalid capacity hardware catalog");
const ids = new Set();
for (const hardware of data.devices) {
validateHardwareDevice(hardware);
requireValue(!ids.has(hardware.id), `Duplicate capacity hardware ${hardware.id}`);
ids.add(hardware.id);
}
requireValue(data.devices.length > 0, "Invalid capacity hardware catalog");
return { version: data.version, devices: data.devices, url };
}
+19 -4
View File
@@ -3,6 +3,8 @@
* Native baselines and manifest-selected JSON Schema controls come from the authored YAML catalog.
*/
import { createValidator } from "./cookbook-validator.mjs";
import { WORKLOADS } from "./cookbook-workloads.mjs";
export { WORKLOADS };
export { loadCapacityIndex, loadCapacityModel, loadCapacityHardware } from "./cookbook-capacity.mjs";
const scope = globalThis;
@@ -63,6 +65,7 @@ function validateIndex(index, baseUrl) {
for (const key of ["id", "label", "workload", "runtime", "catalog_url"]) {
requireShape(text(deployment[key]), `${entry}.${key}`, "nonempty text");
}
requireShape(Object.hasOwn(WORKLOADS, deployment.workload), `${entry}.workload`, "a supported workload ID");
requireShape(
!deploymentIds.has(deployment.id),
`${entry}.id (${deployment.id})`,
@@ -129,6 +132,7 @@ function validateCatalog(catalog, deployment) {
}
}
requireShape(catalog.workload === deployment.workload, `${context}.workload`, deployment.workload);
requireShape(Object.hasOwn(WORKLOADS, catalog.workload), `${context}.workload`, "a supported workload ID");
const paths = new Set();
for (const [position, control] of catalog.controls.entries()) {
const field = `${context}.controls[${position}]`;
@@ -350,23 +354,28 @@ export function sampleRequest(catalog, config) {
websocketUrl: `ws://${host}:${server.port}/v1/stream`,
};
}
const media = Object.hasOwn(WORKLOADS, catalog.workload) ? WORKLOADS[catalog.workload] : undefined;
if (media === undefined) throw new CookbookValidationError(`Unsupported workload: ${catalog.workload}`);
const components = config.generator?.pipeline?.components;
const model = components?.lora_path ? components.lora_nickname : server.served_model_name || catalog.model.id;
const body = {
...(model === undefined ? {} : { model }),
prompt: config.default_request?.prompt ?? "A river flowing through a peaceful forest",
};
if (catalog.workload === "i2v") body.input_reference = "/absolute/path/to/first-frame.png";
if (media.reference === "input_reference") body.input_reference = "/absolute/path/to/first-frame.png";
if (media.reference === "video_reference") {
body.video_reference = { video_url: "/absolute/path/to/input.mp4" };
}
const argv = [
"curl",
"--fail-with-body",
`http://${host}:${server.port}/v1/videos/sync`,
`http://${host}:${server.port}${media.endpoint}`,
"-H",
"Content-Type: application/json",
"--data",
JSON.stringify(body),
];
argv.push("--output", "output.mp4");
argv.push("--output", `output.${media.extension}`);
return { body, command: argv.map(shellQuote).join(" ") };
}
@@ -434,4 +443,10 @@ function getOptions(catalog, config = catalog.base_config, selections = {}) {
}
export { loadIndex, loadDeployment, getOptions, resolveConfig };
export { estimateMemory } from "./cookbook-memory.mjs";
export {
assessCapacity,
estimateMemory,
matchEvidence,
recommendCapacity,
resolveExecution,
} from "./cookbook-memory.mjs";
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
/**
* Shared workload IDs and media direction. Endpoint and request-field choices mirror the
* serving API contract; callers must not infer support from an ID alone.
*/
export const WORKLOADS = Object.freeze({
t2v: Object.freeze({
input: "text",
output: "video",
endpoint: "/v1/videos/sync",
extension: "mp4",
media_type: "video/mp4",
}),
i2v: Object.freeze({
input: "image",
output: "video",
endpoint: "/v1/videos/sync",
extension: "mp4",
media_type: "video/mp4",
reference: "input_reference",
}),
t2i: Object.freeze({
input: "text",
output: "image",
endpoint: "/v1/images/generations",
extension: "json",
media_type: "application/json",
}),
t2a: Object.freeze({
input: "text",
output: "audio",
endpoint: "/v1/videos/sync",
extension: "wav",
media_type: "audio/wav",
}),
v2a: Object.freeze({
input: "video",
output: "audio",
endpoint: "/v1/videos/sync",
extension: "wav",
media_type: "audio/wav",
reference: "video_reference",
}),
});
export const WORKLOAD_IDS = Object.freeze(Object.keys(WORKLOADS));
+1
View File
@@ -0,0 +1 @@
/Users/aryank/os/FastVideo-review-int/docs/node_modules
+105 -9
View File
@@ -46,11 +46,17 @@ function fixture(t) {
const hardware = {
hardware: {
"example-gpu": {
name: "Fixture GPU",
memory_type: "dedicated",
memory_gb: 24,
name: "Fixture GPU 24 GB",
vendor: "nvidia",
kind: "gpu",
family: "fixture-gpu",
variant: "Fixture 24 GB",
form_factor: "desktop",
memory: { type: "dedicated", nominal: { value: 24, unit: "GB" } },
backend: "cuda",
sources: [SOURCE],
supported_dtypes: ["fp32", "fp16", "bf16"],
aliases: ["fixture"],
sources: [{ url: SOURCE, checked: "2026-10-19" }],
},
},
};
@@ -196,7 +202,10 @@ function referenceRun(f) {
test("capacity export resolves shared metadata and preserves native example configuration", (t) => {
const f = fixture(t);
const data = buildCapacityCatalog({ root: f.root });
assert.deepEqual(data.hardware["example-gpu"], f.hardware.hardware["example-gpu"]);
assert.equal(data.hardware["example-gpu"].id, "example-gpu");
assert.equal(data.hardware["example-gpu"].memory_gb, 24);
assert.deepEqual(data.hardware["example-gpu"].aliases, ["fixture"]);
assert.equal(data.hardware["example-gpu"].memory.type, "dedicated");
const model = data.models[0];
assert.equal(model.id, "example-model");
assert.equal(model.name, f.manifest.title);
@@ -437,15 +446,82 @@ test("hardware and components require source URLs and do not fix a runtime dtype
f.save();
assert.throws(() => buildCapacityCatalog({ root: f.root }), /source|URL/i);
transformer(f).sources = [SOURCE];
f.hardware.hardware["example-gpu"].sources = ["not-a-url"];
f.hardware.hardware["example-gpu"].sources = [{ url: "not-a-url", checked: "2026-10-19" }];
f.save();
assert.throws(() => buildCapacityCatalog({ root: f.root }), /source|URL|https/i);
f.hardware.hardware["example-gpu"].sources = [SOURCE];
f.hardware.hardware["example-gpu"].sources = [{ url: SOURCE, checked: "2026-10-19" }];
transformer(f).dtype = "bf16";
f.save();
assert.throws(() => buildCapacityCatalog({ root: f.root }), /dtype|unknown|unsupported/i);
});
test("the authored hardware inventory is sourced, unique and keeps unified memory explicit", () => {
const catalog = buildCapacityCatalog({ root: ROOT }).hardware;
const devices = Object.values(catalog);
assert.equal(devices.length, 52);
assert.equal(new Set(devices.map((device) => device.id)).size, devices.length);
const aliases = new Map();
for (const device of devices) {
assert.ok(device.sources.length > 0, `${device.id}: missing source`);
assert.match(device.sources[0].checked, /^\d{4}-\d{2}-\d{2}$/);
assert.ok(["dedicated", "unified"].includes(device.memory.type), `${device.id}: memory type`);
assert.ok(device.memory.nominal.value > 0, `${device.id}: capacity`);
for (const alias of device.aliases) {
assert.equal(aliases.has(alias), false, `${alias} is assigned to ${aliases.get(alias)} and ${device.id}`);
aliases.set(alias, device.id);
}
for (const partition of device.partitions ?? []) {
assert.ok(partition.memory_gb > 0 && partition.memory_gb <= device.memory_gb);
assert.match(partition.id, /^[a-z0-9][a-z0-9.-]*$/);
}
}
const spark = catalog["dgx-spark-gb10-128gb"];
assert.equal(spark.memory.type, "unified");
assert.equal(spark.memory_gb, 128);
assert.equal(spark.memory_type, undefined);
assert.equal(catalog["h100-sxm-80gb"].partitions.length, 3);
assert.deepEqual(
catalog["a100-sxm-80gb"].partitions.map((partition) => partition.id),
["1g.10gb", "2g.20gb", "3g.40gb", "7g.80gb"],
);
assert.equal(catalog["gb200-186gb"].gpu_count, 1);
});
test("hardware aliases, budgets and partitions cannot contradict the device", (t) => {
const f = fixture(t);
f.hardware.hardware["example-gpu"].aliases = "fixture";
f.save();
assert.throws(() => buildCapacityCatalog({ root: f.root }), /aliases/i);
f.hardware.hardware["example-gpu"].aliases = ["fixture", "FIXTURE"];
f.save();
assert.throws(() => buildCapacityCatalog({ root: f.root }), /repeats its own identity/i);
f.hardware.hardware["example-gpu"].aliases = ["fixture"];
f.hardware.hardware["other-gpu"] = {
...clone(f.hardware.hardware["example-gpu"]),
name: "Other GPU",
aliases: ["fixture"],
};
f.save();
assert.throws(() => buildCapacityCatalog({ root: f.root }), /alias|duplicate/i);
delete f.hardware.hardware["other-gpu"];
f.hardware.hardware["example-gpu"].memory.usable = { value: 25, unit: "GB" };
f.save();
assert.throws(() => buildCapacityCatalog({ root: f.root }), /usable/i);
f.hardware.hardware["example-gpu"].memory.usable = { value: 20, unit: "GB" };
f.hardware.hardware["example-gpu"].memory.policy = "Fixture application budget";
f.hardware.hardware["example-gpu"].partitions = [
{ id: "1g.25gb", label: "1g.25gb", memory: { value: 25, unit: "GB" } },
];
f.save();
assert.throws(() => buildCapacityCatalog({ root: f.root }), /partition/i);
delete f.hardware.hardware["example-gpu"].partitions;
delete f.hardware.hardware["example-gpu"].memory.usable;
delete f.hardware.hardware["example-gpu"].memory.policy;
f.hardware.hardware["example-gpu"].memory.type = "unified";
f.save();
assert.equal(buildCapacityCatalog({ root: f.root }).hardware["example-gpu"].memory.type, "unified");
});
test("hardware format names are known, unique and nonempty", (t) => {
const f = fixture(t);
const hardware = f.hardware.hardware["example-gpu"];
@@ -623,7 +699,12 @@ test("exports use relative URLs and browser loaders resolve them under a deploym
assert.equal(model.components.vae.decode_feature_multiplier, f.vaes.vaes["example-vae"].decode_feature_multiplier);
assert.deepEqual(model.examples[0].base_config, f.config);
const hardware = await loadCapacityHardware(loadedIndex, { fetcher, signal });
assert.deepEqual(hardware["example-gpu"], f.hardware.hardware["example-gpu"]);
assert.equal(hardware.version, 1);
assert.equal(hardware.devices.length, 1);
assert.equal(hardware.devices[0].id, "example-gpu");
assert.equal(hardware.devices[0].memory_gb, 24);
assert.deepEqual(hardware.devices[0].aliases, ["fixture"]);
assert.equal(hardware.url, new URL(index.hardware_url, indexUrl).href);
assert.deepEqual(requested, [
indexUrl,
new URL(index.models[0].catalog_url, indexUrl).href,
@@ -660,7 +741,7 @@ test("browser loaders reject malformed or mismatched catalog responses", async (
await assert.rejects(
() =>
loadCapacityHardware(index, {
fetcher: fetcher({ gpu: { name: "Invalid capacity", memory_type: "dedicated", memory_gb: -1 } }),
fetcher: fetcher({ version: 1, devices: [{ id: "gpu", name: "Invalid capacity" }] }),
}),
/hardware/i,
);
@@ -735,3 +816,18 @@ test(
assert.equal(readFileSync(sentinel, "utf8"), "unchanged\n");
},
);
test("the shipped hardware catalog passes the browser loader", async () => {
// Same shape exportCapacityCatalog writes to hardware.json.
const hardware = Object.values(buildCapacityCatalog().hardware);
const files = {
"https://docs.example/capacity/index.json": { hardware_url: "hardware.json", models: [] },
"https://docs.example/capacity/hardware.json": { version: 1, devices: hardware },
};
const fetcher = async (url) => ({ ok: url in files, status: 404, url, json: async () => files[url] });
const index = await loadCapacityIndex("https://docs.example/capacity/index.json", { fetcher });
const loaded = await loadCapacityHardware(index, { fetcher });
assert.equal(loaded.devices.length, hardware.length);
const a100 = loaded.devices.find((device) => device.id === "a100-sxm-80gb");
assert.ok(a100.partitions.some((partition) => partition.id === "7g.80gb" && partition.memory_gb === a100.memory_gb));
});
+29
View File
@@ -99,6 +99,8 @@ test("browser bundle exposes reusable methods and its error class with no DOM or
);
assert.deepEqual(Object.keys(context.FastVideoConfigCookbook).sort(), [
"CookbookValidationError",
"WORKLOADS",
"assessCapacity",
"estimateMemory",
"getOptions",
"loadCapacityHardware",
@@ -106,7 +108,10 @@ test("browser bundle exposes reusable methods and its error class with no DOM or
"loadCapacityModel",
"loadDeployment",
"loadIndex",
"matchEvidence",
"recommendCapacity",
"resolveConfig",
"resolveExecution",
"sampleRequest",
]);
const api = context.FastVideoConfigCookbook;
@@ -478,6 +483,30 @@ test("sample requests follow effective alias, host and port without repinning re
assert.equal(resolveConfig(image).clientRequest.input_reference, "/absolute/path/to/first-frame.png");
});
test("sample requests follow shared workload media", () => {
const expectations = {
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" },
};
for (const [workload, expected] of Object.entries(expectations)) {
const data = catalog();
data.workload = workload;
const result = resolveConfig(data);
assert.match(result.clientCommand, expected.endpoint);
assert.match(result.clientCommand, expected.output);
assert.equal(result.clientRequest.input_reference, undefined);
if (expected.reference) {
assert.deepEqual(result.clientRequest.video_reference, { video_url: "/absolute/path/to/input.mp4" });
} else {
assert.equal(result.clientRequest.video_reference, undefined);
}
}
const invalid = catalog();
invalid.workload = "t2x";
assert.throws(() => resolveConfig(invalid), /Unsupported workload/);
});
test("LoRA sample requests use the startup adapter instead of an edited server alias", () => {
const data = catalog();
const components = { lora_path: "/models/adapter.safetensors", lora_nickname: "my-adapter" };
File diff suppressed because it is too large Load Diff
+80 -4
View File
@@ -51,12 +51,13 @@ CUDA FastH3 8-Step V2 serving uses `examples/serving/openai_fasth3_8step.yaml`.
## Wan configs
Four Wan CUDA configs mirror the working generate config or example for each
Five Wan CUDA configs mirror the working generate config or example for each
checkpoint:
| Config | Model | GPUs | Served alias |
| --- | --- | --- | --- |
| `openai_fastwan21_1_3b.yaml` | FastWan2.1 T2V 1.3B (DMD, VSA) | 1 | `fastwan21-1.3b` |
| `openai_wan21_t2v_14b.yaml` | Wan2.1 T2V 14B 720P | 1 | `wan21-t2v-14b` |
| `openai_wan21_i2v_14b.yaml` | Wan2.1 I2V 14B 480P | 2 | `wan21-i2v-14b` |
| `openai_wan22_t2v_a14b.yaml` | Wan2.2 T2V A14B | 2 | `wan22-t2v-a14b` |
| `openai_wan22_ti2v_5b.yaml` | Wan2.2 TI2V 5B | 1 | `wan22-ti2v-5b` |
@@ -75,7 +76,81 @@ source image through the OpenAI-compatible reference fields, for example
The I2V 14B server requires one on every request; the TI2V 5B server treats
its absence as text-to-video.
Or submit, poll, and download with the OpenAI Python client:
## Other families
These configs are source-backed declarations; none is a hardware-fit claim.
`docs/cookbook/serving-coverage.md` records the full baseline list and the
uncovered registry families.
| Config | Model | Task | GPUs |
| --- | --- | --- | --- |
| `openai_wan22_i2v_a14b.yaml` | Wan2.2 I2V A14B | image-to-video | 1 |
| `openai_hunyuan15_t2v_480p.yaml` | HunyuanVideo 1.5 T2V 480p | text-to-video | 1 |
| `openai_kandinsky5_t2v.yaml` | Kandinsky-5.0 T2V Lite | text-to-video | 1 |
| `openai_hunyuan15_i2v_480p.yaml` | HunyuanVideo 1.5 I2V 480p | image-to-video | 1 |
| `openai_kandinsky6_ti2va.yaml` | Kandinsky 6 Pro TI2VA | text/image-to-video with audio | 1 |
| `openai_kandinsky6_ti2va_distilled.yaml` | Kandinsky 6 Pro-distill TI2VA | text/image-to-video with audio | 1 |
| `openai_ltx2_distilled.yaml` | LTX2 Distilled | text-to-video with audio | 4 |
| `openai_flux1_dev.yaml` | FLUX.1-dev | text-to-image | 1 |
| `openai_flux2_dev.yaml` | FLUX.2-dev | text-to-image | 1 |
| `openai_flux2_klein.yaml` | FLUX.2-klein-4B | text-to-image | 1 |
| `openai_sd35.yaml` | SD3.5 Medium | text-to-image | 1 |
| `openai_stable_audio.yaml` | Stable Audio Open 1.0 | text-to-audio | 1 |
| `openai_mmaudio.yaml` | MMAudio large-44k-v2 | video-to-audio | 1 |
The HunyuanVideo 1.5 I2V 480p checkpoint's registered preset renders at
720x1280; "480p" is the checkpoint identity, not a claim that every output is
480p.
Image servers use `/v1/images/generations` and `/v1/images/edits`. Send the
model's advertised alias as `model` and set `FASTVIDEO_MODEL` for the sample
clients. SD3.5 Medium is gated; accept its license and authenticate before
starting the server.
## Audio jobs
Audio uses the same asynchronous job routes as video so clients keep one
polling and deletion contract. The job's `media_type` is `audio/wav`, and
`/v1/videos/{id}/content` returns WAV bytes. Set `seconds` or
`audio_start_in_s`/`audio_end_in_s`; do not send `size`, `width`, `height`, or
`video_params`.
MMAudio is video-to-audio and requires `video_reference` (a URL, or a local
path the server allows). Stable Audio is text-to-audio.
Media references from HTTP callers follow three server settings:
```yaml
server:
media_reference_max_bytes: 2147483648 # per downloaded reference (default 2 GiB)
media_roots: [/data/media] # local paths callers may use (default: none)
allow_private_media_urls: false # loopback/private/link-local URLs (default: off)
```
- Local paths are rejected unless they resolve inside a `media_roots` entry,
including through symlinks.
- URLs must use http(s). Unless `allow_private_media_urls` is set, every
redirect hop must resolve to a public address. Turn it on only for trusted
internal media services.
- Downloads count streamed bytes against the limit. The server removes its copy
when the job finishes, fails, or is cancelled; caller files are never deleted.
Stable Audio needs the optional inference
dependencies listed in `examples/inference/basic/basic_stable_audio.py`.
```bash
python -m pip install httpx
FASTVIDEO_MODEL=stable-audio FASTVIDEO_SECONDS=6 \
python examples/serving/clients/audio.py
FASTVIDEO_MODEL=mmaudio-large-44k-v2 \
FASTVIDEO_VIDEO_REFERENCE=/path/to/clip.mp4 \
python examples/serving/clients/audio.py
```
Node and cURL clients are in `examples/serving/clients/audio.mjs` and
`audio.sh`.
Or submit, poll, and download video with the OpenAI Python client:
```bash
python -m pip install openai==3.6.0
@@ -84,9 +159,10 @@ python examples/serving/clients/video.py
The `clients/` directory also contains cURL and OpenAI JavaScript examples.
They default to model `fasth3`; set `FASTVIDEO_MODEL` to the advertised alias
when using the LoRA or Wan configs. Set `FASTVIDEO_BASE_URL` for another endpoint.
when using a LoRA or another family config. Set `FASTVIDEO_BASE_URL` for another endpoint.
These clients call your FastVideo server, not OpenAI's cloud. The SDK key
`local` is a placeholder, not authentication. Keep the server on loopback or
use an authenticated proxy for remote access.
For a blocking call, `POST /v1/videos/sync` returns the MP4 body directly.
For a blocking call, `POST /v1/videos/sync` returns the generated media body
directly. Check its `Content-Type` before saving the response.
+48
View File
@@ -0,0 +1,48 @@
// Run in Node.js 22+ or a trusted backend, not in a browser with a private key.
import { writeFile } from "node:fs/promises";
const baseUrl = process.env.FASTVIDEO_BASE_URL || "http://127.0.0.1:8000/v1";
const headers = {
Authorization: `Bearer ${process.env.FASTVIDEO_API_KEY || "local"}`,
"Content-Type": "application/json",
};
const body = {
model: process.env.FASTVIDEO_MODEL || "stable-audio",
prompt: process.env.FASTVIDEO_PROMPT || "Rain on a metal roof, distant thunder.",
seconds: Number(process.env.FASTVIDEO_SECONDS || 6),
};
if (process.env.FASTVIDEO_VIDEO_REFERENCE) {
body.video_reference = { video_url: process.env.FASTVIDEO_VIDEO_REFERENCE };
}
let response = await fetch(`${baseUrl}/videos`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`Submit failed (${response.status}): ${await response.text()}`);
let job = await response.json();
console.log(`Submitted ${job.id}`);
const deadline = performance.now() + 1_800_000;
while (job.status === "queued" || job.status === "in_progress") {
const remaining = deadline - performance.now();
if (remaining <= 0) throw new Error(`Polling timed out; job ${job.id} may still be running`);
await new Promise((resolve) => setTimeout(resolve, Math.min(2000, remaining)));
response = await fetch(`${baseUrl}/videos/${job.id}`, { headers });
if (!response.ok) throw new Error(`Poll failed (${response.status}): ${await response.text()}`);
job = await response.json();
}
if (job.status !== "completed") {
throw new Error(`Audio job ${job.id} failed: ${job.error?.message || job.status}`);
}
if (job.media_type !== "audio/wav") {
throw new Error(`Expected audio/wav, got ${job.media_type}`);
}
response = await fetch(`${baseUrl}/videos/${job.id}/content`, { headers });
if (!response.ok) throw new Error(`Download failed (${response.status}): ${await response.text()}`);
const output = `${job.id}.wav`;
await writeFile(output, Buffer.from(await response.arrayBuffer()));
console.log(`Saved ${output}`);
+66
View File
@@ -0,0 +1,66 @@
# SPDX-License-Identifier: Apache-2.0
"""Generate audio through a local FastVideo media-job endpoint.
Audio uses the same asynchronous job routes as video because the queue,
polling and deletion contracts are shared. Read ``media_type`` from the job:
successful audio jobs return ``audio/wav`` and a WAV download.
Install HTTP dependencies with: python -m pip install httpx
"""
import os
import time
from pathlib import Path
import httpx
BASE_URL = os.environ.get("FASTVIDEO_BASE_URL", "http://127.0.0.1:8000/v1")
MODEL = os.environ.get("FASTVIDEO_MODEL", "stable-audio")
PROMPT = os.environ.get("FASTVIDEO_PROMPT", "Rain on a metal roof, distant thunder.")
def main() -> None:
body = {
"model": MODEL,
"prompt": PROMPT,
"seconds": int(os.environ.get("FASTVIDEO_SECONDS", "6")),
}
video_reference = os.environ.get("FASTVIDEO_VIDEO_REFERENCE")
if video_reference:
body["video_reference"] = {"video_url": video_reference}
headers = {"Authorization": f"Bearer {os.environ.get('FASTVIDEO_API_KEY', 'local')}"}
with httpx.Client(base_url=BASE_URL, headers=headers, timeout=60.0) as client:
response = client.post("/videos", json=body)
response.raise_for_status()
job = response.json()
job_id = job["id"]
print(f"Submitted {job_id}")
deadline = time.monotonic() + 1800
while job["status"] in {"queued", "in_progress"}:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(f"Polling timed out; job {job_id} may still be running")
time.sleep(min(2, remaining))
job = client.get(f"/videos/{job_id}", timeout=min(60, remaining)).json()
if job["status"] != "completed":
message = (job.get("error") or {}).get("message", job["status"])
raise RuntimeError(f"Audio job {job_id} failed: {message}")
media_type = job.get("media_type")
if media_type != "audio/wav":
raise RuntimeError(f"Expected audio/wav, got {media_type!r}")
output = Path(f"{job_id}.wav")
with client.stream("GET", f"/videos/{job_id}/content", timeout=300.0) as download:
download.raise_for_status()
with output.open("wb") as stream:
for chunk in download.iter_bytes():
stream.write(chunk)
print(f"Saved {output}")
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Requires curl and jq. Run after starting the FastVideo audio server.
set -euo pipefail
base_url="${FASTVIDEO_BASE_URL:-http://127.0.0.1:8000/v1}"
api_key="${FASTVIDEO_API_KEY:-local}"
model="${FASTVIDEO_MODEL:-stable-audio}"
prompt="${FASTVIDEO_PROMPT:-Rain on a metal roof, distant thunder.}"
seconds="${FASTVIDEO_SECONDS:-6}"
payload=$(jq -n --arg model "$model" --arg prompt "$prompt" --argjson seconds "$seconds" \
'{model: $model, prompt: $prompt, seconds: $seconds}')
if [[ -n "${FASTVIDEO_VIDEO_REFERENCE:-}" ]]; then
payload=$(jq --arg video "$FASTVIDEO_VIDEO_REFERENCE" '. + {video_reference: {video_url: $video}}' <<< "$payload")
fi
job=$(curl --fail-with-body --silent --show-error --max-time 60 \
"$base_url/videos" \
-H "Authorization: Bearer $api_key" \
-H 'Content-Type: application/json' \
-d "$payload")
job_id=$(jq -er '.id' <<< "$job")
printf 'Submitted %s\n' "$job_id"
deadline=$((SECONDS + 1800))
while true; do
status=$(jq -er '.status' <<< "$job")
case "$status" in
completed) break ;;
failed) jq -r '.error.message' <<< "$job" >&2; exit 1 ;;
queued|in_progress) ;;
*) printf 'Unexpected job status: %s\n' "$status" >&2; exit 1 ;;
esac
remaining=$((deadline - SECONDS))
if (( remaining <= 0 )); then
printf 'Polling timed out; job %s may still be running\n' "$job_id" >&2
exit 1
fi
if (( remaining > 2 )); then sleep 2; else sleep "$remaining"; fi
remaining=$((deadline - SECONDS))
if (( remaining <= 0 )); then continue; fi
if (( remaining > 60 )); then remaining=60; fi
job=$(curl --fail-with-body --silent --show-error --max-time "$remaining" \
-H "Authorization: Bearer $api_key" "$base_url/videos/$job_id")
done
media_type=$(jq -er '.media_type' <<< "$job")
if [[ "$media_type" != "audio/wav" ]]; then
printf 'Expected audio/wav, got %s\n' "$media_type" >&2
exit 1
fi
curl --fail-with-body --silent --show-error --max-time 300 \
-H "Authorization: Bearer $api_key" \
"$base_url/videos/$job_id/content" --output "$job_id.wav"
printf 'Saved %s.wav\n' "$job_id"
@@ -0,0 +1,78 @@
# OpenAI-compatible FastH3 OmniRef (Ref2VA) server on one RTX 5090 (32 GB).
#
# Serving adaptation of examples/inference/basic/basic_fasth3_omniref_rtx5090.yaml; the same NVFP4 stack and
# device policy, not yet GPU-validated as a server.
#
# Point model_path at a composed OmniRef directory. Build it once with
# python examples/inference/basic/basic_fasth3_omniref_pdd.py \
# --model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --profile rtx-5090 \
# --composed-dir ./FastH3-OmniRef-Composed --image assets/images/couple.jpg --prompt "..."
#
# Requests carry ordered references: image_reference / input_reference for images,
# video_reference for clips. Local paths must sit under server.media_roots.
#
# FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3 \
# FASTVIDEO_H3_VSA_FP4=1 FASTVIDEO_H3_VSA_FP4_PREFIX=fp8 \
# FASTVIDEO_H3_VSA_FP4_TWO_LEVEL_P=1 FASTVIDEO_H3_VSA_FP4_K_SMOOTH=1 \
# FASTVIDEO_H3_GATE_CHUNK_ROWS=8192 \
# FASTVIDEO_ULYSSES_A2A=auto FASTVIDEO_MINIMAX_H3_EXACT_KERNELS=all \
# FASTVIDEO_H3_VSA_HEADS_FIRST_TILE=1 FASTVIDEO_H3_VAE_TILE_PARALLEL=1 \
# FASTVIDEO_H3_REF2VA_MEMO_ENTRIES=16 \
# FASTVIDEO_MINIMAX_H3_FUSIONS=all \
# FASTVIDEO_VSA_TRITON=1 FASTVIDEO_VSA_PTX_BLACKWELL=0 FASTVIDEO_FA4=0 \
# FASTVIDEO_H3_ADALN_TABLE=./adaln_table.pt \
# FASTVIDEO_H3_VAE_TILE_BATCH=4 \
# fastvideo serve --config examples/serving/openai_fasth3_omniref_rtx5090.yaml
#
# See docs/inference/fasth3_omniref.md.
generator:
model_path: ./FastH3-OmniRef-Composed
engine:
num_gpus: 1
use_fsdp_inference: false
quantization:
transformer_quant: NVFP4
layer_profile: h3_dit_vsa
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
dit_layerwise: false
text_encoder: true
vae: false
pin_cpu_memory: true
lazy_module_load: false
pipeline:
workload_type: i2v
components:
override_pipeline_cls_name: MiniMaxH3Ref2VAModularPipeline
experimental:
attention_backend: VIDEO_SPARSE_ATTN_H3
h3_vsa_fp4:
enabled: true
prefix_precision: fp8
two_level_p: true
smooth_k: true
video_decode_backend: h3-vae
h3_sequential_load: true
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_fasth3_omniref_rtx5090
served_model_name: fasth3-omniref
default_request:
negative_prompt: ""
sampling:
height: 480
width: 832
num_frames: 124
fps: 24
num_inference_steps: 8 # eight fused PDD blocks; the contract sets it
guidance_scale: 1.0
batch_cfg: false
seed: 1234
output:
return_frames: false
@@ -0,0 +1,74 @@
# OpenAI-compatible FastH3 OmniRef (Ref2VA) server on one RTX PRO 6000 Blackwell (96 GB).
#
# Serving adaptation of examples/inference/basic/basic_fasth3_omniref_rtx_pro6000.yaml; the same NVFP4 stack and
# device policy, not yet GPU-validated as a server.
#
# Point model_path at a composed OmniRef directory. Build it once with
# python examples/inference/basic/basic_fasth3_omniref_pdd.py \
# --model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --profile rtx-pro-6000 \
# --composed-dir ./FastH3-OmniRef-Composed --image assets/images/couple.jpg --prompt "..."
#
# Requests carry ordered references: image_reference / input_reference for images,
# video_reference for clips. Local paths must sit under server.media_roots.
#
# FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3 \
# FASTVIDEO_H3_VSA_FP4=1 FASTVIDEO_H3_VSA_FP4_PREFIX=fp8 \
# FASTVIDEO_H3_VSA_FP4_TWO_LEVEL_P=1 FASTVIDEO_H3_VSA_FP4_K_SMOOTH=1 \
# FASTVIDEO_H3_GATE_CHUNK_ROWS=8192 \
# FASTVIDEO_MINIMAX_H3_FUSIONS=all \
# FASTVIDEO_VSA_TRITON=1 FASTVIDEO_VSA_PTX_BLACKWELL=0 FASTVIDEO_FA4=0 \
# FASTVIDEO_H3_VAE_TILE_BATCH=8 \
# fastvideo serve --config examples/serving/openai_fasth3_omniref_rtx_pro6000.yaml
#
# See docs/inference/fasth3_omniref.md.
generator:
model_path: ./FastH3-OmniRef-Composed
engine:
num_gpus: 1
use_fsdp_inference: false
quantization:
transformer_quant: NVFP4
layer_profile: h3_dit_vsa
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
dit_layerwise: false
text_encoder: false
vae: false
pin_cpu_memory: true
lazy_module_load: false
pipeline:
workload_type: i2v
components:
override_pipeline_cls_name: MiniMaxH3Ref2VAModularPipeline
experimental:
attention_backend: VIDEO_SPARSE_ATTN_H3
h3_vsa_fp4:
enabled: true
prefix_precision: fp8
two_level_p: true
smooth_k: true
video_decode_backend: h3-vae
h3_sequential_load: false
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_fasth3_omniref_rtx_pro6000
served_model_name: fasth3-omniref
default_request:
negative_prompt: ""
sampling:
height: 768
width: 1344
num_frames: 124
fps: 24
num_inference_steps: 8 # eight fused PDD blocks; the contract sets it
guidance_scale: 1.0
batch_cfg: false
seed: 1234
output:
return_frames: false
@@ -0,0 +1,79 @@
# OpenAI-compatible FastH3 OmniRef (Ref2VA) server on one DGX Spark (GB10, 128 GB unified).
#
# Serving adaptation of examples/inference/basic/basic_fasth3_omniref_spark.yaml; the same NVFP4 stack and
# device policy, not yet GPU-validated as a server.
#
# Point model_path at a composed OmniRef directory. Build it once with
# python examples/inference/basic/basic_fasth3_omniref_pdd.py \
# --model-path FastVideo/FastH3-OmniRef-NVFP4-QAD --profile spark \
# --composed-dir ./FastH3-OmniRef-Composed --image assets/images/couple.jpg --prompt "..."
#
# Requests carry ordered references: image_reference / input_reference for images,
# video_reference for clips. Local paths must sit under server.media_roots.
#
# FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3 \
# FASTVIDEO_H3_VSA_FP4=1 FASTVIDEO_H3_VSA_FP4_PREFIX=fp8 \
# FASTVIDEO_H3_VSA_FP4_TWO_LEVEL_P=1 FASTVIDEO_H3_VSA_FP4_K_SMOOTH=1 \
# FASTVIDEO_H3_GATE_CHUNK_ROWS=8192 \
# FASTVIDEO_ULYSSES_A2A=auto FASTVIDEO_MINIMAX_H3_EXACT_KERNELS=all \
# FASTVIDEO_H3_VSA_HEADS_FIRST_TILE=1 FASTVIDEO_H3_VAE_TILE_PARALLEL=1 \
# FASTVIDEO_H3_REF2VA_MEMO_ENTRIES=16 \
# FASTVIDEO_MINIMAX_H3_FUSIONS=all \
# FASTVIDEO_VSA_TRITON=1 FASTVIDEO_VSA_PTX_BLACKWELL=0 FASTVIDEO_FA4=0 \
# FASTVIDEO_NVFP4_MM_BACKEND=cudnn FASTVIDEO_NVFP4_MM_ROW_SPLIT=table \
# FASTVIDEO_H3_VAE_TILE_BATCH=12 FASTVIDEO_H3_VAE_INT8_OVERLAY=0 \
# fastvideo serve --config examples/serving/openai_fasth3_omniref_spark.yaml
#
# See docs/inference/fasth3_omniref.md.
generator:
model_path: ./FastH3-OmniRef-Composed
engine:
num_gpus: 1
use_fsdp_inference: false
quantization:
transformer_quant: NVFP4
layer_profile: h3_dit_vsa
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
dit_layerwise: false
text_encoder: false
image_encoder: false
vae: false
pin_cpu_memory: false
lazy_module_load: false
pipeline:
workload_type: i2v
components:
override_pipeline_cls_name: MiniMaxH3Ref2VAModularPipeline
experimental:
attention_backend: VIDEO_SPARSE_ATTN_H3
h3_vsa_fp4:
enabled: true
prefix_precision: fp8
two_level_p: true
smooth_k: true
video_decode_backend: h3-vae
h3_sequential_load: false
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_fasth3_omniref_spark
served_model_name: fasth3-omniref
default_request:
negative_prompt: ""
sampling:
height: 480
width: 832
num_frames: 124
fps: 24
num_inference_steps: 8 # eight fused PDD blocks; the contract sets it
guidance_scale: 1.0
batch_cfg: false
seed: 1234
output:
return_frames: false
+26
View File
@@ -0,0 +1,26 @@
# OpenAI-compatible image server for FLUX.1-dev.
# fastvideo serve --config examples/serving/openai_flux1_dev.yaml
# curl http://127.0.0.1:8000/v1/images/generations -H 'Content-Type: application/json' \
# -d '{"model":"flux1-dev","prompt":"a photo of a cat","size":"1024x1024","response_format":"b64_json"}'
generator:
model_path: black-forest-labs/FLUX.1-dev
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
vae: false
text_encoder: false
pin_cpu_memory: false
pipeline:
workload_type: t2i
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_flux1_dev
served_model_name: flux1-dev
+26
View File
@@ -0,0 +1,26 @@
# OpenAI-compatible image server for FLUX.2-dev.
# fastvideo serve --config examples/serving/openai_flux2_dev.yaml
generator:
model_path: black-forest-labs/FLUX.2-dev
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
vae: true
text_encoder: true
pin_cpu_memory: false
pipeline:
workload_type: t2i
components:
override_pipeline_cls_name: Flux2Pipeline
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_flux2_dev
served_model_name: flux2-dev
+24
View File
@@ -0,0 +1,24 @@
# OpenAI-compatible image server for FLUX.2-klein (4-step distilled).
# fastvideo serve --config examples/serving/openai_flux2_klein.yaml
generator:
model_path: black-forest-labs/FLUX.2-klein-4B
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
vae: true
text_encoder: true
pin_cpu_memory: false
pipeline:
workload_type: t2i
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_flux2_klein
served_model_name: flux2-klein
@@ -0,0 +1,36 @@
# OpenAI-compatible server for HunyuanVideo 1.5 I2V 480p step-distilled.
# fastvideo serve --config examples/serving/openai_hunyuan15_i2v_480p.yaml
# Send image_reference (or input_reference) with the first frame.
generator:
model_path: hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_i2v_step_distilled
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: true
vae: true
text_encoder: true
pin_cpu_memory: true
pipeline:
workload_type: i2v
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_hunyuan15_i2v_480p
served_model_name: hunyuan15-i2v-480p
default_request:
negative_prompt: ""
sampling:
height: 720
width: 1280
num_frames: 121
fps: 24
num_inference_steps: 12
guidance_scale: 1.0
output:
return_frames: false
@@ -0,0 +1,35 @@
# OpenAI-compatible server for HunyuanVideo 1.5 T2V at 480p.
# fastvideo serve --config examples/serving/openai_hunyuan15_t2v_480p.yaml
generator:
model_path: hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: true
vae: true
text_encoder: true
pin_cpu_memory: true
pipeline:
workload_type: t2v
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_hunyuan15_t2v_480p
served_model_name: hunyuan15-t2v-480p
default_request:
negative_prompt: ""
sampling:
height: 480
width: 848
num_frames: 121
fps: 24
num_inference_steps: 50
guidance_scale: 6.0
output:
return_frames: false
@@ -0,0 +1,39 @@
# OpenAI-compatible server for Kandinsky-5.0 T2V Lite (5 seconds, 50 steps).
# Use the distilled checkpoint or lower num_inference_steps for faster runs.
# fastvideo serve --config examples/serving/openai_kandinsky5_t2v.yaml
generator:
model_path: kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s-Diffusers
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
vae: false
text_encoder: true
pin_cpu_memory: true
pipeline:
workload_type: t2v
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_kandinsky5_t2v
served_model_name: kandinsky5-t2v
default_request:
negative_prompt: >-
Static, 2D cartoon, cartoon, 2d animation, paintings, images, worst quality,
low quality, ugly, deformed, walking backwards
sampling:
seed: 1024
num_frames: 121
height: 512
width: 768
fps: 24
num_inference_steps: 50
guidance_scale: 5.0
output:
return_frames: false
@@ -0,0 +1,35 @@
# OpenAI-compatible server for Kandinsky 6 Pro TI2VA with synchronized audio.
# fastvideo serve --config examples/serving/openai_kandinsky6_ti2va.yaml
# Omit image_reference for text-to-video; send it for image conditioning.
generator:
model_path: kandinskylab/Kandinsky-6.0-Pro-5s-Diffusers
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
vae: false
text_encoder: true
pin_cpu_memory: true
pipeline:
workload_type: t2v
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_kandinsky6_ti2va
served_model_name: kandinsky6-ti2va
default_request:
sampling:
height: 512
width: 768
num_frames: 121
fps: 24
num_inference_steps: 50
guidance_scale: 5.0
output:
return_frames: false
@@ -0,0 +1,35 @@
# OpenAI-compatible server for Kandinsky 6 Pro-distill TI2VA (pi-Flow, 10 steps).
# fastvideo serve --config examples/serving/openai_kandinsky6_ti2va_distilled.yaml
# Omit image_reference for text-to-video; send it for image conditioning.
generator:
model_path: kandinskylab/Kandinsky-6.0-Pro-distill-5s-Diffusers
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
vae: false
text_encoder: true
pin_cpu_memory: true
pipeline:
workload_type: t2v
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_kandinsky6_ti2va_distilled
served_model_name: kandinsky6-ti2va-distilled
default_request:
sampling:
height: 512
width: 768
num_frames: 121
fps: 24
num_inference_steps: 10
guidance_scale: 1.0
output:
return_frames: false
@@ -0,0 +1,36 @@
# OpenAI-compatible REST server for LTX-2 distilled text-to-video with audio.
# fastvideo serve --config examples/serving/openai_ltx2_distilled.yaml
# The streaming variant remains in ltx2_distilled_streaming.yaml.
generator:
model_path: FastVideo/LTX2-Distilled-Diffusers
engine:
num_gpus: 4
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 4
offload:
dit: false
vae: true
text_encoder: true
pin_cpu_memory: true
pipeline:
workload_type: t2v
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_ltx2_distilled
served_model_name: ltx2-distilled
default_request:
sampling:
height: 1024
width: 1536
num_frames: 121
fps: 24
num_inference_steps: 8
guidance_scale: 1.0
seed: 10
output:
return_frames: false
+37
View File
@@ -0,0 +1,37 @@
# OpenAI-compatible media server for MMAudio large-44k-v2 video-to-audio.
# fastvideo serve --config examples/serving/openai_mmaudio.yaml
# Send video_reference (local path or URL). Audio uses the /v1/videos job
# endpoints and returns media_type audio/wav.
generator:
model_path: FastVideo/MMAudio-large-44k-v2-Diffusers
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
vae: false
text_encoder: false
pin_cpu_memory: false
pipeline:
workload_type: v2a
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_mmaudio
served_model_name: mmaudio-large-44k-v2
default_request:
negative_prompt: music
sampling:
audio_start_in_s: 0.0
audio_end_in_s: 8.0
num_inference_steps: 25
guidance_scale: 4.5
seed: 42
output:
return_frames: false
+24
View File
@@ -0,0 +1,24 @@
# OpenAI-compatible image server for Stable Diffusion 3.5 Medium.
# fastvideo serve --config examples/serving/openai_sd35.yaml
generator:
model_path: stabilityai/stable-diffusion-3.5-medium
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
vae: false
text_encoder: false
pin_cpu_memory: false
pipeline:
workload_type: t2i
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_sd35
served_model_name: sd35
+38
View File
@@ -0,0 +1,38 @@
# OpenAI-compatible media server for Stable Audio Open 1.0 text-to-audio.
# fastvideo serve --config examples/serving/openai_stable_audio.yaml
# Audio uses the /v1/videos job endpoints and returns media_type audio/wav.
# Requires the optional Stable Audio inference dependencies listed in
# examples/inference/basic/basic_stable_audio.py.
generator:
model_path: FastVideo/stable-audio-open-1.0-Diffusers
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: false
vae: false
text_encoder: false
pin_cpu_memory: false
pipeline:
workload_type: t2a
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_stable_audio
served_model_name: stable-audio
default_request:
negative_prompt: ""
sampling:
audio_start_in_s: 0.0
audio_end_in_s: 10.0
num_inference_steps: 100
guidance_scale: 7.0
seed: 0
output:
return_frames: false
@@ -0,0 +1,43 @@
# OpenAI-compatible server for Wan2.1 T2V 14B at 720p. This is the base
# 50-step model, not the distilled FastWan variant.
# fastvideo serve --config examples/serving/openai_wan21_t2v_14b.yaml
generator:
model_path: Wan-AI/Wan2.1-T2V-14B-Diffusers
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: true
vae: true
text_encoder: true
pin_cpu_memory: true
pipeline:
workload_type: t2v
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_wan21_t2v_14b
served_model_name: wan21-t2v-14b
default_request:
negative_prompt: >-
Bright tones, overexposed, static, blurred details, subtitles, style,
works, paintings, images, static, overall gray, worst quality, low
quality, JPEG compression residue, ugly, incomplete, extra fingers,
poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen
limbs, fused fingers, still picture, messy background, three legs, many
people in the background, walking backwards
sampling:
seed: 1024
num_frames: 81
height: 720
width: 1280
fps: 16
num_inference_steps: 50
guidance_scale: 5.0
output:
return_frames: false
@@ -0,0 +1,33 @@
# OpenAI-compatible server for Wan2.2 I2V A14B (mixture-of-experts DiT).
# fastvideo serve --config examples/serving/openai_wan22_i2v_a14b.yaml
# Send image_reference (or input_reference) with the first frame.
generator:
model_path: Wan-AI/Wan2.2-I2V-A14B-Diffusers
engine:
num_gpus: 1
use_fsdp_inference: false
parallelism:
tp_size: 1
sp_size: 1
offload:
dit: true
vae: false
text_encoder: true
pin_cpu_memory: true
pipeline:
workload_type: i2v
server:
host: 0.0.0.0
port: 8000
output_dir: outputs/openai_wan22_i2v_a14b
served_model_name: wan22-i2v-a14b
default_request:
sampling:
height: 480
width: 832
num_frames: 81
fps: 16
output:
return_frames: false
+8 -1
View File
@@ -395,7 +395,14 @@ def request_to_sampling_param(
if request.plan is not None:
raise NotImplementedError("GenerationRequest.plan is not wired into VideoGenerator yet")
sampling_param = SamplingParam.from_pretrained(model_path)
from fastvideo.registry import get_sampling_param_cls_for_name
try:
sampling_param_cls = get_sampling_param_cls_for_name(model_path, allow_remote=False) or SamplingParam
except ValueError:
# A local directory without model_index.json has no registered family; use the base defaults.
sampling_param_cls = SamplingParam
sampling_param = sampling_param_cls.from_pretrained(model_path)
if request.state is not None:
_validate_continuation_state(request.state)
sampling_param.continuation_state = request.state
+15
View File
@@ -4,6 +4,8 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
DEFAULT_MEDIA_REFERENCE_MAX_BYTES = 2 * 1024**3
@dataclass
class ServerConfig:
@@ -11,6 +13,17 @@ class ServerConfig:
port: int = 8000
output_dir: str = "outputs/"
served_model_name: str | None = None
media_reference_max_bytes: int = DEFAULT_MEDIA_REFERENCE_MAX_BYTES
# Directories whose files HTTP callers may reference by local path. Empty rejects local paths.
media_roots: list[str] = field(default_factory=list)
# Allows media URLs that resolve to loopback, private, or link-local addresses.
allow_private_media_urls: bool = False
def __post_init__(self) -> None:
if self.media_reference_max_bytes <= 0:
raise ValueError("server.media_reference_max_bytes must be positive")
if any(not isinstance(root, str) or not root.strip() for root in self.media_roots):
raise ValueError("server.media_roots entries must be non-empty paths")
@dataclass
@@ -171,6 +184,8 @@ class SamplingConfig:
height_sr: int = 1072
width_sr: int = 1920
fps: int = 24
audio_start_in_s: float | None = None
audio_end_in_s: float | None = None
num_inference_steps: int = 50
num_inference_steps_sr: int = 50
guidance_scale: float = 1.0
+6
View File
@@ -52,6 +52,7 @@ class ServeSubcommand(CLISubcommand):
from fastvideo.entrypoints.openai.api_server import (
run_server, )
from fastvideo.entrypoints.openai.media_policy import MediaPolicy
logger.info(
"Server will listen on %s:%d",
@@ -67,6 +68,11 @@ class ServeSubcommand(CLISubcommand):
output_dir=serve_config.server.output_dir,
default_request=serve_config.default_request,
served_model_name=serve_config.server.served_model_name,
media_reference_max_bytes=serve_config.server.media_reference_max_bytes,
media_policy=MediaPolicy(
roots=tuple(serve_config.server.media_roots),
allow_private_urls=serve_config.server.allow_private_media_urls,
),
)
def validate(self, args: argparse.Namespace) -> None:
+14 -1
View File
@@ -12,7 +12,8 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastvideo.api.presets import validate_preset_selection
from fastvideo.api.schema import GenerationRequest
from fastvideo.api.schema import DEFAULT_MEDIA_REFERENCE_MAX_BYTES, GenerationRequest
from fastvideo.entrypoints.openai.media_policy import MediaPolicy
from fastvideo.entrypoints.openai.state import (
DEFAULT_OUTPUT_DIR,
clear_state,
@@ -61,6 +62,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
output_dir: str = app.state.output_dir
served_model_name: str | None = app.state.served_model_name
default_request: GenerationRequest | None = getattr(app.state, "default_request", None)
media_reference_max_bytes: int = getattr(app.state, "media_reference_max_bytes", DEFAULT_MEDIA_REFERENCE_MAX_BYTES)
media_policy: MediaPolicy = getattr(app.state, "media_policy", MediaPolicy())
logger.info("Initializing %s generation runtime for %s ...", app.state.runtime, args.model_path)
factory = app.state.generator_factory
@@ -75,6 +78,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
output_dir,
default_request=default_request,
served_model_name=served_model_name,
media_reference_max_bytes=media_reference_max_bytes,
media_policy=media_policy,
)
try:
@@ -98,6 +103,8 @@ def create_app(
generator_factory: Callable[[], ServingGenerator] | None = None,
video_request_validator: Callable[[VideoGenerationRequest], None] | None = None,
runtime: str = "cuda",
media_reference_max_bytes: int = DEFAULT_MEDIA_REFERENCE_MAX_BYTES,
media_policy: MediaPolicy | None = None,
) -> FastAPI:
"""Build the FastAPI application with all routers mounted"""
@@ -117,6 +124,8 @@ def create_app(
app.state.output_dir = output_dir
app.state.default_request = default_request
app.state.served_model_name = served_model_name
app.state.media_reference_max_bytes = media_reference_max_bytes
app.state.media_policy = media_policy or MediaPolicy()
app.state.generator_factory = generator_factory
app.state.video_request_validator = video_request_validator
app.state.runtime = runtime
@@ -211,6 +220,8 @@ def run_server(
output_dir: str = DEFAULT_OUTPUT_DIR,
default_request: GenerationRequest | None = None,
served_model_name: str | None = None,
media_reference_max_bytes: int = DEFAULT_MEDIA_REFERENCE_MAX_BYTES,
media_policy: MediaPolicy | None = None,
):
"""Create the app and run it with uvicorn"""
os.environ.setdefault("FASTVIDEO_STAGE_LOGGING", "1")
@@ -222,6 +233,8 @@ def run_server(
output_dir=output_dir,
default_request=default_request,
served_model_name=served_model_name,
media_reference_max_bytes=media_reference_max_bytes,
media_policy=media_policy,
)
logger.info("Starting FastVideo server on %s:%d", host, port)
@@ -0,0 +1,113 @@
# SPDX-License-Identifier: Apache-2.0
"""Rules for media references supplied by HTTP callers.
Local paths are readable only inside operator-listed ``server.media_roots``.
URLs must use http(s) and, unless ``server.allow_private_media_urls`` is set,
must resolve to public addresses on every redirect hop. Downloads are capped
by streamed bytes and leave no partial file on failure or cancellation.
"""
from __future__ import annotations
import asyncio
import contextlib
import ipaddress
import os
import socket
from dataclasses import dataclass
from urllib.parse import urljoin, urlparse
import aiofiles
import httpx
MAX_REDIRECTS = 5
READ_TIMEOUT_S = 60.0
class MediaPolicyError(ValueError):
"""A media reference is not allowed by the server's media policy."""
@dataclass(frozen=True)
class MediaPolicy:
roots: tuple[str, ...] = ()
allow_private_urls: bool = False
def resolve_local_media(source: str, policy: MediaPolicy) -> str:
"""Return the real path of a caller-supplied local file inside a configured root."""
path = os.path.realpath(os.path.expanduser(source))
for root in policy.roots:
real_root = os.path.realpath(os.path.expanduser(root))
if os.path.commonpath([path, real_root]) == real_root:
if not os.path.isfile(path):
raise MediaPolicyError(f"Media reference does not exist: {source}")
return path
# Checked before existence so callers cannot probe the server's filesystem.
raise MediaPolicyError("Local media paths are not accepted from HTTP callers. Provide an http(s) URL, or list "
"the directory in server.media_roots in the serve config.")
def _resolve_addresses(host: str, port: int) -> list[str]:
return [str(info[4][0]) for info in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)]
async def _check_url(url: str, policy: MediaPolicy) -> None:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise MediaPolicyError("Media URLs must use http or https.")
if policy.allow_private_urls:
return
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
addresses = await asyncio.to_thread(_resolve_addresses, parsed.hostname, port)
except OSError as error:
raise MediaPolicyError(f"Unable to resolve media host {parsed.hostname}: {error}") from error
for address in addresses:
if not ipaddress.ip_address(address.split("%", 1)[0]).is_global:
raise MediaPolicyError(f"Media URL {parsed.hostname} resolves to a private or local address. Set "
"server.allow_private_media_urls for trusted internal media hosts.")
def discard(path: str) -> None:
with contextlib.suppress(OSError):
os.unlink(path)
async def fetch_media(
url: str,
target: str,
*,
max_bytes: int,
policy: MediaPolicy,
transport: httpx.AsyncBaseTransport | None = None,
) -> httpx.Headers:
"""Download ``url`` to ``target`` under ``policy``; returns the final response headers."""
os.makedirs(os.path.dirname(os.path.abspath(target)), exist_ok=True)
try:
async with httpx.AsyncClient(follow_redirects=False, timeout=READ_TIMEOUT_S, transport=transport) as client:
for _ in range(MAX_REDIRECTS + 1):
await _check_url(url, policy)
async with client.stream("GET", url) as response:
if response.is_redirect:
location = response.headers.get("location")
if not location:
raise MediaPolicyError("Media URL redirect has no location.")
url = urljoin(url, location)
continue
response.raise_for_status()
written = 0
async with aiofiles.open(target, "wb") as output:
async for chunk in response.aiter_bytes():
written += len(chunk)
if written > max_bytes:
raise MediaPolicyError(
f"Media reference is larger than the {max_bytes}-byte limit; raise "
"server.media_reference_max_bytes in the serve config or provide a smaller file.")
await output.write(chunk)
return response.headers
raise MediaPolicyError(f"Media URL redirected more than {MAX_REDIRECTS} times.")
except BaseException:
# Includes cancellation: a client disconnect must not leave a partial upload behind.
discard(target)
raise
+3 -1
View File
@@ -145,6 +145,8 @@ class VideoGenerationRequest(BaseModel):
# SGLang spelling retained as an alias-like input field.
n: int | None = Field(default=None, ge=1, le=10)
start_time_seconds: float | None = Field(default=None, ge=0.0)
audio_start_in_s: float | None = Field(default=None, ge=0.0)
audio_end_in_s: float | None = Field(default=None, gt=0.0)
quality: Literal["auto", "default", "standard", "hd"] | None = None
negative_prompt: str | None = None
num_inference_steps: int | None = Field(default=None, ge=1, le=200)
@@ -226,7 +228,7 @@ class VideoResponse(BaseModel):
expires_at: int | None = None
file_path: str | None = None
file_name: str | None = None
media_type: Literal["video/mp4"] = "video/mp4"
media_type: Literal["video/mp4", "audio/wav"] = "video/mp4"
completed_at: int | None = None
error: VideoError | None = None
peak_memory_mb: float | None = None
+167 -26
View File
@@ -7,7 +7,16 @@ import asyncio
import math
import os
from typing import Any
from urllib.parse import urlparse
from fastvideo.entrypoints.openai.media_policy import (
MediaPolicy,
MediaPolicyError,
discard,
fetch_media,
resolve_local_media,
)
from fastvideo.entrypoints.openai.state import get_media_policy, get_media_reference_max_bytes
from fastvideo.api.compat import (
REQUEST_BATCH_EXTRA_PASSTHROUGH_FIELDS,
explicit_request_updates,
@@ -19,9 +28,10 @@ from fastvideo.entrypoints.openai.protocol import (
FileImageReference,
FileVideoReference,
UrlImageReference,
UrlVideoReference,
VideoGenerationRequest,
)
from fastvideo.entrypoints.openai.utils import save_image_to_path
from fastvideo.entrypoints.openai.utils import image_suffix, save_base64_image_to_path
from fastvideo.fastvideo_args import FastVideoArgs
from fastvideo.models.vision_utils import load_image
from fastvideo.registry import get_preset_selection
@@ -31,6 +41,9 @@ class RequestAdaptationError(ValueError):
"""The transport request cannot be represented by the loaded pipeline."""
_VIDEO_SUFFIXES = frozenset({".avi", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm"})
def _as_list(value: Any | list[Any] | None) -> list[Any]:
if value is None:
return []
@@ -54,50 +67,158 @@ def _image_sources(request: VideoGenerationRequest) -> list[str]:
return sources
def discard_materialized_media(paths: list[str]) -> None:
for path in paths:
discard(path)
async def _download_media(source: str,
target: str,
*,
max_bytes: int | None = None,
policy: MediaPolicy | None = None) -> None:
try:
await fetch_media(source,
target,
max_bytes=get_media_reference_max_bytes() if max_bytes is None else max_bytes,
policy=get_media_policy() if policy is None else policy)
except MediaPolicyError as error:
raise RequestAdaptationError(str(error)) from error
def _local_media(source: str, policy: MediaPolicy) -> str:
try:
return resolve_local_media(source, policy)
except MediaPolicyError as error:
raise RequestAdaptationError(str(error)) from error
async def _materialize_video(source: str,
target_base: str,
*,
max_bytes: int | None = None,
policy: MediaPolicy | None = None) -> str:
policy = get_media_policy() if policy is None else policy
source = source.strip()
if not source:
raise RequestAdaptationError("Video references must not be empty.")
if source.lower().startswith(("http://", "https://")):
suffix = os.path.splitext(urlparse(source).path)[1].lower()
if suffix not in _VIDEO_SUFFIXES:
suffix = ".mp4"
target = f"{target_base}{suffix}"
try:
await _download_media(source, target, max_bytes=max_bytes, policy=policy)
except RequestAdaptationError:
raise
except Exception as error:
raise RequestAdaptationError(f"Unable to fetch video reference: {error}") from error
return target
if source.lower().startswith("data:"):
raise RequestAdaptationError("Data video references are not supported; provide a URL or local path.")
return _local_media(source, policy)
async def _materialize_image_url(source: str, target_base: str, *, max_bytes: int | None, policy: MediaPolicy) -> str:
target = f"{target_base}{image_suffix(source)}"
try:
await _download_media(source, target, max_bytes=max_bytes, policy=policy)
except RequestAdaptationError:
raise
except Exception as error:
raise RequestAdaptationError(f"Unable to fetch image reference: {error}") from error
return target
async def prepare_reference_media(
request_id: str,
request: VideoGenerationRequest,
output_dir: str,
) -> None:
"""Materialize and decode image references before a job reaches workers."""
*,
max_bytes: int | None = None,
policy: MediaPolicy | None = None,
) -> list[str]:
"""Materialize media references and return the files created for this request."""
policy = get_media_policy() if policy is None else policy
_image_sources(request)
_video_sources(request)
uploads_dir = os.path.join(os.path.abspath(output_dir), "uploads")
created: list[str] = []
def track(path: str) -> str:
if path not in created:
created.append(path)
return path
def is_remote(source: str) -> bool:
return source.lower().startswith(("http://", "https://", "data:image"))
async def materialize(source: str, index: int) -> str:
source = source.strip()
if not source:
raise RequestAdaptationError("Image references must not be empty.")
if source.lower().startswith(("http://", "https://", "data:image")):
target = os.path.join(uploads_dir, f"{request_id}_{index}")
target = os.path.join(uploads_dir, f"{request_id}_{index}")
if source.startswith("data:image"):
try:
local_path = await save_image_to_path(source, target)
local_path = track(await save_base64_image_to_path(source, target))
except Exception as error:
raise RequestAdaptationError(f"Unable to fetch image reference: {error}") from error
raise RequestAdaptationError(f"Unable to decode image reference: {error}") from error
elif is_remote(source):
local_path = track(await _materialize_image_url(source, target, max_bytes=max_bytes, policy=policy))
else:
local_path = os.path.abspath(os.path.expanduser(source))
if not os.path.isfile(local_path):
raise RequestAdaptationError(f"Image reference does not exist: {source}")
local_path = _local_media(source, policy)
try:
await asyncio.to_thread(load_image, local_path)
except Exception as error:
raise RequestAdaptationError(f"Unable to decode image reference: {error}") from error
return local_path
index = 0
references = _as_list(request.image_reference)
for reference in references:
if isinstance(reference, UrlImageReference):
reference.image_url = await materialize(reference.image_url, index)
try:
index = 0
for reference in _as_list(request.image_reference):
if isinstance(reference, UrlImageReference):
reference.image_url = await materialize(reference.image_url, index)
index += 1
if request.input_reference:
request.input_reference = await materialize(request.input_reference, index)
index += 1
if request.input_reference:
request.input_reference = await materialize(request.input_reference, index)
index += 1
elif request.input_reference == "":
request.input_reference = None
if request.reference_url:
request.reference_url = await materialize(request.reference_url, index)
elif request.reference_url == "":
request.reference_url = None
elif request.input_reference == "":
request.input_reference = None
if request.reference_url:
request.reference_url = await materialize(request.reference_url, index)
elif request.reference_url == "":
request.reference_url = None
for index, reference in enumerate(_as_list(request.video_reference)):
if isinstance(reference, UrlVideoReference):
remote = is_remote(reference.video_url)
reference.video_url = await _materialize_video(reference.video_url,
os.path.join(uploads_dir, f"{request_id}_video_{index}"),
max_bytes=max_bytes,
policy=policy)
if remote:
track(reference.video_url)
if request.video_path:
remote = is_remote(request.video_path)
request.video_path = await _materialize_video(request.video_path,
os.path.join(uploads_dir, f"{request_id}_video_path"),
max_bytes=max_bytes,
policy=policy)
if remote:
track(request.video_path)
if request.video_url:
remote = is_remote(request.video_url)
request.video_url = await _materialize_video(request.video_url,
os.path.join(uploads_dir, f"{request_id}_video_url"),
max_bytes=max_bytes,
policy=policy)
if remote:
track(request.video_url)
except BaseException:
# Includes cancellation, so a disconnected client leaves no downloaded inputs behind.
discard_materialized_media(created)
raise
return created
def _video_sources(request: VideoGenerationRequest) -> list[str]:
@@ -288,6 +409,9 @@ def build_generation_request(
) -> GenerationRequest:
"""Build one tracked FastVideo request using explicit-field precedence."""
validate_model_and_lora(request, args, served_model_name)
workload_type = getattr(args, "workload_type", "")
workload_value = str(getattr(workload_type, "value", workload_type) or "")
is_audio = workload_value.endswith("2a")
kwargs: dict[str, Any] = {}
if default_request is not None:
kwargs.update(explicit_request_updates(default_request))
@@ -323,7 +447,7 @@ def build_generation_request(
num_frames = request.num_frames if "num_frames" in body_set else request.video_params.num_frames
if num_frames is not None:
kwargs["num_frames"] = num_frames
elif "seconds" in body_set and request.seconds is not None:
elif not is_audio and "seconds" in body_set and request.seconds is not None:
kwargs["num_frames"] = int(request.seconds) * int(kwargs["fps"])
direct_fields = (
@@ -336,12 +460,25 @@ def build_generation_request(
"enable_teacache",
"max_sequence_length",
"boundary_ratio",
"audio_start_in_s",
"audio_end_in_s",
)
for name in direct_fields:
if name in body_set:
value = getattr(request, name)
if value is not None:
kwargs[name] = value
if is_audio and "seconds" in body_set and request.seconds is not None and "audio_end_in_s" not in body_set:
start = float(kwargs.get("audio_start_in_s") or 0.0)
kwargs["audio_start_in_s"] = start
kwargs["audio_end_in_s"] = start + float(int(request.seconds))
if is_audio:
if {"size", "width", "height", "video_params"} & body_set:
raise RequestAdaptationError("Audio generation does not accept size, width, height, or video_params.")
start = float(kwargs.get("audio_start_in_s") or 0.0)
end = kwargs.get("audio_end_in_s")
if end is not None and float(end) <= start:
raise RequestAdaptationError(f"audio_end_in_s ({end}) must be greater than audio_start_in_s ({start}).")
if "n" in body_set or "num_outputs_per_prompt" in body_set:
kwargs["num_videos_per_prompt"] = request.resolved_num_outputs
@@ -355,6 +492,8 @@ def build_generation_request(
raise RequestAdaptationError("short_edge requires aspect_ratio.")
_apply_aspect_ratio(kwargs, request, model_family=model_family)
_apply_reference_inputs(kwargs, request, args, model_family=model_family)
if model_family == "mmaudio" and not workload_value.endswith("t2a") and not kwargs.get("video_path"):
raise RequestAdaptationError("MMAudio video-to-audio generation requires video_reference or video_path.")
extension_fields = ("flow_shift", "sound_duration", "start_time_seconds")
for name in extension_fields:
@@ -411,7 +550,9 @@ def build_generation_request(
f"got {requested_num_frames}, next valid value is {aligned_num_frames}.")
kwargs["num_frames"] = aligned_num_frames
output_path = os.path.join(os.path.abspath(output_dir), "videos", f"{request_id}.mp4")
media_dir = "audio" if is_audio else "videos"
extension = ".wav" if is_audio else ".mp4"
output_path = os.path.join(os.path.abspath(output_dir), media_dir, f"{request_id}{extension}")
os.makedirs(os.path.dirname(output_path), exist_ok=True)
kwargs.update({
"output_path": output_path,
+23
View File
@@ -9,6 +9,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from fastvideo.api.schema import DEFAULT_MEDIA_REFERENCE_MAX_BYTES
from fastvideo.entrypoints.openai.media_policy import MediaPolicy
if TYPE_CHECKING:
from fastvideo.api.schema import GenerationRequest
from fastvideo.entrypoints.openai.serving_engine import OpenAIServingEngine, ServingGenerator
@@ -22,6 +25,8 @@ _fastvideo_args: FastVideoArgs | None = None
_output_dir: str = DEFAULT_OUTPUT_DIR
_served_model_name: str | None = None
_default_request: GenerationRequest | None = None
_media_reference_max_bytes: int = DEFAULT_MEDIA_REFERENCE_MAX_BYTES
_media_policy: MediaPolicy = MediaPolicy()
def get_generator() -> ServingGenerator:
@@ -60,6 +65,16 @@ def get_default_request() -> GenerationRequest | None:
return _default_request
def get_media_reference_max_bytes() -> int:
"""Return the server's URL media-reference byte cap."""
return _media_reference_max_bytes
def get_media_policy() -> MediaPolicy:
"""Return the local-root and private-address rules for caller media references."""
return _media_policy
def set_state(
generator: ServingGenerator,
serving_engine: OpenAIServingEngine,
@@ -67,22 +82,30 @@ def set_state(
output_dir: str,
default_request: GenerationRequest | None = None,
served_model_name: str | None = None,
media_reference_max_bytes: int = DEFAULT_MEDIA_REFERENCE_MAX_BYTES,
media_policy: MediaPolicy | None = None,
) -> None:
"""Set all server state at once (called from lifespan)."""
global _generator, _serving_engine, _fastvideo_args, _output_dir, _served_model_name, _default_request
global _media_reference_max_bytes, _media_policy
_generator = generator
_serving_engine = serving_engine
_fastvideo_args = fastvideo_args
_output_dir = output_dir
_served_model_name = served_model_name
_default_request = default_request
_media_reference_max_bytes = media_reference_max_bytes
_media_policy = media_policy or MediaPolicy()
def clear_state() -> None:
"""Clear server state on shutdown."""
global _generator, _serving_engine, _fastvideo_args, _served_model_name, _default_request
global _media_reference_max_bytes, _media_policy
_generator = None
_serving_engine = None
_fastvideo_args = None
_served_model_name = None
_default_request = None
_media_reference_max_bytes = DEFAULT_MEDIA_REFERENCE_MAX_BYTES
_media_policy = MediaPolicy()
+21 -26
View File
@@ -7,7 +7,6 @@ import re
from typing import Any
import aiofiles
import httpx
from fastapi import UploadFile
from fastvideo.logger import init_logger
@@ -59,41 +58,37 @@ async def _maybe_url_image(img_url: str, target_path: str) -> str | None:
if img_url.lower().startswith(("http://", "https://")):
return await _save_url_image_to_path(img_url, target_path)
elif img_url.startswith("data:image"):
return await _save_base64_image_to_path(img_url, target_path)
return await save_base64_image_to_path(img_url, target_path)
else:
raise ValueError("Unsupported image url format")
_IMAGE_SUFFIXES = frozenset({".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"})
def image_suffix(image_url: str) -> str:
"""File suffix for a downloaded image; decoding inspects content, so this is only a hint."""
suffix = os.path.splitext(image_url.split("?")[0])[1].lower()
if suffix not in _IMAGE_SUFFIXES:
return ".jpg"
return ".jpg" if suffix == ".jpeg" else suffix
async def _save_url_image_to_path(image_url: str, target_path: str) -> str:
os.makedirs(os.path.dirname(target_path), exist_ok=True)
# Local imports: state imports serving modules that import these helpers.
from fastvideo.entrypoints.openai.media_policy import fetch_media
from fastvideo.entrypoints.openai.state import get_media_policy, get_media_reference_max_bytes
if not os.path.splitext(target_path)[1]:
target_path = f"{target_path}{image_suffix(image_url)}"
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.get(image_url, timeout=30.0)
response.raise_for_status()
if not os.path.splitext(target_path)[1]:
content_type = response.headers.get("content-type", "").lower()
url_path = image_url.split("?")[0]
_, url_ext = os.path.splitext(url_path)
url_ext = url_ext.lower()
if url_ext in {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}:
ext = ".jpg" if url_ext == ".jpeg" else url_ext
elif "png" in content_type:
ext = ".png"
elif "webp" in content_type:
ext = ".webp"
else:
ext = ".jpg"
target_path = f"{target_path}{ext}"
async with aiofiles.open(target_path, "wb") as f:
await f.write(response.content)
return target_path
await fetch_media(image_url, target_path, max_bytes=get_media_reference_max_bytes(), policy=get_media_policy())
except Exception as e:
raise RuntimeError(f"Failed to download image from URL: {e}") from e
return target_path
async def _save_base64_image_to_path(base64_data: str, target_path: str) -> str:
async def save_base64_image_to_path(base64_data: str, target_path: str) -> str:
pattern = r"data:(.*?)(;base64)?,(.*)"
match = re.match(pattern, base64_data)
if not match or not match.group(2):
+95 -50
View File
@@ -28,11 +28,13 @@ from fastvideo.entrypoints.openai.protocol import (
)
from fastvideo.entrypoints.openai.request_adapter import (
build_generation_request,
discard_materialized_media,
prepare_reference_media,
validate_model_and_lora,
)
from fastvideo.entrypoints.openai.state import (
get_default_request,
get_media_reference_max_bytes,
get_output_dir,
get_served_model_name,
get_server_args,
@@ -113,6 +115,8 @@ def _build_generation_kwargs(
"enable_teacache",
"max_sequence_length",
"boundary_ratio",
"audio_start_in_s",
"audio_end_in_s",
):
if name in body_set and getattr(req, name) is not None:
kwargs[name] = getattr(req, name)
@@ -122,9 +126,11 @@ def _build_generation_kwargs(
kwargs["image_path"] = req.input_reference
kwargs.pop("output_path", None)
output_dir = os.path.join(os.path.abspath(get_output_dir()), "videos")
is_audio = _is_audio_workload()
output_dir = os.path.join(os.path.abspath(get_output_dir()), "audio" if is_audio else "videos")
os.makedirs(output_dir, exist_ok=True)
kwargs["output_path"] = os.path.join(output_dir, f"{request_id}.mp4")
extension = ".wav" if is_audio else ".mp4"
kwargs["output_path"] = os.path.join(output_dir, f"{request_id}{extension}")
kwargs["save_video"] = True
return kwargs
@@ -135,13 +141,27 @@ def _result_value(result: Any, name: str, default: Any = None) -> Any:
return getattr(result, name, default)
def _remove_artifact(file_path: str | None) -> None:
if not file_path or not os.path.isfile(file_path):
return
def _is_audio_workload() -> bool:
try:
os.unlink(file_path)
except OSError:
logger.warning("Failed to delete video artifact %s", file_path, exc_info=True)
args = get_server_args()
except AssertionError:
return False
workload_type = getattr(args, "workload_type", "")
return str(getattr(workload_type, "value", workload_type) or "").endswith("2a")
def _media_type_for_path(path: str) -> str:
return "audio/wav" if path.lower().endswith(".wav") else "video/mp4"
def _remove_artifact(file_path: str | None) -> None:
if file_path:
discard_materialized_media([file_path])
def _remove_artifacts(file_path: str | None, materialized: list[str]) -> None:
_remove_artifact(file_path)
discard_materialized_media(materialized)
def _stage_durations(result: Any) -> dict[str, float]:
@@ -162,8 +182,14 @@ def _make_video_job(
generation_request: GenerationRequest,
) -> dict[str, Any]:
sampling = request_to_sampling_param(generation_request, model_path=get_server_args().model_path)
size = f"{sampling.width}x{sampling.height}" if sampling.width and sampling.height else None
seconds = int(round(sampling.num_frames / sampling.fps)) if sampling.fps else int(req.seconds or 4)
is_audio = _is_audio_workload()
size = None if is_audio else (f"{sampling.width}x{sampling.height}" if sampling.width and sampling.height else None)
if is_audio and sampling.audio_end_in_s is not None:
start = sampling.audio_start_in_s or 0.0
seconds = max(1, int(round(sampling.audio_end_in_s - start)))
else:
seconds = int(round(sampling.num_frames / sampling.fps)) if sampling.fps else int(req.seconds or 4)
media_type = "audio/wav" if is_audio else "video/mp4"
return {
"id": request_id,
"object": "video",
@@ -175,6 +201,7 @@ def _make_video_job(
"size": size,
"seconds": str(max(1, seconds)),
"quality": req.quality or "default",
"media_type": media_type,
# ``file_path`` is a FastVideo compatibility extension. vLLM-Omni's
# public job shape uses ``file_name`` after completion.
"file_path": None,
@@ -185,6 +212,7 @@ def _make_video_job(
async def _run_generation(
request_id: str,
generation_request: GenerationRequest,
materialized_media: list[str] | None = None,
) -> None:
started = 0.0
video_path = generation_request.output.output_path
@@ -242,11 +270,8 @@ async def _run_generation(
finally:
if request_id in _DELETED_VIDEO_IDS:
_DELETED_VIDEO_IDS.discard(request_id)
if video_path and os.path.isfile(video_path):
try:
os.unlink(video_path)
except OSError:
logger.warning("Failed to clean up deleted video artifact %s", video_path, exc_info=True)
_remove_artifact(video_path)
discard_materialized_media(materialized_media or [])
def _track_video_job(request_id: str, task: asyncio.Task[None]) -> None:
@@ -329,12 +354,16 @@ async def _parse_video_request(raw_request: Request) -> VideoGenerationRequest:
raise HTTPException(status_code=400, detail=f"Invalid request body: {error}") from error
async def _adapt_request(request_id: str, request: VideoGenerationRequest) -> GenerationRequest:
async def _adapt_request(request_id: str, request: VideoGenerationRequest) -> tuple[GenerationRequest, list[str]]:
materialized_media: list[str] = []
try:
get_serving_engine().validate_video_request(request)
validate_model_and_lora(request, get_server_args(), get_served_model_name())
await prepare_reference_media(request_id, request, get_output_dir())
return await asyncio.to_thread(
materialized_media = await prepare_reference_media(request_id,
request,
get_output_dir(),
max_bytes=get_media_reference_max_bytes())
generation_request = await asyncio.to_thread(
build_generation_request,
request_id,
request,
@@ -343,7 +372,9 @@ async def _adapt_request(request_id: str, request: VideoGenerationRequest) -> Ge
output_dir=get_output_dir(),
default_request=get_default_request(),
)
return generation_request, materialized_media
except Exception as error:
discard_materialized_media(materialized_media)
raise HTTPException(status_code=400, detail=str(error)) from error
@@ -353,47 +384,59 @@ async def create_video(raw_request: Request) -> VideoResponse:
"""Create an asynchronous video generation job."""
request = await _parse_video_request(raw_request)
request_id = f"video_gen_{generate_request_id()}"
generation_request = await _adapt_request(request_id, request)
job = _make_video_job(request_id, request, generation_request)
await VIDEO_STORE.upsert(request_id, job)
task = asyncio.create_task(_run_generation(request_id, generation_request), name=f"video-job-{request_id}")
generation_request, materialized_media = await _adapt_request(request_id, request)
try:
job = _make_video_job(request_id, request, generation_request)
await VIDEO_STORE.upsert(request_id, job)
except Exception:
discard_materialized_media(materialized_media)
raise
task = asyncio.create_task(_run_generation(request_id, generation_request, materialized_media),
name=f"video-job-{request_id}")
task.add_done_callback(lambda _: discard_materialized_media(materialized_media))
_track_video_job(request_id, task)
return VideoResponse(**job)
@router.post("/sync")
async def create_video_sync(raw_request: Request) -> FileResponse:
"""Generate synchronously and return raw MP4 bytes with vLLM headers."""
"""Generate synchronously and return the generated media with vLLM headers."""
request = await _parse_video_request(raw_request)
request_id = f"video_sync-{generate_request_id()}"
generation_request = await _adapt_request(request_id, request)
generation_request, materialized_media = await _adapt_request(request_id, request)
started = time.perf_counter()
response: FileResponse | None = None
try:
result = await get_serving_engine().generate(generation_request)
except Exception as error:
logger.exception("Sync video generation failed for %s", request_id)
raise HTTPException(status_code=500, detail=f"Video generation failed: {error}") from error
if isinstance(result, list):
if not result:
raise HTTPException(status_code=500, detail="FastVideo returned no generation results")
result = result[0]
elapsed = time.perf_counter() - started
video_path = _result_value(result, "video_path") or generation_request.output.output_path
if not video_path or not os.path.exists(video_path):
raise HTTPException(status_code=500, detail="FastVideo did not produce an MP4 file")
return FileResponse(
video_path,
media_type="video/mp4",
filename=os.path.basename(video_path),
headers={
"X-Request-Id": request_id,
"X-Model": get_served_model_name(),
"X-Inference-Time-S": f"{elapsed:.3f}",
"X-Stage-Durations": json.dumps(_stage_durations(result), separators=(",", ":")),
"X-Peak-Memory-MB": f"{float(_result_value(result, 'peak_memory_mb', 0.0) or 0.0):.3f}",
},
background=BackgroundTask(_remove_artifact, video_path),
)
try:
result = await get_serving_engine().generate(generation_request)
except Exception as error:
logger.exception("Sync video generation failed for %s", request_id)
raise HTTPException(status_code=500, detail=f"Video generation failed: {error}") from error
if isinstance(result, list):
if not result:
raise HTTPException(status_code=500, detail="FastVideo returned no generation results")
result = result[0]
elapsed = time.perf_counter() - started
video_path = _result_value(result, "video_path") or generation_request.output.output_path
if not video_path or not os.path.exists(video_path):
raise HTTPException(status_code=500, detail="FastVideo did not produce a media file")
response = FileResponse(
video_path,
media_type=_media_type_for_path(video_path),
filename=os.path.basename(video_path),
headers={
"X-Request-Id": request_id,
"X-Model": get_served_model_name(),
"X-Inference-Time-S": f"{elapsed:.3f}",
"X-Stage-Durations": json.dumps(_stage_durations(result), separators=(",", ":")),
"X-Peak-Memory-MB": f"{float(_result_value(result, 'peak_memory_mb', 0.0) or 0.0):.3f}",
},
background=BackgroundTask(_remove_artifacts, video_path, materialized_media),
)
return response
finally:
if response is None:
discard_materialized_media(materialized_media)
@router.get("", response_model=VideoListResponse)
@@ -470,7 +513,9 @@ async def download_video_content(video_id: str = Path(...), variant: str | None
raise HTTPException(status_code=404, detail="Generation is still in-progress")
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Generated video file not found on disk")
return FileResponse(path=file_path, media_type="video/mp4", filename=os.path.basename(file_path))
return FileResponse(path=file_path,
media_type=_media_type_for_path(file_path),
filename=os.path.basename(file_path))
__all__ = [
+3
View File
@@ -231,6 +231,9 @@ class FastVideoArgs:
video_decode_backend: str = "h3-vae"
taeh3_checkpoint: str | None = None
taeh3_chunk_size: int = 5
# Exported NVFP4 MiniMax-H3 decoder file (decoder-nvfp4.safetensors or decoder.safetensors) that replaces
# the VAE decoder at load. Overrides FASTVIDEO_H3_VAE_NVFP4_DECODER. Needs Blackwell FP4 hardware.
h3_vae_nvfp4_decoder: str | None = None
# Load each heavy component on first use and free it once the last stage
# that holds it has run, instead of keeping every component resident from
+15 -11
View File
@@ -828,12 +828,15 @@ class VAELoader(ComponentLoader):
return None
@staticmethod
def _defers_h3_nvfp4_decoder(class_name: str | None) -> bool:
return class_name == "AutoencoderKLMiniMaxH3" and bool(envs.FASTVIDEO_H3_VAE_NVFP4_DECODER.get())
def _h3_nvfp4_decoder_path(class_name: str | None, fastvideo_args: FastVideoArgs) -> str | None:
"""The NVFP4 decoder to swap in: the serve-config field, else ``FASTVIDEO_H3_VAE_NVFP4_DECODER``."""
if class_name != "AutoencoderKLMiniMaxH3":
return None
return getattr(fastvideo_args, "h3_vae_nvfp4_decoder", None) or envs.FASTVIDEO_H3_VAE_NVFP4_DECODER.get()
@staticmethod
def _apply_h3_nvfp4_decoder(vae: nn.Module, path: str, target_device: torch.device) -> None:
"""Swap in an exported NVFP4 MiniMax-H3 decoder (``FASTVIDEO_H3_VAE_NVFP4_DECODER``)."""
"""Swap in an exported NVFP4 MiniMax-H3 decoder (``h3_vae_nvfp4_decoder``)."""
from fastvideo.models.vaes.minimax_h3_nvfp4_decoder import (
apply_nvfp4_decoder_checkpoint,
freeze_nvfp4_linears,
@@ -841,9 +844,9 @@ class VAELoader(ComponentLoader):
)
if not os.path.isfile(path):
raise FileNotFoundError(f"FASTVIDEO_H3_VAE_NVFP4_DECODER={path} does not exist")
raise FileNotFoundError(f"NVFP4 H3 decoder {path} does not exist")
loaded_blocks = len(vae.decoder.transformer_blocks)
# The swap runs where the VAE was built (the CPU, see ``_defers_h3_nvfp4_decoder``); a pre-packed
# The swap runs where the VAE was built (the CPU, see ``_h3_nvfp4_decoder_path``); a pre-packed
# file never allocates the quantized linears' full-precision weights, a bf16 file packs on the GPU.
device = next(vae.parameters()).device
metadata = apply_nvfp4_decoder_checkpoint(vae, load_nvfp4_decoder_checkpoint(path, device), freeze=False)
@@ -978,7 +981,7 @@ class VAELoader(ComponentLoader):
vae = vae_cls(vae_config)
# An NVFP4 H3 decoder is swapped in on the CPU first, so the dense decoder it replaces
# never reaches the GPU; the VAE moves to the target device after the swap.
if not self._defers_h3_nvfp4_decoder(class_name):
if not self._h3_nvfp4_decoder_path(class_name, fastvideo_args):
vae = vae.to(target_device)
# Find all safetensors files
@@ -990,9 +993,9 @@ class VAELoader(ComponentLoader):
int8_convrot_overlay_to_apply,
)
int8_convrot_path = int8_convrot_overlay_to_apply(model_path)
if int8_convrot_path is not None and envs.FASTVIDEO_H3_VAE_NVFP4_DECODER.get():
logger.info("Skipping MiniMax-H3 INT8 ConvRot VAE overlay %s: FASTVIDEO_H3_VAE_NVFP4_DECODER "
"replaces the decoder", int8_convrot_path)
if int8_convrot_path is not None and self._h3_nvfp4_decoder_path(class_name, fastvideo_args):
logger.info("Skipping MiniMax-H3 INT8 ConvRot VAE overlay %s: the NVFP4 decoder replaces the "
"decoder", int8_convrot_path)
int8_convrot_path = None
safetensors_list = dense_vae_safetensors(safetensors_list)
if not safetensors_list:
@@ -1035,8 +1038,9 @@ class VAELoader(ComponentLoader):
if class_name == "AutoencoderKLMiniMaxH3" and int8_convrot_path is not None:
from fastvideo.models.vaes.minimax_h3_int8_convrot import overlay_minimax_h3_int8_convrot_decoder
overlay_minimax_h3_int8_convrot_decoder(vae, int8_convrot_path)
if class_name == "AutoencoderKLMiniMaxH3" and envs.FASTVIDEO_H3_VAE_NVFP4_DECODER.get():
self._apply_h3_nvfp4_decoder(vae, envs.FASTVIDEO_H3_VAE_NVFP4_DECODER.get(), target_device)
nvfp4_decoder = self._h3_nvfp4_decoder_path(class_name, fastvideo_args)
if nvfp4_decoder:
self._apply_h3_nvfp4_decoder(vae, nvfp4_decoder, target_device)
if (class_name == "AutoencoderKLWan" and getattr(vae.config, "use_light_vae", False)
and target_device.type == "cuda" and hasattr(vae, "optimize_memory_format")):
vae.optimize_memory_format()
+6 -3
View File
@@ -220,6 +220,7 @@ def _get_config_info(
*,
raise_on_missing: bool = True,
revision: str | None = None,
allow_remote: bool = True,
) -> ConfigInfo | None:
# 1. Exact match
if model_path in _MODEL_HF_PATH_TO_NAME:
@@ -240,8 +241,10 @@ def _get_config_info(
# 3. Use detectors (path or model_index pipeline name).
if os.path.exists(model_path):
config = verify_model_config_and_directory(model_path, required_component_dirs=[])
else:
elif allow_remote:
config = maybe_download_model_index(model_path, revision=revision)
else:
return None
pipeline_name = (_resolve_class_name(config) or "").lower()
scheduler = config.get("scheduler")
@@ -1277,8 +1280,8 @@ def get_pipeline_config_cls_from_name(pipeline_name_or_path: str) -> type[Pipeli
return config_info.pipeline_config_cls
def get_sampling_param_cls_for_name(pipeline_name_or_path: str) -> Any | None:
config_info = _get_config_info(pipeline_name_or_path, raise_on_missing=False)
def get_sampling_param_cls_for_name(pipeline_name_or_path: str, *, allow_remote: bool = True) -> Any | None:
config_info = _get_config_info(pipeline_name_or_path, raise_on_missing=False, allow_remote=allow_remote)
if config_info is None:
logger.warning("No match found for pipeline %s, using default sampling param.", pipeline_name_or_path)
return None
+21 -2
View File
@@ -473,7 +473,14 @@ def test_serve_subcommand_dispatches_via_typed_config(tmp_path, monkeypatch):
captured["config"] = config
return SimpleNamespace(model_path=config.model_path)
def fake_run_server(fastvideo_args, host, port, output_dir, default_request, served_model_name=None):
def fake_run_server(fastvideo_args,
host,
port,
output_dir,
default_request,
served_model_name=None,
media_reference_max_bytes=None,
media_policy=None):
captured["fastvideo_args"] = fastvideo_args
captured["host"] = host
captured["port"] = port
@@ -512,8 +519,17 @@ def test_serve_subcommand_forwards_default_request(tmp_path, monkeypatch):
def fake_generator_config_to_fastvideo_args(config):
return SimpleNamespace(model_path=config.model_path)
def fake_run_server(fastvideo_args, host, port, output_dir, default_request, served_model_name=None):
def fake_run_server(fastvideo_args,
host,
port,
output_dir,
default_request,
served_model_name=None,
media_reference_max_bytes=None,
media_policy=None):
captured["default_request"] = default_request
captured["media_reference_max_bytes"] = media_reference_max_bytes
captured["media_policy"] = media_policy
monkeypatch.setattr(
"fastvideo.entrypoints.cli.serve.generator_config_to_fastvideo_args",
@@ -526,6 +542,9 @@ def test_serve_subcommand_forwards_default_request(tmp_path, monkeypatch):
default_request = captured["default_request"]
assert default_request.prompt == "hello"
assert default_request.sampling.seed == 42
# Without server.media_roots or allow_private_media_urls the CLI passes the closed policy.
assert captured["media_policy"].roots == ()
assert captured["media_policy"].allow_private_urls is False
def test_main_rejects_top_level_config_without_subcommand(tmp_path, monkeypatch):
+3
View File
@@ -37,6 +37,9 @@ def test_serve_config_includes_server_and_default_request_defaults() -> None:
"port": 8000,
"output_dir": "outputs/",
"served_model_name": None,
"media_reference_max_bytes": 2 * 1024**3,
"media_roots": [],
"allow_private_media_urls": False,
}
assert dumped["default_request"]["sampling"]["fps"] == 24
assert dumped["default_request"]["runtime"]["enable_teacache"] is False
+2
View File
@@ -226,6 +226,8 @@ def test_load_run_config_supports_yaml_roundtrip(tmp_path) -> None:
"height_sr": 1072,
"width_sr": 1920,
"fps": 24,
"audio_start_in_s": None,
"audio_end_in_s": None,
"num_inference_steps": 50,
"num_inference_steps_sr": 50,
"guidance_scale": 1.0,

Some files were not shown because too many files have changed in this diff Show More