[feat] Cookbook serving, hardware catalog, and landing page (#1999)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
This commit is contained in:
co-authored by
Aryan Kumar
parent
137d9110ca
commit
089a5a55a0
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Generated
+77
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
+1067
-95
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/Users/aryank/os/FastVideo-review-int/docs/node_modules
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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" };
|
||||
|
||||
+766
-163
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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}`);
|
||||
@@ -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()
|
||||
Executable
+54
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user