[docs] Unify cookbook model editors and launch commands (#1970)
Co-authored-by: Aryan Kumar <aryan5v@users.noreply.github.com>
This commit is contained in:
co-authored by
Aryan Kumar
parent
3f45f2da21
commit
25940a7e86
@@ -1,4 +1,4 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export default function HardwarePicker({
|
||||
@@ -14,10 +14,11 @@ export default function HardwarePicker({
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [custom, setCustom] = useState(() => {
|
||||
const [custom, setCustom] = useState("");
|
||||
useEffect(() => {
|
||||
const requested = new URL(window.location.href).searchParams.get("hardware");
|
||||
return requested?.startsWith("custom:") ? requested.slice(7) : "";
|
||||
});
|
||||
setCustom(requested?.startsWith("custom:") ? requested.slice(7) : "");
|
||||
}, []);
|
||||
const id = useId();
|
||||
const known = options.filter((option) => option.join(" ").toLowerCase().includes(search.toLowerCase()));
|
||||
function add() {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import ModelHeader from "./ModelHeader";
|
||||
import ComponentPlan from "./ComponentPlan";
|
||||
import { sitePath, type DirectoryModel } from "@/lib/cookbook-pages";
|
||||
|
||||
export default function ModelRelease({ model }: { model: DirectoryModel }) {
|
||||
return (
|
||||
<div className="cookbook not-prose recipe-detail">
|
||||
<ModelHeader model={model} />
|
||||
<div className="release-intro">
|
||||
<p>FastH3 Trim is the smaller FastH3 model, previously called CompactH3.</p>
|
||||
<p>A maintained serving recipe is being prepared. Use the published checkpoint instructions for setup.</p>
|
||||
</div>
|
||||
<section className="recipe-matrix" aria-label="Model releases">
|
||||
<div className="matrix-row">
|
||||
<h2>Weights</h2>
|
||||
<div className="matrix-row-content">
|
||||
<div className="option-chips">
|
||||
{[
|
||||
["BF16", "FastVideo-FastH3-Trim-8-Step"],
|
||||
["FP8", "FastVideo-FastH3-Trim-8-Step-FP8"],
|
||||
["NVFP4", "FastVideo-FastH3-Trim-8-Step-NVFP4"],
|
||||
].map(([name, repo]) => (
|
||||
<a className="option-chip-static" key={repo} href={`https://huggingface.co/FastVideo/${repo}`}>
|
||||
{name} ↗
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="matrix-row">
|
||||
<h2>Components</h2>
|
||||
<div className="matrix-row-content">
|
||||
<ComponentPlan />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="recipe-source-links">
|
||||
<a href={sitePath("/inference/fasth3-distilled")}>FastH3 checkpoint guide →</a>
|
||||
<a href="https://huggingface.co/collections/FastVideo/fasth3-v2">Release collection ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: FastH3 Trim
|
||||
description: Configure FastH3 Trim NVFP4 and copy inference or serving commands.
|
||||
mode: frame
|
||||
---
|
||||
|
||||
<Cookbook modelId="fasth3-trim" />
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 121 KiB |
+181
-46
@@ -8,8 +8,8 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import SourceRecipe from "./SourceRecipe";
|
||||
import ComponentPlan from "@/components/cookbook/ComponentPlan";
|
||||
import HardwarePicker from "@/components/cookbook/HardwarePicker";
|
||||
import ModelRelease from "@/components/cookbook/ModelRelease";
|
||||
import ModelHeader from "@/components/cookbook/ModelHeader";
|
||||
import { configAndLaunch, inferenceOutput, sdkCommand } from "@/lib/cookbook-commands";
|
||||
import {
|
||||
api,
|
||||
guideUrl,
|
||||
@@ -194,14 +194,23 @@ function Editor({
|
||||
}) {
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [componentDraft, setComponentDraft] = useState(false);
|
||||
const [runMode, setRunMode] = useState("serving");
|
||||
const [sourceImage, setSourceImage] = useState("");
|
||||
const sourceImageId = useId();
|
||||
const [showEnv, setShowEnv] = useState(false);
|
||||
const [showClient, setShowClient] = useState(false);
|
||||
const [outputTab, setOutputTab] = useState("commands");
|
||||
const [hardware, setHardware] = useState(
|
||||
() => new URL(window.location.href).searchParams.get("hardware") ?? "unspecified",
|
||||
() =>
|
||||
new URL(window.location.href).searchParams.get("hardware") ??
|
||||
(catalog.id.startsWith("fasth3-trim/") ? "spark" : "unspecified"),
|
||||
);
|
||||
const [edits, setEdits] = useState<Record<string, Value>>({});
|
||||
const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
|
||||
const [reset, setReset] = useState(0);
|
||||
const [notice, setNotice] = useState("");
|
||||
let result: Result | undefined;
|
||||
let output: ReturnType<typeof inferenceOutput> | undefined;
|
||||
let failure = "";
|
||||
let failedPath: string | undefined;
|
||||
try {
|
||||
@@ -209,11 +218,18 @@ function Editor({
|
||||
throw new Error("Component preview selected. A compatible serving recipe is needed before generating YAML.");
|
||||
if (Object.keys(parseErrors).length) throw new Error("Correct the highlighted fields to generate a configuration.");
|
||||
result = api.resolveConfig(catalog, edits);
|
||||
output =
|
||||
runMode === "serving" ? result : inferenceOutput(result, catalog.workload === "i2v" ? sourceImage : undefined);
|
||||
} catch (error) {
|
||||
failure = errorText(error);
|
||||
failedPath = (error as { path?: string }).path;
|
||||
}
|
||||
const fields = api.getOptions(catalog, catalog.base_config, edits);
|
||||
const launchCommand =
|
||||
output &&
|
||||
(runMode === "sdk"
|
||||
? output.command.replace(/fastvideo generate --config config\.yaml$/, sdkCommand)
|
||||
: output.command);
|
||||
const groups = [...new Set(fields.map((field) => field.path.split(".").slice(0, -1).join(".")))];
|
||||
function update(path: string, raw: string | undefined, field: Field) {
|
||||
setNotice("");
|
||||
@@ -240,8 +256,8 @@ function Editor({
|
||||
}
|
||||
}
|
||||
function download() {
|
||||
if (!result) return;
|
||||
const url = URL.createObjectURL(new Blob([result.yaml], { type: "application/yaml" }));
|
||||
if (!output) return;
|
||||
const url = URL.createObjectURL(new Blob([output.yaml], { type: "application/yaml" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "config.yaml";
|
||||
@@ -250,16 +266,20 @@ function Editor({
|
||||
setNotice("Downloaded config.yaml.");
|
||||
}
|
||||
const mlx = catalog.runtime.backend === "mlx";
|
||||
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"],
|
||||
];
|
||||
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) ?? hardwareOptions[0];
|
||||
const model = modelDirectory.find((model) => model.id === catalog.id.split("/")[0])!;
|
||||
const renderField = (field: Field) => (
|
||||
@@ -289,24 +309,78 @@ function Editor({
|
||||
model={model}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" disabled={!result} onClick={() => result && copy(result.yaml)}>
|
||||
<Button variant="outline" disabled={!output} onClick={() => output && copy(output.yaml)}>
|
||||
Copy YAML
|
||||
</Button>
|
||||
<Button disabled={!result} onClick={download}>
|
||||
<Button disabled={!output} onClick={download}>
|
||||
Download YAML
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{
|
||||
<div className="recipe-run-modes option-chips" role="group" aria-label="Run mode">
|
||||
{(mlx ? ["serving"] : ["sdk", "inference", "serving"]).map((mode) => (
|
||||
<button
|
||||
type="button"
|
||||
key={mode}
|
||||
aria-pressed={runMode === mode}
|
||||
onClick={() => {
|
||||
setRunMode(mode);
|
||||
setNotice("");
|
||||
}}
|
||||
>
|
||||
{mode === "sdk" ? "Python SDK" : mode === "inference" ? "CLI" : "Serving"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
<section className="recipe-command-box" aria-label="Generated configuration">
|
||||
<Tabs.Root defaultValue="commands">
|
||||
<Tabs.Root value={outputTab} onValueChange={setOutputTab}>
|
||||
<div className="recipe-command-toolbar">
|
||||
<span className="recipe-unverified">Unverified recipe</span>
|
||||
<div className="recipe-command-tools">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!output}
|
||||
onClick={() => output && copy(configAndLaunch(output.yaml, launchCommand!))}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
{runMode === "serving" && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!result}
|
||||
aria-expanded={showClient}
|
||||
aria-controls={`client-${catalog.id.replaceAll("/", "-")}`}
|
||||
onClick={() => {
|
||||
setOutputTab("commands");
|
||||
setShowClient(!showClient);
|
||||
}}
|
||||
>
|
||||
cURL
|
||||
</button>
|
||||
)}
|
||||
{Object.keys(catalog.env).length > 0 && (
|
||||
<button type="button" aria-pressed={showEnv} onClick={() => setShowEnv(!showEnv)}>
|
||||
Env
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Tabs.List className="recipe-tabs" aria-label="Output format">
|
||||
<Tabs.Trigger value="commands">Commands</Tabs.Trigger>
|
||||
<Tabs.Trigger value="yaml">config.yaml</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
{showEnv && (
|
||||
<pre className="recipe-environment" tabIndex={0} aria-label="Environment variables">
|
||||
<code>
|
||||
{Object.entries(catalog.env)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join("\n")}
|
||||
</code>
|
||||
</pre>
|
||||
)}
|
||||
{failure && (
|
||||
<div role="alert" className="recipe-error">
|
||||
<p>{failure}</p>
|
||||
@@ -316,38 +390,50 @@ function Editor({
|
||||
</div>
|
||||
)}
|
||||
<Tabs.Content value="commands" className="recipe-command-content">
|
||||
{result && (
|
||||
{result && output && (
|
||||
<>
|
||||
<div className="recipe-code-heading">
|
||||
<span>Serve</span>
|
||||
<button type="button" onClick={() => copy(result.command)}>
|
||||
Copy server command
|
||||
</button>
|
||||
<span>{runMode === "sdk" ? "Python SDK" : runMode === "inference" ? "Generate" : "Serve"}</span>
|
||||
</div>
|
||||
<pre tabIndex={0} aria-label="Server command">
|
||||
<code>{result.command}</code>
|
||||
<pre
|
||||
tabIndex={0}
|
||||
aria-label={
|
||||
runMode === "sdk" ? "SDK command" : runMode === "inference" ? "Inference command" : "Server command"
|
||||
}
|
||||
>
|
||||
<code>{launchCommand}</code>
|
||||
</pre>
|
||||
<details className="recipe-request-code">
|
||||
<summary>{result.websocketUrl ? "Health check and WebSocket" : "Example request"}</summary>
|
||||
<button type="button" onClick={() => copy(result.clientCommand)}>
|
||||
Copy client command
|
||||
</button>
|
||||
<pre tabIndex={0} aria-label="Client command">
|
||||
<code>{result.clientCommand}</code>
|
||||
</pre>
|
||||
{result.websocketUrl && (
|
||||
<p>
|
||||
WebSocket URL: <code>{result.websocketUrl}</code>
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
<p className="matrix-note">
|
||||
Includes your settings and writes config.yaml. Run from an installed FastVideo checkout.
|
||||
</p>
|
||||
{runMode === "serving" && (
|
||||
<details
|
||||
className="recipe-request-code"
|
||||
id={`client-${catalog.id.replaceAll("/", "-")}`}
|
||||
open={showClient}
|
||||
onToggle={(event) => setShowClient(event.currentTarget.open)}
|
||||
>
|
||||
<summary>{result.websocketUrl ? "Health check and WebSocket" : "Example request"}</summary>
|
||||
<button type="button" onClick={() => copy(result.clientCommand)}>
|
||||
Copy client command
|
||||
</button>
|
||||
<pre tabIndex={0} aria-label="Client command">
|
||||
<code>{result.clientCommand}</code>
|
||||
</pre>
|
||||
{result.websocketUrl && (
|
||||
<p>
|
||||
WebSocket URL: <code>{result.websocketUrl}</code>
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="yaml" className="recipe-command-content">
|
||||
{result && (
|
||||
{output && (
|
||||
<pre tabIndex={0} aria-label="Generated YAML">
|
||||
<code>{result.yaml}</code>
|
||||
<code>{output.yaml}</code>
|
||||
</pre>
|
||||
)}
|
||||
</Tabs.Content>
|
||||
@@ -358,7 +444,7 @@ function Editor({
|
||||
</section>
|
||||
<details className="recipe-install">
|
||||
<summary>
|
||||
Installation <span>{catalog.runtime.label}</span>
|
||||
Installation <span>{catalog.runtime.label.replace(/\s*REST/gi, "")}</span>
|
||||
</summary>
|
||||
{catalog.requirements?.length ? (
|
||||
<ul>
|
||||
@@ -385,7 +471,7 @@ function Editor({
|
||||
if (catalog.id !== deployment.id) selectRuntime(deployment.id);
|
||||
}}
|
||||
>
|
||||
{deployment.runtime.includes("mlx") ? "MLX" : "NVIDIA"}
|
||||
{deployment.runtime.includes("mlx") ? "MLX" : "CUDA"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -415,8 +501,43 @@ function Editor({
|
||||
</div>
|
||||
<p className="matrix-note">Hardware fit has not been verified. GPU count updates the YAML.</p>
|
||||
</MatrixRow>
|
||||
{catalog.workload === "i2v" && runMode !== "serving" && (
|
||||
<MatrixRow title="Inputs">
|
||||
<div className="matrix-field">
|
||||
<label htmlFor={sourceImageId} className="matrix-field-label">
|
||||
Source image path
|
||||
</label>
|
||||
<Input
|
||||
id={sourceImageId}
|
||||
value={sourceImage}
|
||||
required
|
||||
aria-invalid={!sourceImage.trim()}
|
||||
aria-describedby={`${sourceImageId}-hint`}
|
||||
placeholder="/path/to/first-frame.png"
|
||||
onChange={(event) => {
|
||||
setSourceImage(event.target.value);
|
||||
setNotice("");
|
||||
}}
|
||||
/>
|
||||
<p id={`${sourceImageId}-hint`} className="matrix-note">
|
||||
Path to an image on the machine running FastVideo. Included as request.inputs.image_path.
|
||||
</p>
|
||||
</div>
|
||||
</MatrixRow>
|
||||
)}
|
||||
{model.family === "FastH3" && (
|
||||
<MatrixRow title="Components">
|
||||
{model.id === "fasth3-trim" && (
|
||||
<div className="option-chips" aria-label="Recipe components">
|
||||
{["NVFP4 transformer", "50-layer NVFP4 encoder", "Lightweight video VAE", "H3 audio VAE"].map(
|
||||
(name) => (
|
||||
<span className="option-chip-static" key={name}>
|
||||
{name}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ComponentPlan key={reset} onDraftChange={setComponentDraft} />
|
||||
</MatrixRow>
|
||||
)}
|
||||
@@ -461,11 +582,12 @@ function Editor({
|
||||
</details>
|
||||
</section>
|
||||
<div className="recipe-bottom-actions">
|
||||
<span>Save config.yaml before running the server.</span>
|
||||
<span>Copy config and launch together, or download config.yaml first.</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEdits({});
|
||||
setSourceImage("");
|
||||
setComponentDraft(false);
|
||||
setParseErrors({});
|
||||
setReset(reset + 1);
|
||||
@@ -489,6 +611,8 @@ function MatrixRow({ title, children }: { title: string; children: ReactNode })
|
||||
}
|
||||
|
||||
export default function Cookbook({ modelId }: { modelId?: string }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
const [index, setIndex] = useState<Index>();
|
||||
const [selected, setSelected] = useState("");
|
||||
const [catalog, setCatalog] = useState<Catalog>();
|
||||
@@ -555,8 +679,19 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
|
||||
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);
|
||||
if (directoryModel?.releaseOnly) return <ModelRelease model={directoryModel} />;
|
||||
if (directoryModel && !directoryModel.configurable) return <SourceRecipe model={directoryModel} />;
|
||||
if (directoryModel && !directoryModel.configurable)
|
||||
return mounted ? (
|
||||
<SourceRecipe model={directoryModel} />
|
||||
) : (
|
||||
<div className="cookbook not-prose recipe-detail">
|
||||
<ModelHeader model={directoryModel} />
|
||||
<p>{directoryModel.description}</p>
|
||||
<p className="recipe-loading">Loading recipe…</p>
|
||||
<a href={`https://github.com/hao-ai-lab/FastVideo/blob/main/${directoryModel.recipes[0].source}`}>
|
||||
View maintained example ↗
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="cookbook not-prose recipe-detail">
|
||||
{error ? (
|
||||
@@ -586,7 +721,7 @@ export default function Cookbook({ modelId }: { modelId?: string }) {
|
||||
aria-pressed={selected === deployment.id}
|
||||
onClick={() => select(deployment.id)}
|
||||
>
|
||||
{deployment.runtime.includes("mlx") ? "MLX" : "NVIDIA"}
|
||||
{deployment.runtime.includes("mlx") ? "MLX" : "CUDA"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+454
-106
@@ -1,35 +1,124 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Tabs } from "radix-ui";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import ModelHeader from "@/components/cookbook/ModelHeader";
|
||||
import HardwarePicker from "@/components/cookbook/HardwarePicker";
|
||||
import { configAndLaunch, fileAndLaunch, sdkCommand, pythonSource } from "@/lib/cookbook-commands";
|
||||
import { sitePath, type DirectoryModel } from "@/lib/cookbook-pages";
|
||||
import { api } from "@/lib/cookbook";
|
||||
|
||||
/** Existing inference recipes stay runnable while editable serving catalogs are added. */
|
||||
interface Control {
|
||||
name: string;
|
||||
label: string;
|
||||
value: string | number | boolean;
|
||||
spans?: number[][];
|
||||
path?: string[];
|
||||
flag?: string;
|
||||
integer?: boolean;
|
||||
booleanOptional?: boolean;
|
||||
choices?: (string | number | { value: string | number; label: string })[];
|
||||
}
|
||||
interface Example {
|
||||
source: string;
|
||||
controls: Control[];
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
prompt: "Prompt",
|
||||
num_gpus: "GPU count",
|
||||
height: "Height",
|
||||
width: "Width",
|
||||
num_frames: "Frames",
|
||||
seed: "Seed",
|
||||
num_inference_steps: "Inference steps",
|
||||
guidance_scale: "Guidance scale",
|
||||
fps: "FPS",
|
||||
host: "Host",
|
||||
port: "Port",
|
||||
served_model_name: "Served model name",
|
||||
};
|
||||
|
||||
/** Every source page uses the model editor; settings modify the maintained example itself. */
|
||||
export default function SourceRecipe({ model }: { model: DirectoryModel }) {
|
||||
const [selected, setSelected] = useState(model.recipes[0].id);
|
||||
const [mode, setMode] = useState("inference");
|
||||
const [yaml, setYaml] = useState("");
|
||||
const [mode, setMode] = useState("sdk");
|
||||
const [loaded, setLoaded] = useState<{ url: string; example: Example }>();
|
||||
const [notice, setNotice] = useState("");
|
||||
const [showEnv, setShowEnv] = useState(false);
|
||||
const [showClient, setShowClient] = useState(false);
|
||||
const [outputTab, setOutputTab] = useState("commands");
|
||||
const [error, setError] = useState("");
|
||||
const [edits, setEdits] = useState<Record<number, string | number | boolean>>({});
|
||||
const [hardware, setHardware] = useState("unspecified");
|
||||
useEffect(() => {
|
||||
setHardware(new URL(window.location.href).searchParams.get("hardware") ?? "unspecified");
|
||||
}, []);
|
||||
const recipe = model.recipes.find((recipe) => recipe.id === selected)!;
|
||||
const yamlSource =
|
||||
mode === "serving" ? recipe.serving?.source : /\.ya?ml$/.test(recipe.source) ? recipe.source : undefined;
|
||||
const sourceUrl = sitePath(`/assets/cookbook-sources/${recipe.id}-${mode}.yaml`);
|
||||
const sourceUrl = sitePath(
|
||||
yamlSource
|
||||
? `/assets/cookbook-sources/${recipe.id}-${mode === "serving" ? "serving" : "inference"}.yaml`
|
||||
: `/assets/cookbook-examples/${recipe.id}.json`,
|
||||
);
|
||||
const example = loaded?.url === sourceUrl ? loaded.example : undefined;
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setYaml("");
|
||||
setError("");
|
||||
if (yamlSource)
|
||||
fetch(sourceUrl, { signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`YAML could not be loaded: HTTP ${response.status}.`);
|
||||
const text = await response.text();
|
||||
if (!controller.signal.aborted) setYaml(text);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) setError(error.message);
|
||||
});
|
||||
fetch(sourceUrl, { signal: controller.signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`Example could not be loaded: HTTP ${response.status}.`);
|
||||
let data: Example;
|
||||
if (!yamlSource) data = await response.json();
|
||||
else {
|
||||
const source = await response.text();
|
||||
const config = parse(source);
|
||||
const controls: Control[] = [];
|
||||
function visit(value: Record<string, unknown>, path: string[] = []) {
|
||||
for (const [name, item] of Object.entries(value)) {
|
||||
if (item && typeof item === "object" && !Array.isArray(item))
|
||||
visit(item as Record<string, unknown>, [...path, name]);
|
||||
else if (labels[name] && ["string", "number", "boolean"].includes(typeof item))
|
||||
controls.push({
|
||||
name,
|
||||
label: labels[name],
|
||||
value: item as Control["value"],
|
||||
integer: !["guidance_scale", "audio_end_in_s"].includes(name),
|
||||
path: [...path, name],
|
||||
});
|
||||
}
|
||||
}
|
||||
visit(config);
|
||||
data = { source, controls };
|
||||
}
|
||||
if (!yamlSource) {
|
||||
for (const knob of recipe.knobs ?? []) {
|
||||
const field = data.controls.find((field) => field.name === knob.key);
|
||||
if (field) field.choices = knob.options;
|
||||
else
|
||||
data.controls.push({
|
||||
name: knob.key,
|
||||
label: labels[knob.key] ?? knob.label,
|
||||
value: knob.default,
|
||||
flag: knob.flag,
|
||||
spans: [],
|
||||
choices: knob.options,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!controller.signal.aborted) setLoaded({ url: sourceUrl, example: data });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) setError(error.message);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [sourceUrl, yamlSource]);
|
||||
}, [sourceUrl, yamlSource, recipe]);
|
||||
function selectMode(next: string) {
|
||||
setMode(next);
|
||||
if ((next === "serving") !== (mode === "serving")) setEdits({});
|
||||
setNotice("");
|
||||
}
|
||||
async function copy(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
@@ -38,140 +127,399 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
|
||||
setNotice("Copy failed. Select and copy the text below.");
|
||||
}
|
||||
}
|
||||
const command =
|
||||
let source = example?.source ?? "";
|
||||
let failure = "";
|
||||
for (const [key, value] of Object.entries(edits)) {
|
||||
const field = example?.controls[Number(key)];
|
||||
if (
|
||||
field &&
|
||||
typeof field.value === "number" &&
|
||||
(!Number.isFinite(value) ||
|
||||
((field.integer ?? Number.isInteger(field.value)) && !Number.isInteger(value)) ||
|
||||
(field.name === "num_gpus" && Number(value) < 1))
|
||||
)
|
||||
failure = "Enter valid numeric settings before copying the example.";
|
||||
}
|
||||
if (example && yamlSource) {
|
||||
const config = parse(source);
|
||||
for (const [key, value] of Object.entries(edits)) {
|
||||
const path = example.controls[Number(key)].path!;
|
||||
const parent = path.slice(0, -1).reduce((item, name) => item[name], config);
|
||||
parent[path.at(-1)!] = value;
|
||||
}
|
||||
source = stringify(config);
|
||||
} else if (example) {
|
||||
source = pythonSource(source, example.controls, edits);
|
||||
}
|
||||
const baselineCommand =
|
||||
mode === "serving" && recipe.serving
|
||||
? `${recipe.serving.env ? `${recipe.serving.env} ` : ""}fastvideo serve --config ${recipe.serving.source}`
|
||||
: recipe.command;
|
||||
const localFile = recipe.source.replace(/[^/]+$/, `cookbook_${recipe.id.replaceAll("-", "_")}.py`);
|
||||
const command = yamlSource
|
||||
? baselineCommand.replace(/--config\s+\S+/, "--config config.yaml")
|
||||
: baselineCommand.replace(recipe.source, localFile) +
|
||||
Object.entries(edits)
|
||||
.filter(([key]) => example?.controls[Number(key)].flag)
|
||||
.map(([key, value]) => {
|
||||
const field = example!.controls[Number(key)];
|
||||
return typeof value === "boolean" && field.booleanOptional
|
||||
? ` ${value ? field.flag : field.flag!.replace(/^--/, "--no-")}`
|
||||
: ` ${field.flag} '${String(value).replaceAll("'", "'\"'\"'")}'`;
|
||||
})
|
||||
.join("");
|
||||
const sdk = mode === "sdk" && !!yamlSource;
|
||||
const config = sdk && source ? parse(source) : undefined;
|
||||
const displayedSource = config
|
||||
? stringify({ generator: config.generator, request: config.request ?? config.default_request })
|
||||
: source;
|
||||
const launch = sdk ? command.replace(/fastvideo generate --config config\.yaml$/, sdkCommand) : command;
|
||||
const ready = !!example && !failure;
|
||||
const clientCommand =
|
||||
mode === "serving" && ready
|
||||
? api.sampleRequest(
|
||||
{
|
||||
runtime: { interface: "rest", server_defaults: { host: "127.0.0.1", port: 8000 } },
|
||||
model: { id: model.model },
|
||||
workload: "t2v",
|
||||
},
|
||||
parse(source),
|
||||
).command
|
||||
: undefined;
|
||||
const launchScript = yamlSource
|
||||
? configAndLaunch(displayedSource, launch)
|
||||
: fileAndLaunch(localFile, displayedSource, launch);
|
||||
const environment = [...baselineCommand.matchAll(/(?:^|\s)([A-Z][A-Z0-9_]*=(?:'[^']*'|"[^"]*"|[^\s]+))/g)]
|
||||
.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 chooseHardware = (value: string) => {
|
||||
setHardware(value);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("hardware", value);
|
||||
window.history.replaceState(null, "", url);
|
||||
};
|
||||
const control = (field: Control, index: number) => {
|
||||
const value = Object.hasOwn(edits, index) ? edits[index] : field.value;
|
||||
return (
|
||||
<div className="matrix-field" key={index}>
|
||||
{field.choices || typeof value === "boolean" ? (
|
||||
<span className="matrix-field-label">{field.label}</span>
|
||||
) : (
|
||||
<label htmlFor={`source-${index}`} className="matrix-field-label">
|
||||
{field.label}
|
||||
</label>
|
||||
)}
|
||||
{field.choices ? (
|
||||
<div role="group" aria-label={field.label} className="option-chips">
|
||||
{field.choices.map((option) => {
|
||||
const choice = typeof option === "object" ? option.value : option;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={String(choice)}
|
||||
aria-pressed={value === choice}
|
||||
onClick={() => setEdits({ ...edits, [index]: choice })}
|
||||
>
|
||||
{typeof option === "object" ? option.label : String(option)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : typeof value === "boolean" ? (
|
||||
<div role="group" aria-label={field.label} className="option-chips">
|
||||
{[true, false].map((choice) => (
|
||||
<button
|
||||
type="button"
|
||||
key={String(choice)}
|
||||
aria-pressed={value === choice}
|
||||
onClick={() => setEdits({ ...edits, [index]: choice })}
|
||||
>
|
||||
{choice ? "On" : "Off"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id={`source-${index}`}
|
||||
type={typeof field.value === "number" ? "number" : "text"}
|
||||
value={typeof value === "number" && Number.isNaN(value) ? "" : String(value)}
|
||||
step={typeof field.value === "number" && (field.integer ?? Number.isInteger(field.value)) ? 1 : "any"}
|
||||
onChange={(event) =>
|
||||
setEdits({
|
||||
...edits,
|
||||
[index]:
|
||||
typeof field.value === "number"
|
||||
? event.target.value === ""
|
||||
? NaN
|
||||
: Number(event.target.value)
|
||||
: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="cookbook not-prose recipe-detail">
|
||||
<ModelHeader
|
||||
model={model}
|
||||
actions={
|
||||
<>
|
||||
{yamlSource ? (
|
||||
<>
|
||||
<Button variant="outline" disabled={!yaml} onClick={() => copy(yaml)}>
|
||||
Copy YAML
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<a href={sourceUrl} download={yamlSource?.split("/").at(-1)}>
|
||||
Download YAML
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => copy(command)}>
|
||||
Copy command
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" disabled={!ready} onClick={() => copy(displayedSource)}>
|
||||
Copy {yamlSource ? "YAML" : "Python"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
const url = URL.createObjectURL(new Blob([displayedSource], { type: "text/plain" }));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = yamlSource ? "config.yaml" : localFile.split("/").at(-1)!;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}}
|
||||
>
|
||||
Download {yamlSource ? "YAML" : "Python"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<section className="recipe-command-box" aria-label="Source recipe">
|
||||
<div className="recipe-command-toolbar">
|
||||
<span className="recipe-unverified">Unverified recipe</span>
|
||||
<button type="button" onClick={() => copy(command)}>
|
||||
Copy command
|
||||
<div className="recipe-run-modes option-chips" role="group" aria-label="Run mode">
|
||||
{["sdk", "inference", ...(recipe.serving ? ["serving"] : [])].map((entry) => (
|
||||
<button type="button" key={entry} aria-pressed={mode === entry} onClick={() => selectMode(entry)}>
|
||||
{entry === "sdk" ? "Python SDK" : entry === "inference" ? "CLI" : "Serving"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="recipe-command-content">
|
||||
<pre tabIndex={0} aria-label="Inference command">
|
||||
<code>{command}</code>
|
||||
</pre>
|
||||
{yamlSource && (
|
||||
<details>
|
||||
<summary>View source YAML</summary>
|
||||
{error ? (
|
||||
<p role="alert">{error}</p>
|
||||
) : (
|
||||
<pre tabIndex={0} aria-label="Source YAML">
|
||||
<code>{yaml || "Loading YAML…"}</code>
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
<section className="recipe-command-box" aria-label="Source recipe">
|
||||
<Tabs.Root value={outputTab} onValueChange={setOutputTab}>
|
||||
<div className="recipe-command-toolbar">
|
||||
<span className="recipe-unverified">Unverified recipe</span>
|
||||
<div className="recipe-command-tools">
|
||||
<button type="button" disabled={!ready} onClick={() => copy(launchScript)}>
|
||||
Copy
|
||||
</button>
|
||||
{mode === "serving" && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!clientCommand}
|
||||
aria-expanded={showClient}
|
||||
aria-controls="source-client-command"
|
||||
onClick={() => {
|
||||
setOutputTab("commands");
|
||||
setShowClient(!showClient);
|
||||
}}
|
||||
>
|
||||
cURL
|
||||
</button>
|
||||
)}
|
||||
</details>
|
||||
{environment && (
|
||||
<button type="button" aria-pressed={showEnv} onClick={() => setShowEnv(!showEnv)}>
|
||||
Env
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Tabs.List className="recipe-tabs" aria-label="Output format">
|
||||
<Tabs.Trigger value="commands">Commands</Tabs.Trigger>
|
||||
<Tabs.Trigger value="source">{yamlSource ? "config.yaml" : "Python example"}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
{showEnv && (
|
||||
<pre className="recipe-environment" tabIndex={0} aria-label="Environment variables">
|
||||
<code>{environment}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
{(error || failure) && (
|
||||
<p className="recipe-error" role="alert">
|
||||
{error || failure}
|
||||
</p>
|
||||
)}
|
||||
<Tabs.Content value="commands" className="recipe-command-content">
|
||||
<div className="recipe-code-heading">
|
||||
<span>{mode === "serving" ? "Serve" : mode === "sdk" ? "Python SDK" : "Generate"}</span>
|
||||
</div>
|
||||
<pre tabIndex={0} aria-label={mode === "serving" ? "Server command" : "Inference command"}>
|
||||
<code>{launch}</code>
|
||||
</pre>
|
||||
<p className="matrix-note">Includes your settings. Run from an installed FastVideo checkout.</p>
|
||||
{clientCommand && (
|
||||
<details
|
||||
className="recipe-request-code"
|
||||
id="source-client-command"
|
||||
open={showClient}
|
||||
onToggle={(event) => setShowClient(event.currentTarget.open)}
|
||||
>
|
||||
<summary>Example request</summary>
|
||||
<button type="button" onClick={() => copy(clientCommand)}>
|
||||
Copy client command
|
||||
</button>
|
||||
<pre tabIndex={0} aria-label="Client command">
|
||||
<code>{clientCommand}</code>
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="source" className="recipe-command-content">
|
||||
<pre tabIndex={0} aria-label="Source example">
|
||||
<code>{displayedSource || "Loading example…"}</code>
|
||||
</pre>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
<p role="status" className="recipe-copy-status">
|
||||
{notice}
|
||||
</p>
|
||||
</section>
|
||||
<details className="recipe-install">
|
||||
<summary>Installation and setup</summary>
|
||||
<summary>
|
||||
Installation and setup <span>{mlx ? "MLX" : "CUDA"}</span>
|
||||
</summary>
|
||||
{mode === "serving" && recipe.serving && (
|
||||
<pre>
|
||||
<code>{recipe.serving.install}</code>
|
||||
</pre>
|
||||
)}
|
||||
{mode === "serving" && recipe.serving?.prepare && (
|
||||
<>
|
||||
<pre>
|
||||
<code>{recipe.serving.prepare}</code>
|
||||
</pre>
|
||||
<Button variant="outline" onClick={() => copy(recipe.serving!.prepare!)}>
|
||||
Copy preparation commands
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<a href={sitePath(model.familyPath)}>Model setup guide ↗</a>
|
||||
</details>
|
||||
<section className="recipe-matrix" aria-label="Recipe options">
|
||||
<div className="matrix-row">
|
||||
<h2>Recipe</h2>
|
||||
<div className="matrix-row-content">
|
||||
<div className="option-chips" role="group" aria-label="Recipe variant">
|
||||
{model.recipes.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
aria-pressed={selected === entry.id}
|
||||
onClick={() => {
|
||||
setSelected(entry.id);
|
||||
setMode("inference");
|
||||
setNotice("");
|
||||
setYaml("");
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section className="recipe-matrix" aria-label="Configuration settings">
|
||||
<div className="matrix-row">
|
||||
<h2>Hardware</h2>
|
||||
<div className="matrix-row-content">
|
||||
<div className="option-chips">
|
||||
{recipe.gpu_types.map((platform) => (
|
||||
<span className="option-chip-static" key={platform}>
|
||||
{platform === "Apple Silicon" ? "MLX" : platform}
|
||||
</span>
|
||||
<div className="hardware-backends option-chips" role="group" aria-label="Hardware backend">
|
||||
{[
|
||||
...new Set(model.recipes.map((entry) => (entry.gpu_types.includes("Apple Silicon") ? "MLX" : "CUDA"))),
|
||||
].map((platform) => (
|
||||
<button
|
||||
type="button"
|
||||
key={platform}
|
||||
aria-pressed={platform === (mlx ? "MLX" : "CUDA")}
|
||||
onClick={() => {
|
||||
if (platform === (mlx ? "MLX" : "CUDA")) return;
|
||||
setNotice("");
|
||||
const target = model.recipes.find((entry) =>
|
||||
entry.gpu_types.includes(platform === "MLX" ? "Apple Silicon" : "NVIDIA"),
|
||||
)!;
|
||||
setSelected(target.id);
|
||||
setMode("sdk");
|
||||
setEdits({});
|
||||
}}
|
||||
>
|
||||
{platform}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="hardware-resource-grid">
|
||||
<HardwarePicker
|
||||
backend={mlx ? "mlx" : "nvidia"}
|
||||
options={devices}
|
||||
value={
|
||||
hardware.startsWith("custom:")
|
||||
? hardware
|
||||
: (devices.find(([id]) => id === hardware)?.[0] ?? devices[0][0])
|
||||
}
|
||||
onChange={chooseHardware}
|
||||
/>
|
||||
<div className="matrix-resources">
|
||||
<span className="matrix-resource-title">Resources</span>
|
||||
{example?.controls.map((field, i) => (field.name === "num_gpus" ? control(field, i) : null))}
|
||||
{!example?.controls.some((field) => field.name === "num_gpus") && (
|
||||
<p>{mlx ? "1 Apple Silicon host" : `${recipe.hardware?.gpu_count ?? 1} GPU in the source example`}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="matrix-note">
|
||||
{recipe.hardware?.accelerator ??
|
||||
(recipe.hardware?.gpu_count
|
||||
? `${recipe.hardware.gpu_count} GPU${recipe.hardware.gpu_count === 1 ? "" : "s"} in the source recipe`
|
||||
: "See the model guide for resources")}
|
||||
Hardware fit has not been verified. Settings edit the source example; device choice is a preference.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="matrix-row">
|
||||
<h2>Run mode</h2>
|
||||
<div className="matrix-row-content">
|
||||
<div className="option-chips" role="group" aria-label="Run mode">
|
||||
<button type="button" aria-pressed={mode === "inference"} onClick={() => setMode("inference")}>
|
||||
Inference
|
||||
</button>
|
||||
{recipe.serving && (
|
||||
<button type="button" aria-pressed={mode === "serving"} onClick={() => setMode("serving")}>
|
||||
Serving
|
||||
</button>
|
||||
)}
|
||||
{model.recipes.filter((entry) => entry.gpu_types.includes(mlx ? "Apple Silicon" : "NVIDIA")).length > 1 && (
|
||||
<div className="matrix-row">
|
||||
<h2>Recipe</h2>
|
||||
<div className="matrix-row-content option-chips" role="group" aria-label="Recipe variant">
|
||||
{model.recipes
|
||||
.filter((entry) => entry.gpu_types.includes(mlx ? "Apple Silicon" : "NVIDIA"))
|
||||
.map((entry) => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.id}
|
||||
aria-pressed={selected === entry.id}
|
||||
onClick={() => {
|
||||
setNotice("");
|
||||
setSelected(entry.id);
|
||||
setMode("sdk");
|
||||
setEdits({});
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="matrix-row">
|
||||
<h2>Generation settings</h2>
|
||||
<div className="matrix-row-content">
|
||||
<div className="matrix-options-grid">
|
||||
{example?.controls.map((field, i) =>
|
||||
field.name !== "num_gpus" && field.path?.[0] !== "server" ? control(field, i) : null,
|
||||
)}
|
||||
</div>
|
||||
{example && !example.controls.length && (
|
||||
<p className="matrix-note">
|
||||
This example uses its model defaults. Open the Python example to customize its pipeline.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{example?.controls.some((field) => field.path?.[0] === "server") && (
|
||||
<details className="matrix-advanced">
|
||||
<summary>
|
||||
Advanced <span>Server settings</span>
|
||||
</summary>
|
||||
<div className="matrix-row">
|
||||
<h2>Server</h2>
|
||||
<div className="matrix-row-content matrix-options-grid">
|
||||
{example.controls.map((field, i) => (field.path?.[0] === "server" ? control(field, i) : null))}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
<div className="recipe-source-links">
|
||||
<a href={sitePath(model.familyPath)}>Model guide →</a>
|
||||
<div className="recipe-bottom-actions">
|
||||
<a
|
||||
href={`https://github.com/hao-ai-lab/FastVideo/blob/main/${mode === "serving" ? recipe.serving?.source : recipe.source}`}
|
||||
>
|
||||
View source ↗
|
||||
View original example ↗
|
||||
</a>
|
||||
<span>
|
||||
Run from the FastVideo checkout. Source recipe. Interactive YAML configuration is not available for this model
|
||||
yet.
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEdits({});
|
||||
setNotice("Restored the baseline example.");
|
||||
}}
|
||||
>
|
||||
Reset changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { stringify } from "yaml";
|
||||
import type { Result, Value } from "./cookbook";
|
||||
|
||||
export function pythonSource(
|
||||
source: string,
|
||||
controls: { value: string | number | boolean; spans?: number[][] }[],
|
||||
edits: Record<number, string | number | boolean> = {},
|
||||
) {
|
||||
const replacements = controls.flatMap((field, index) =>
|
||||
(field.spans ?? []).map(([start, end]) => {
|
||||
const value = Object.hasOwn(edits, index) ? edits[index] : field.value;
|
||||
return { start, end, text: typeof value === "boolean" ? (value ? "True" : "False") : JSON.stringify(value) };
|
||||
}),
|
||||
);
|
||||
for (const replacement of replacements.sort((a, b) => b.start - a.start))
|
||||
source = source.slice(0, replacement.start) + replacement.text + source.slice(replacement.end);
|
||||
return source;
|
||||
}
|
||||
|
||||
/** Quoted heredocs preserve prompts literally, including shell substitutions. */
|
||||
export function configAndLaunch(yaml: string, command: string) {
|
||||
return fileAndLaunch("config.yaml", yaml, command);
|
||||
}
|
||||
|
||||
export function fileAndLaunch(filename: string, content: string, command: string) {
|
||||
let delimiter = "FASTVIDEO_CONFIG";
|
||||
while (content.split("\n").includes(delimiter)) delimiter += "_";
|
||||
return `(\nset -e\ncat > ${filename} <<'${delimiter}'\n${content.trimEnd()}\n${delimiter}\n${command}\n)`;
|
||||
}
|
||||
|
||||
export const sdkCommand = `python - <<'FASTVIDEO_SDK'
|
||||
import yaml
|
||||
from fastvideo import VideoGenerator
|
||||
|
||||
with open("config.yaml") as file:
|
||||
config = yaml.safe_load(file)
|
||||
generator = VideoGenerator.from_config(config["generator"])
|
||||
try:
|
||||
generator.generate(config["request"])
|
||||
finally:
|
||||
generator.shutdown()
|
||||
FASTVIDEO_SDK`;
|
||||
|
||||
/** The CUDA CLI uses the same generator with request instead of server defaults. */
|
||||
export function inferenceOutput(result: Result, imagePath?: string) {
|
||||
const defaults = (result.config.default_request ?? {}) as Record<string, Value>;
|
||||
const output = (defaults.output ?? {}) as Record<string, Value>;
|
||||
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];
|
||||
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.");
|
||||
}
|
||||
return {
|
||||
yaml: stringify({
|
||||
generator: result.config.generator,
|
||||
request: {
|
||||
...defaults,
|
||||
...(Object.keys(inputs).length ? { inputs } : {}),
|
||||
prompt: defaults.prompt ?? "A river flowing through a peaceful forest",
|
||||
output: { ...output, save_video: true, return_frames: false, output_path: "outputs/cookbook/" },
|
||||
},
|
||||
}),
|
||||
command: result.command.replace(
|
||||
/\bfastvideo serve --config config\.yaml$/,
|
||||
"fastvideo generate --config config.yaml",
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,15 @@ export const recipePages = [
|
||||
tags: ["Text to video", "Audio", "8-step"],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: "fasth3-trim",
|
||||
family: "FastH3",
|
||||
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: "fastwan21",
|
||||
family: "Wan",
|
||||
@@ -56,7 +65,15 @@ export interface SourceRecipe {
|
||||
command: string;
|
||||
gpu_types: string[];
|
||||
hardware?: { gpu_count?: number; platform?: string; accelerator?: string };
|
||||
serving?: { source: string; install: string; env?: string };
|
||||
serving?: { source: string; install: string; env?: string; prepare?: string };
|
||||
knobs?: {
|
||||
key: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
flag: string;
|
||||
default: string | number;
|
||||
options: (string | number | { value: string | number; label: string })[];
|
||||
}[];
|
||||
}
|
||||
export const sourceRecipes = sourceCatalog.recipes as SourceRecipe[];
|
||||
const familyInfo: Record<string, { title: string; path: string; logo: string; category: string }> = {
|
||||
@@ -75,6 +92,7 @@ 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/FastWan2.1-T2V-1.3B-Diffusers": "fastwan21",
|
||||
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers": "wan21-i2v",
|
||||
"FastVideo/LTX2-Distilled-Diffusers": "ltx2-distilled",
|
||||
@@ -116,7 +134,6 @@ export const modelDirectory = [...new Set(sourceRecipes.map((recipe) => recipe.m
|
||||
path: backend?.path ?? `/cookbook/${family.path}/models/${first.id}`,
|
||||
logo,
|
||||
provider: providers[logo],
|
||||
releaseOnly: false,
|
||||
category: family.category,
|
||||
description: backend?.description ?? first.summary,
|
||||
tags: backend?.tags ?? [...new Set(recipes.map((recipe) => recipe.task))],
|
||||
@@ -125,26 +142,6 @@ export const modelDirectory = [...new Set(sourceRecipes.map((recipe) => recipe.m
|
||||
featured: backend?.featured ?? false,
|
||||
};
|
||||
})
|
||||
.concat([
|
||||
{
|
||||
id: "fasth3-trim",
|
||||
model: "FastVideo/FastVideo-FastH3-Trim-8-Step",
|
||||
recipes: [] as SourceRecipe[],
|
||||
title: "FastH3 Trim",
|
||||
family: "FastH3",
|
||||
familyPath: "/cookbook/minimax-h3",
|
||||
path: "/cookbook/minimax-h3/fasth3-trim",
|
||||
logo: "fastvideo",
|
||||
provider: "FastVideo",
|
||||
releaseOnly: true,
|
||||
category: "Video",
|
||||
description: "The smaller FastH3 release, previously called CompactH3.",
|
||||
tags: ["Text to video", "Audio", "8-step"],
|
||||
platforms: ["NVIDIA", "Apple Silicon"],
|
||||
configurable: false,
|
||||
featured: false,
|
||||
},
|
||||
])
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.featured) - Number(a.featured) ||
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface Catalog {
|
||||
id: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
workload: string;
|
||||
env: Record<string, string>;
|
||||
base_config: Record<string, Value>;
|
||||
runtime: {
|
||||
label: string;
|
||||
@@ -48,12 +50,21 @@ export interface Catalog {
|
||||
guide?: { url: string; label?: string };
|
||||
}
|
||||
export interface Result {
|
||||
config: Record<string, Value>;
|
||||
yaml: string;
|
||||
command: string;
|
||||
clientCommand: string;
|
||||
websocketUrl?: string;
|
||||
}
|
||||
interface Api {
|
||||
sampleRequest(
|
||||
catalog: {
|
||||
runtime: { interface: string; server_defaults: { host: string; port: number } };
|
||||
model: { id: string };
|
||||
workload: string;
|
||||
},
|
||||
config: Record<string, Value>,
|
||||
): { command: string };
|
||||
loadIndex(url: string, options?: { signal?: AbortSignal }): Promise<Index>;
|
||||
loadDeployment(index: Index, id: string, options?: { signal?: AbortSignal }): Promise<Catalog>;
|
||||
getOptions(catalog: Catalog, config?: Catalog["base_config"], edits?: Record<string, Value>): Field[];
|
||||
|
||||
@@ -142,6 +142,10 @@
|
||||
"label": "FastH3 8-Step V2",
|
||||
"href": "/cookbook/minimax-h3/fasth3-8step"
|
||||
},
|
||||
{
|
||||
"label": "FastH3 Trim",
|
||||
"href": "/cookbook/minimax-h3/fasth3-trim"
|
||||
},
|
||||
{
|
||||
"label": "Recipes and setup",
|
||||
"href": "/cookbook/minimax-h3"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"test": "playwright test",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test:content": "node --test tests/content.test.mjs"
|
||||
"test:content": "node --test tests/content.test.mjs tests/commands.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"blume": "2.2.3",
|
||||
|
||||
@@ -22,6 +22,9 @@ await cp(new URL("docs/assets/cookbook-config/", repo), destination, {
|
||||
await cp(new URL("docs/assets/logos/icon_simple.svg", repo), new URL("icon.svg", publicDir));
|
||||
|
||||
await cp(new URL("../assets/hao-ai-lab.png", import.meta.url), new URL("assets/hao-ai-lab.png", publicDir));
|
||||
await cp(new URL("../.generated/source-examples/", import.meta.url), new URL("assets/cookbook-examples/", publicDir), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Publish exact checked-in YAML for inference-only model pages. No settings are synthesized.
|
||||
const sourceDir = new URL("assets/cookbook-sources/", publicDir);
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import runpy
|
||||
import shutil
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, urlsplit, urlunsplit
|
||||
|
||||
@@ -18,6 +20,68 @@ SITE = ROOT / "docs-site"
|
||||
GENERATED = SITE / ".generated"
|
||||
|
||||
|
||||
def source_controls(source: str) -> list[dict]:
|
||||
"""Expose literal settings in maintained examples without importing them."""
|
||||
labels = {
|
||||
"prompt": "Prompt", "num_gpus": "GPU count", "height": "Height", "width": "Width",
|
||||
"num_frames": "Frames", "seed": "Seed", "num_inference_steps": "Inference steps",
|
||||
"guidance_scale": "Guidance scale", "fps": "FPS", "audio_end_in_s": "Audio duration",
|
||||
"text_encoder_cpu_offload": "Offload text encoder", "vae_cpu_offload": "Offload VAE",
|
||||
"dit_cpu_offload": "Offload transformer", "inference_torch_compile": "Compile transformer",
|
||||
"video_decode_backend": "Video decoder",
|
||||
}
|
||||
lines = source.splitlines(keepends=True)
|
||||
offsets = [0]
|
||||
for line in lines:
|
||||
offsets.append(offsets[-1] + len(line))
|
||||
|
||||
def position(line, column):
|
||||
prefix = source[:offsets[line - 1]] + lines[line - 1].encode()[:column].decode()
|
||||
return len(prefix.encode("utf-16-le")) // 2
|
||||
|
||||
settings = {}
|
||||
|
||||
def add(name, value):
|
||||
if name not in labels or not isinstance(value, ast.Constant) or type(value.value) not in (str, int, float, bool):
|
||||
return
|
||||
key = json.dumps([name, value.value])
|
||||
entry = settings.setdefault(key, {"name": name, "label": labels[name], "value": value.value,
|
||||
"integer": type(value.value) is int, "spans": []})
|
||||
entry["spans"].append([position(value.lineno, value.col_offset), position(value.end_lineno, value.end_col_offset)])
|
||||
|
||||
for node in ast.walk(ast.parse(source)):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
||||
if node.func.attr in {"from_pretrained", "generate_video"}:
|
||||
for keyword in node.keywords:
|
||||
add(keyword.arg, keyword.value)
|
||||
elif node.func.attr == "add_argument":
|
||||
flags = [arg.value for arg in node.args if isinstance(arg, ast.Constant) and isinstance(arg.value, str)]
|
||||
flag = next((flag for flag in flags if flag.startswith("--")), "")
|
||||
for keyword in node.keywords:
|
||||
if keyword.arg == "default":
|
||||
add(flag.removeprefix("--").replace("-", "_"), keyword.value)
|
||||
key = json.dumps([flag.removeprefix("--").replace("-", "_"), getattr(keyword.value, "value", None)])
|
||||
if key in settings:
|
||||
settings[key]["flag"] = flag
|
||||
if any(item.arg == "type" and isinstance(item.value, ast.Name) and item.value.id == "float"
|
||||
for item in node.keywords):
|
||||
settings[key]["integer"] = False
|
||||
settings[key]["booleanOptional"] = any(
|
||||
item.arg == "action" and isinstance(item.value, ast.Attribute) and item.value.attr == "BooleanOptionalAction"
|
||||
for item in node.keywords)
|
||||
elif isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name) and target.id.isupper():
|
||||
add(target.id.lower(), node.value)
|
||||
result = list(settings.values())
|
||||
seen = {}
|
||||
for field in result:
|
||||
seen[field["label"]] = seen.get(field["label"], 0) + 1
|
||||
if seen[field["label"]] > 1:
|
||||
field["label"] += f' ({seen[field["label"]]})'
|
||||
return result
|
||||
|
||||
|
||||
def signature(obj) -> str:
|
||||
"""Preserve parameter kinds, annotations, defaults, and return annotations."""
|
||||
if obj.is_attribute:
|
||||
@@ -159,6 +223,27 @@ def prepare() -> None:
|
||||
examples = runpy.run_path(str(ROOT / "docs/generate_examples.py"))
|
||||
examples["validate_cookbook"]()
|
||||
examples["generate_examples"](generate_main_index=True)
|
||||
source_dir = GENERATED / "source-examples"
|
||||
shutil.rmtree(source_dir, ignore_errors=True)
|
||||
source_dir.mkdir()
|
||||
for recipe in json.loads((ROOT / "docs/assets/cookbook-recipes.json").read_text())["recipes"]:
|
||||
path = ROOT / recipe["source"]
|
||||
if path.suffix == ".py":
|
||||
source = path.read_text()
|
||||
controls = source_controls(source)
|
||||
argv = shlex.split(recipe["command"])
|
||||
for field in controls:
|
||||
flag = field.get("flag")
|
||||
positions = [i for i, argument in enumerate(argv[:-1]) if argument == flag]
|
||||
if positions and type(field["value"]) is not bool:
|
||||
converter = float if isinstance(field["value"], (int, float)) and not field["integer"] else type(field["value"])
|
||||
field["value"] = converter(argv[positions[-1] + 1])
|
||||
elif field.get("booleanOptional"):
|
||||
if flag in argv:
|
||||
field["value"] = True
|
||||
elif flag.replace("--", "--no-", 1) in argv:
|
||||
field["value"] = False
|
||||
(source_dir / f'{recipe["id"]}.json').write_text(json.dumps({"source": source, "controls": controls}))
|
||||
anchors = json.loads((SITE / "migration-anchors.json").read_text())
|
||||
shutil.rmtree(GENERATED / "python-reference", ignore_errors=True)
|
||||
package = griffe.load("fastvideo", search_paths=[ROOT], allow_inspection=False, resolve_aliases=True,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { parse } from "yaml";
|
||||
import { configAndLaunch, inferenceOutput, pythonSource, sdkCommand } from "../lib/cookbook-commands.ts";
|
||||
|
||||
test("every generated Python example remains valid after its source-backed settings are applied", () => {
|
||||
const directory = new URL("../.generated/source-examples/", import.meta.url);
|
||||
const examples = readdirSync(directory)
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.map((name) => {
|
||||
const data = JSON.parse(readFileSync(new URL(name, directory), "utf8"));
|
||||
const edits = Object.fromEntries(
|
||||
data.controls.map((field, i) => [
|
||||
i,
|
||||
typeof field.value === "number"
|
||||
? 2
|
||||
: typeof field.value === "boolean"
|
||||
? false
|
||||
: "A bowl 🥣 with 'quotes' and $(literal text)",
|
||||
]),
|
||||
);
|
||||
return {
|
||||
name,
|
||||
baseline: pythonSource(data.source, data.controls),
|
||||
edited: pythonSource(data.source, data.controls, edits),
|
||||
};
|
||||
});
|
||||
assert.ok(examples.length > 25);
|
||||
execFileSync(
|
||||
"python3",
|
||||
[
|
||||
"-c",
|
||||
"import ast,json,sys\nfor example in json.load(sys.stdin):\n for key in ('baseline', 'edited'):\n ast.parse(example[key], filename=example['name'])",
|
||||
],
|
||||
{ input: JSON.stringify(examples) },
|
||||
);
|
||||
});
|
||||
|
||||
test("SDK launch commands have valid shell syntax without executing model code", () => {
|
||||
execFileSync("bash", ["-n"], {
|
||||
input: configAndLaunch("generator: {}\nrequest: {}\n", `FASTVIDEO_FA4=0 ${sdkCommand}`),
|
||||
});
|
||||
});
|
||||
|
||||
test("copy-and-launch writes literal YAML before invoking the command", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "fastvideo-copy-command-"));
|
||||
try {
|
||||
const yaml = 'prompt: "$(touch injected) `touch injected2`"\nFASTVIDEO_CONFIG\n';
|
||||
execFileSync("bash", ["-c", configAndLaunch(yaml, "cp config.yaml received.yaml")], { cwd: root });
|
||||
assert.equal(readFileSync(join(root, "received.yaml"), "utf8"), yaml);
|
||||
assert.throws(() => readFileSync(join(root, "injected")));
|
||||
assert.throws(() => readFileSync(join(root, "injected2")));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("inference output preserves edited settings and hidden generator fields", () => {
|
||||
const config = {
|
||||
generator: {
|
||||
model_path: "FastVideo/Trim",
|
||||
engine: { num_gpus: 2 },
|
||||
pipeline: { experimental: { VSA_sparsity: 0.8 } },
|
||||
},
|
||||
server: { port: 9000 },
|
||||
default_request: { prompt: "A ceramic bowl", sampling: { num_frames: 243, seed: 0 } },
|
||||
};
|
||||
const result = inferenceOutput({ config, command: "FASTVIDEO_FA4=0 fastvideo serve --config config.yaml" });
|
||||
const saved = parse(result.yaml);
|
||||
assert.deepEqual(saved.generator, config.generator);
|
||||
assert.deepEqual(saved.request.sampling, config.default_request.sampling);
|
||||
assert.equal(saved.request.prompt, "A ceramic bowl");
|
||||
assert.equal(saved.request.output.save_video, true);
|
||||
assert.equal(saved.server, undefined);
|
||||
assert.equal(result.command, "FASTVIDEO_FA4=0 fastvideo generate --config config.yaml");
|
||||
assert.ok(config.server);
|
||||
});
|
||||
|
||||
test("Wan I2V inference requires a source image and preserves the serving baseline", () => {
|
||||
const config = parse(
|
||||
readFileSync(new URL("../../examples/serving/openai_wan21_i2v_14b.yaml", import.meta.url), "utf8"),
|
||||
);
|
||||
const baseline = structuredClone(config);
|
||||
const result = { config, command: "fastvideo serve --config config.yaml" };
|
||||
assert.throws(() => inferenceOutput(result), /source image path/);
|
||||
assert.throws(() => inferenceOutput(result, " "), /source image path/);
|
||||
const saved = parse(inferenceOutput(result, " /data/first-frame.png ").yaml);
|
||||
assert.equal(saved.request.inputs.image_path, "/data/first-frame.png");
|
||||
assert.deepEqual(saved.request.sampling, config.default_request.sampling);
|
||||
assert.deepEqual(config, baseline);
|
||||
assert.equal(config.default_request.inputs, undefined);
|
||||
const withDefaults = structuredClone(config);
|
||||
withDefaults.default_request.inputs = { image_path: "/data/existing.png", video_path: "/data/reference.mp4" };
|
||||
const existing = parse(inferenceOutput({ ...result, config: withDefaults }).yaml);
|
||||
assert.deepEqual(existing.request.inputs, withDefaults.default_request.inputs);
|
||||
});
|
||||
@@ -117,12 +117,12 @@ test("an obsolete deployment response cannot replace the selected deployment", a
|
||||
await output(page, "config.yaml");
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).toBeVisible();
|
||||
await backend(page, "MLX");
|
||||
await backend(page, "NVIDIA");
|
||||
await backend(page, "CUDA");
|
||||
release();
|
||||
await output(page, "config.yaml");
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("FastVideo-FastH3-8-Step-V2");
|
||||
await expect(
|
||||
page.getByRole("group", { name: "Hardware backend" }).getByRole("button", { name: "NVIDIA", exact: true }),
|
||||
page.getByRole("group", { name: "Hardware backend" }).getByRole("button", { name: "CUDA", exact: true }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
@@ -245,10 +245,18 @@ test("image and audio models have logos and dedicated source recipe pages", asyn
|
||||
await card.click();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("FLUX.2 Klein 4B");
|
||||
await expect(page.getByLabel("Inference command")).toContainText("flux");
|
||||
await expect(page.getByText(/Interactive YAML configuration is not available for this model yet/)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("group", { name: "Run mode" }).getByRole("button", { name: "Python SDK", exact: true }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByRole("button", { name: "Serving", exact: true })).toHaveCount(0);
|
||||
await page.getByLabel("Seed", { exact: true }).fill("0");
|
||||
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeEnabled();
|
||||
await page.goto("/FastVideo/cookbook");
|
||||
await page.getByRole("button", { name: "Audio", exact: true }).click();
|
||||
await expect(page.locator(".recipe-card")).toHaveCount(3);
|
||||
await page.locator(".recipe-card").first().click();
|
||||
await page.getByLabel("Audio duration", { exact: true }).fill("1.5");
|
||||
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("provider labels match FastVideo logos and CompactH3 is replaced by FastH3 Trim", async ({ page }) => {
|
||||
@@ -259,10 +267,112 @@ test("provider labels match FastVideo logos and CompactH3 is replaced by FastH3
|
||||
await expect(page.locator(".recipe-card")).toHaveCount(1);
|
||||
await page.locator(".recipe-card").click();
|
||||
await expect(page.getByRole("heading", { level: 1 })).toHaveText("FastH3 Trim");
|
||||
await expect(page.getByRole("link", { name: "NVFP4 ↗", exact: true })).toHaveAttribute(
|
||||
await expect(page.getByRole("link", { name: "View on Hugging Face ↗", exact: true })).toHaveAttribute(
|
||||
"href",
|
||||
/FastVideo-FastH3-Trim-8-Step-NVFP4/,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Copy YAML", exact: true })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("FastH3 Trim edits drive inference, serving, and complete copyable launch scripts", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: async (text: string) => {
|
||||
(window as Window & { copiedCommand?: string }).copiedCommand = text;
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-trim");
|
||||
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 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", exact: true }).click();
|
||||
const script = await page.evaluate(() => (window as Window & { copiedCommand?: string }).copiedCommand);
|
||||
expect(script).toContain("cat > config.yaml <<'FASTVIDEO_CONFIG'");
|
||||
expect(script).toContain("FastVideo-FastH3-Trim-8-Step-NVFP4");
|
||||
expect(script).toContain('"seed": 0');
|
||||
expect(script).toContain("FASTVIDEO_VSA_TRITON=1");
|
||||
expect(script).toContain("fastvideo serve --config config.yaml");
|
||||
await page.locator(".recipe-request-code summary").click();
|
||||
await expect(page.getByLabel("Client command", { exact: true })).toContainText("A potter finishes a blue bowl.");
|
||||
await chip(page, "Run mode", "CLI");
|
||||
await expect(page.getByLabel("Inference command", { exact: true })).toContainText("fastvideo generate");
|
||||
await output(page, "config.yaml");
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("request:");
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).not.toContainText("server:");
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("num_inference_steps: 9");
|
||||
await chip(page, "Run mode", "Python SDK");
|
||||
await output(page, "Commands");
|
||||
await expect(page.getByLabel("SDK command", { exact: true })).toContainText("VideoGenerator.from_config");
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
test("source-backed controls update the copied command without changing models", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook/minimax-h3/models/minimax-h3-t2v");
|
||||
await chip(page, "GPU count", "2");
|
||||
await expect(page.getByLabel("Inference command", { exact: true })).toContainText("--num-gpus '2'");
|
||||
});
|
||||
|
||||
test("Wan I2V requires an image for CLI and SDK examples without changing server defaults", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook/wan/wan21-i2v");
|
||||
await chip(page, "Run mode", "CLI");
|
||||
await expect(page.getByRole("alert")).toContainText("Enter a source image path");
|
||||
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeDisabled();
|
||||
await page.getByLabel("Source image path", { exact: true }).fill("/data/first-frame.png");
|
||||
await output(page, "config.yaml");
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("image_path: /data/first-frame.png");
|
||||
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeEnabled();
|
||||
await chip(page, "Run mode", "Python SDK");
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).toContainText("image_path: /data/first-frame.png");
|
||||
await page.getByLabel("Source image path", { exact: true }).fill("");
|
||||
await expect(page.getByRole("button", { name: "Copy YAML", exact: true })).toBeDisabled();
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).toHaveCount(0);
|
||||
await chip(page, "Run mode", "Serving");
|
||||
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeEnabled();
|
||||
await expect(page.getByLabel("Generated YAML", { exact: true })).not.toContainText("image_path");
|
||||
});
|
||||
|
||||
test("source editors retain required model flags and preparation commands", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook/wan/models/fastmetal-1-3b-mlx");
|
||||
await expect(page.getByLabel("Height", { exact: true })).toHaveValue("480");
|
||||
await expect(page.getByLabel("Inference command", { exact: true })).toContainText(
|
||||
"hf download FastVideo/FastMetal-1.3B-QAD",
|
||||
);
|
||||
await expect(page.getByLabel("Inference command", { exact: true })).toContainText(
|
||||
"--model-root ./FastMetal-1.3B-QAD",
|
||||
);
|
||||
await page.getByLabel("Seed", { exact: true }).fill("0");
|
||||
await expect(page.getByLabel("Inference command", { exact: true })).toContainText("--seed '0'");
|
||||
await page.getByRole("tab", { name: "Python example", exact: true }).click();
|
||||
await expect(page.getByLabel("Source example", { exact: true })).toContainText("default=480");
|
||||
});
|
||||
|
||||
test("source-backed serving pages expose their actual server settings", async ({ page }) => {
|
||||
await page.goto("/FastVideo/cookbook/wan/models/wan22-t2v");
|
||||
await chip(page, "Run mode", "Serving");
|
||||
await page.locator(".matrix-advanced > summary").click();
|
||||
await page.getByLabel("Port", { exact: true }).fill("9005");
|
||||
await page.getByRole("button", { name: "cURL", exact: true }).click();
|
||||
await expect(page.getByLabel("Client command", { exact: true })).toContainText(":9005/v1/videos/sync");
|
||||
await page.getByRole("tab", { name: "config.yaml", exact: true }).click();
|
||||
await expect(page.getByLabel("Source example", { exact: true })).toContainText("port: 9005");
|
||||
await expect(page.getByLabel("Source example", { exact: true })).toContainText("Wan2.2-T2V-A14B");
|
||||
await expect(page.getByRole("button", { name: "Copy", exact: true })).toBeEnabled();
|
||||
});
|
||||
|
||||
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.reload();
|
||||
await expect(page.getByRole("radio", { name: "RTX 4090 Custom device", exact: true })).toBeChecked();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("custom devices persist and component previews cannot silently generate incompatible YAML", async ({ page }) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Regression checks for source-only API rendering, without importing model code."""
|
||||
import ast
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
@@ -69,6 +70,24 @@ class Empty:
|
||||
self.assertNotIn('[]', rendered)
|
||||
self.assertIn("[", rendered)
|
||||
|
||||
def test_example_controls_edit_literals_without_imports_and_use_javascript_offsets(self):
|
||||
source = '''raise RuntimeError("Never execute this example 🥣")
|
||||
PROMPT = "A bowl"
|
||||
generator = VideoGenerator.from_pretrained("Example/Model", num_gpus=1)
|
||||
parser.add_argument("--num-frames", type=int, default=124)
|
||||
parser.add_argument("--fps", type=float, default=24)
|
||||
generator.generate_video(height=480, prompt=PROMPT)
|
||||
'''
|
||||
controls = renderer.source_controls(source)
|
||||
self.assertEqual({item["name"] for item in controls}, {"prompt", "num_gpus", "num_frames", "height", "fps"})
|
||||
self.assertFalse(next(item for item in controls if item["name"] == "fps")["integer"])
|
||||
frames = next(item for item in controls if item["name"] == "num_frames")
|
||||
self.assertEqual(frames["flag"], "--num-frames")
|
||||
for item in controls:
|
||||
for start, end in item["spans"]:
|
||||
literal = source.encode("utf-16-le")[start * 2:end * 2].decode("utf-16-le")
|
||||
self.assertEqual(ast.literal_eval(literal), item["value"])
|
||||
|
||||
def test_class_includes_constructor_documentation_without_a_separate_method(self):
|
||||
for obj, path in [(self.module["Generator"], "sample.Generator"),
|
||||
(self.public["Generator"], "public.Generator")]:
|
||||
|
||||
@@ -687,6 +687,30 @@
|
||||
margin-block-end: 1rem;
|
||||
overflow: clip;
|
||||
}
|
||||
.recipe-run-modes {
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
.recipe-command-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
order: 3;
|
||||
}
|
||||
.recipe-command-tools button {
|
||||
min-block-size: 1.75rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid var(--recipe-line);
|
||||
border-radius: 0.375rem;
|
||||
color: var(--blume-foreground);
|
||||
background: var(--blume-background);
|
||||
}
|
||||
.recipe-command-tools button[aria-pressed="true"] {
|
||||
border-color: currentColor;
|
||||
}
|
||||
.recipe-environment {
|
||||
padding: 1rem;
|
||||
border-block-end: 1px solid var(--recipe-line);
|
||||
}
|
||||
.recipe-command-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 16,
|
||||
"version": 17,
|
||||
"recipes": [
|
||||
{
|
||||
"id": "fastwan21-t2v",
|
||||
@@ -832,6 +832,31 @@
|
||||
"evidence": "Source-backed",
|
||||
"expected_artifact": "MP4 videos under video_samples_matrixgame3/",
|
||||
"related": ["matrix-game-2"]
|
||||
},
|
||||
{
|
||||
"id": "fasth3-trim-spark",
|
||||
"family": "minimax_h3",
|
||||
"stage": "inference",
|
||||
"task": "Text to video with audio",
|
||||
"label": "FastH3 Trim",
|
||||
"summary": "Eight-step video and synchronized audio with the released NVFP4 transformer, text encoder, and lightweight VAE.",
|
||||
"model": "FastVideo/FastVideo-FastH3-Trim-8-Step-NVFP4",
|
||||
"source": "examples/inference/basic/basic_fasth3_spark_pruned_nvfp4.yaml",
|
||||
"command": "FASTVIDEO_MINIMAX_H3_FUSIONS=all FASTVIDEO_NVFP4_MM_BACKEND=cutlass FASTVIDEO_H3_VAE_TILE_BATCH=1 FASTVIDEO_VSA_TRITON=1 FASTVIDEO_VSA_SM100A=0 FASTVIDEO_FA4=0 FASTVIDEO_ATTENTION_BACKEND=VIDEO_SPARSE_ATTN_H3 FASTVIDEO_STAGE_LOGGING=1 fastvideo generate --config examples/inference/basic/basic_fasth3_spark_pruned_nvfp4.yaml",
|
||||
"gpu_types": [
|
||||
"NVIDIA"
|
||||
],
|
||||
"hardware": {
|
||||
"platform": "cuda",
|
||||
"gpu_count": 1,
|
||||
"device": "spark",
|
||||
"evidence": "source-configured"
|
||||
},
|
||||
"evidence": "Source-backed",
|
||||
"serving": {
|
||||
"source": "examples/serving/openai_fasth3_trim_spark.yaml",
|
||||
"install": "Install FastVideo following the DGX Spark installation guide."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -328,8 +328,13 @@ sidebar family group.
|
||||
`docs-site/lib/cookbook-pages.ts` derives the inventory from
|
||||
`docs/assets/cookbook-recipes.json`. Recipes sharing a checkpoint become variants
|
||||
on one model page. `transfer-docs.mjs` generates source-recipe pages and sidebar
|
||||
entries. Preparation publishes exact checked-in YAML under
|
||||
`assets/cookbook-sources/`; it does not synthesize settings for these pages.
|
||||
entries. Preparation publishes checked-in YAML under `assets/cookbook-sources/`
|
||||
and Python examples with source locations for editable literals under
|
||||
`assets/cookbook-examples/`. Python examples are parsed with AST, never imported.
|
||||
Explicit values in the maintained launch command take priority over argparse
|
||||
defaults. The editor rewrites those literals and preserves required launch flags
|
||||
and preparation commands. Controls inherited from a helper may use an explicitly
|
||||
declared cookbook knob. Settings without a source binding stay hidden.
|
||||
|
||||
For a model with an editable serving catalog, add its display metadata to
|
||||
`recipePages`, map the checkpoint in `backendModels`, and add an MDX overlay with
|
||||
@@ -351,7 +356,22 @@ Old `/cookbook?deployment=<id>` links open the corresponding model page.
|
||||
|
||||
Provider labels follow the displayed logo. FastVideo-owned releases and converted
|
||||
checkpoints using its mark display `FastVideo` as provider. FastH3 Trim replaces
|
||||
the obsolete CompactH3 catalog entry; do not restore its local assembly recipes.
|
||||
the obsolete CompactH3 catalog entry. Its editable NVFP4 deployment uses the
|
||||
released checkpoint and the maintained one-Spark inference configuration, with
|
||||
a serving adaptation under `examples/serving/`. Do not restore the old local
|
||||
assembly recipes or their four-forward sampling settings.
|
||||
|
||||
Every model uses the same header, run modes, command toolbar, hardware matrix,
|
||||
settings, and reset controls. The top choices are Python SDK, CLI, and Serving,
|
||||
limited to supported routes. Hardware backends display CUDA or MLX. Serving
|
||||
appears only where a native server baseline exists.
|
||||
|
||||
The toolbar's Copy action writes the edited YAML or Python example with a quoted
|
||||
heredoc, then runs the matching command. CUDA catalog pages use the same generator
|
||||
settings for SDK, CLI, and serving. Inference uses `request`; serving uses `server`
|
||||
and `default_request`. These commands assume the platform's installation guide
|
||||
has already been followed. cURL and environment tools appear where the recipe
|
||||
provides them. Do not invent CLI flags, serving support, or container commands.
|
||||
|
||||
Custom hardware names can be added through the matrix and persist in the page
|
||||
URL. This is a device preference, not a memory lookup or compatibility result.
|
||||
|
||||
@@ -74,7 +74,7 @@ to documentation, not embedded HTML.
|
||||
|
||||
## Shared JavaScript API
|
||||
|
||||
`docs/js/cookbook-config.mjs` exports the four functions below. The generated
|
||||
`docs/js/cookbook-config.mjs` exports the five functions below. The generated
|
||||
`assets/cookbook-config/cookbook-config.js` bundles them with Ajv and exposes
|
||||
the browser global `FastVideoConfigCookbook` and CommonJS exports:
|
||||
|
||||
@@ -84,6 +84,12 @@ the browser global `FastVideoConfigCookbook` and CommonJS exports:
|
||||
| `loadDeployment(index, id, {signal, fetcher} = {})` | Promise of the selected complete catalog |
|
||||
| `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 |
|
||||
|
||||
`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.
|
||||
|
||||
For WebSocket deployments, `clientRequest` is `null` and `websocketUrl` is
|
||||
present. REST deployments return a request object and omit `websocketUrl`.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
title: FastH3 Trim
|
||||
summary: Eight-step video and audio from the released NVFP4 checkpoint.
|
||||
guide: docs/getting_started/installation/spark_performance.md
|
||||
deployments:
|
||||
spark-rest:
|
||||
label: DGX Spark REST
|
||||
runtime: fastvideo-cuda-rest
|
||||
workload: t2v
|
||||
defaults: examples/serving/openai_fasth3_trim_spark.yaml
|
||||
env:
|
||||
FASTVIDEO_MINIMAX_H3_FUSIONS: all
|
||||
FASTVIDEO_NVFP4_MM_BACKEND: cutlass
|
||||
FASTVIDEO_H3_VAE_TILE_BATCH: "1"
|
||||
FASTVIDEO_VSA_TRITON: "1"
|
||||
FASTVIDEO_VSA_SM100A: "0"
|
||||
FASTVIDEO_FA4: "0"
|
||||
FASTVIDEO_ATTENTION_BACKEND: VIDEO_SPARSE_ATTN_H3
|
||||
FASTVIDEO_STAGE_LOGGING: "1"
|
||||
requirements:
|
||||
- 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.
|
||||
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
|
||||
- default_request.prompt
|
||||
- default_request.sampling.num_frames
|
||||
- default_request.sampling.height
|
||||
- default_request.sampling.width
|
||||
- default_request.sampling.seed
|
||||
overrides:
|
||||
default_request.prompt:
|
||||
title: Prompt
|
||||
type: string
|
||||
minLength: 1
|
||||
default_request.sampling.num_frames:
|
||||
enum: [124, 243, 345]
|
||||
default: 124
|
||||
description: Native H3 frame counts. Hardware fit has not been verified.
|
||||
default_request.sampling.height:
|
||||
minimum: 32
|
||||
multipleOf: 32
|
||||
default: 480
|
||||
default_request.sampling.width:
|
||||
minimum: 32
|
||||
multipleOf: 32
|
||||
default: 832
|
||||
@@ -336,7 +336,7 @@ function toYaml(value, depth = 0) {
|
||||
}
|
||||
|
||||
/** Build same-machine client instructions; a bind-all address is not a client destination. */
|
||||
function sampleRequest(catalog, config) {
|
||||
export function sampleRequest(catalog, config) {
|
||||
const server = { ...catalog.runtime.server_defaults, ...config.server };
|
||||
let host = server.host;
|
||||
if (host === "0.0.0.0") host = "127.0.0.1";
|
||||
@@ -351,7 +351,10 @@ function sampleRequest(catalog, config) {
|
||||
}
|
||||
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: "A river flowing through a peaceful forest" };
|
||||
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";
|
||||
const argv = [
|
||||
"curl",
|
||||
|
||||
@@ -103,6 +103,7 @@ test("browser bundle exposes reusable methods and its error class with no DOM or
|
||||
"loadDeployment",
|
||||
"loadIndex",
|
||||
"resolveConfig",
|
||||
"sampleRequest",
|
||||
]);
|
||||
const api = context.FastVideoConfigCookbook;
|
||||
assert.equal(api.resolveConfig(catalog(), { "server.port": 9000 }).config.server.port, 9000);
|
||||
@@ -485,6 +486,14 @@ test("LoRA sample requests use the startup adapter instead of an edited server a
|
||||
assert.equal(resolveConfig(data, edits).clientRequest.model, "different-server-name");
|
||||
});
|
||||
|
||||
test("sample requests honor an explicitly configured prompt", () => {
|
||||
const data = catalog();
|
||||
data.controls.push({ path: "default_request.prompt", schema: { type: "string" } });
|
||||
const result = resolveConfig(data, { "default_request.prompt": "A potter finishes a blue bowl." });
|
||||
assert.equal(result.clientRequest.prompt, "A potter finishes a blue bowl.");
|
||||
assert.match(result.clientCommand, /A potter finishes a blue bowl/);
|
||||
});
|
||||
|
||||
test("environment variables are present and shell quoted without evaluation", () => {
|
||||
const data = catalog();
|
||||
data.env = { BACKEND: "it's literal $(touch /never-run)" };
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# FastH3 Trim NVFP4. Generator and sampling defaults follow the released
|
||||
# one-Spark recipe: examples/inference/basic/basic_fasth3_spark_pruned_nvfp4.yaml.
|
||||
# Install with the DGX Spark guide. This serving adaptation is not GPU-validated.
|
||||
generator:
|
||||
model_path: FastVideo/FastVideo-FastH3-Trim-8-Step-NVFP4
|
||||
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
|
||||
compile:
|
||||
enabled: false
|
||||
vae_enabled: false
|
||||
pipeline:
|
||||
workload_type: t2v
|
||||
vae_tiling: true
|
||||
experimental:
|
||||
attention_backend: VIDEO_SPARSE_ATTN_H3
|
||||
VSA_sparsity: 0.8
|
||||
VSA_tile_size: 64
|
||||
h3_sequential_load: false
|
||||
inference_torch_compile: false
|
||||
vae_parallel_decode: false
|
||||
video_decode_backend: h3-vae
|
||||
server:
|
||||
host: 127.0.0.1
|
||||
port: 8000
|
||||
output_dir: outputs/fasth3_trim_server
|
||||
served_model_name: fasth3-trim
|
||||
default_request:
|
||||
prompt: A quiet pottery studio with a potter finishing a bowl at the wheel.
|
||||
negative_prompt: ''
|
||||
sampling:
|
||||
seed: 1234
|
||||
height: 480
|
||||
width: 832
|
||||
num_frames: 124
|
||||
fps: 24
|
||||
num_inference_steps: 9
|
||||
guidance_scale: 1.0
|
||||
batch_cfg: false
|
||||
output:
|
||||
return_frames: false
|
||||
Reference in New Issue
Block a user