diff --git a/docs-site/components/cookbook/HardwarePicker.tsx b/docs-site/components/cookbook/HardwarePicker.tsx index 927fc2b84..e012d0489 100644 --- a/docs-site/components/cookbook/HardwarePicker.tsx +++ b/docs-site/components/cookbook/HardwarePicker.tsx @@ -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() { diff --git a/docs-site/components/cookbook/ModelRelease.tsx b/docs-site/components/cookbook/ModelRelease.tsx deleted file mode 100644 index 63299bb2e..000000000 --- a/docs-site/components/cookbook/ModelRelease.tsx +++ /dev/null @@ -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 ( -
- -
-

FastH3 Trim is the smaller FastH3 model, previously called CompactH3.

-

A maintained serving recipe is being prepared. Use the published checkpoint instructions for setup.

-
-
-
-

Weights

-
-
- {[ - ["BF16", "FastVideo-FastH3-Trim-8-Step"], - ["FP8", "FastVideo-FastH3-Trim-8-Step-FP8"], - ["NVFP4", "FastVideo-FastH3-Trim-8-Step-NVFP4"], - ].map(([name, repo]) => ( - - {name} ↗ - - ))} -
-
-
-
-

Components

-
- -
-
-
-
- FastH3 checkpoint guide → - Release collection ↗ -
-
- ); -} diff --git a/docs-site/content/cookbook/minimax-h3/fasth3-trim.mdx b/docs-site/content/cookbook/minimax-h3/fasth3-trim.mdx new file mode 100644 index 000000000..41be59320 --- /dev/null +++ b/docs-site/content/cookbook/minimax-h3/fasth3-trim.mdx @@ -0,0 +1,7 @@ +--- +title: FastH3 Trim +description: Configure FastH3 Trim NVFP4 and copy inference or serving commands. +mode: frame +--- + + diff --git a/docs-site/cookbook-components-preview.png b/docs-site/cookbook-components-preview.png index a687792a7..e68d07b78 100644 Binary files a/docs-site/cookbook-components-preview.png and b/docs-site/cookbook-components-preview.png differ diff --git a/docs-site/islands/Cookbook.tsx b/docs-site/islands/Cookbook.tsx index 06b77e720..92ef9f2a4 100644 --- a/docs-site/islands/Cookbook.tsx +++ b/docs-site/islands/Cookbook.tsx @@ -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>({}); const [parseErrors, setParseErrors] = useState>({}); const [reset, setReset] = useState(0); const [notice, setNotice] = useState(""); let result: Result | undefined; + let output: ReturnType | 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={ <> - - } /> + { +
+ {(mlx ? ["serving"] : ["sdk", "inference", "serving"]).map((mode) => ( + + ))} +
+ }
- +
Unverified recipe +
+ + {runMode === "serving" && ( + + )} + {Object.keys(catalog.env).length > 0 && ( + + )} +
Commands config.yaml
+ {showEnv && ( +
+              
+                {Object.entries(catalog.env)
+                  .map(([name, value]) => `${name}=${value}`)
+                  .join("\n")}
+              
+            
+ )} {failure && (

{failure}

@@ -316,38 +390,50 @@ function Editor({
)} - {result && ( + {result && output && ( <>
- Serve - + {runMode === "sdk" ? "Python SDK" : runMode === "inference" ? "Generate" : "Serve"}
-
-                  {result.command}
+                
+                  {launchCommand}
                 
-
- {result.websocketUrl ? "Health check and WebSocket" : "Example request"} - -
-                    {result.clientCommand}
-                  
- {result.websocketUrl && ( -

- WebSocket URL: {result.websocketUrl} -

- )} -
+

+ Includes your settings and writes config.yaml. Run from an installed FastVideo checkout. +

+ {runMode === "serving" && ( +
setShowClient(event.currentTarget.open)} + > + {result.websocketUrl ? "Health check and WebSocket" : "Example request"} + +
+                      {result.clientCommand}
+                    
+ {result.websocketUrl && ( +

+ WebSocket URL: {result.websocketUrl} +

+ )} +
+ )} )} - {result && ( + {output && (
-                {result.yaml}
+                {output.yaml}
               
)}
@@ -358,7 +444,7 @@ function Editor({
- Installation {catalog.runtime.label} + Installation {catalog.runtime.label.replace(/\s*REST/gi, "")} {catalog.requirements?.length ? (
    @@ -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"} ))} @@ -415,8 +501,43 @@ function Editor({

    Hardware fit has not been verified. GPU count updates the YAML.

    + {catalog.workload === "i2v" && runMode !== "serving" && ( + +
    + + { + setSourceImage(event.target.value); + setNotice(""); + }} + /> +

    + Path to an image on the machine running FastVideo. Included as request.inputs.image_path. +

    +
    +
    + )} {model.family === "FastH3" && ( + {model.id === "fasth3-trim" && ( +
    + {["NVFP4 transformer", "50-layer NVFP4 encoder", "Lightweight video VAE", "H3 audio VAE"].map( + (name) => ( + + {name} + + ), + )} +
    + )}
    )} @@ -461,11 +582,12 @@ function Editor({
- Save config.yaml before running the server. + Copy config and launch together, or download config.yaml first. ))}
diff --git a/docs-site/islands/SourceRecipe.tsx b/docs-site/islands/SourceRecipe.tsx index 9a3c35175..f9e3411d9 100644 --- a/docs-site/islands/SourceRecipe.tsx +++ b/docs-site/islands/SourceRecipe.tsx @@ -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 = { + 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>({}); + 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, path: string[] = []) { + for (const [name, item] of Object.entries(value)) { + if (item && typeof item === "object" && !Array.isArray(item)) + visit(item as Record, [...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 ( +
+ {field.choices || typeof value === "boolean" ? ( + {field.label} + ) : ( + + )} + {field.choices ? ( +
+ {field.choices.map((option) => { + const choice = typeof option === "object" ? option.value : option; + return ( + + ); + })} +
+ ) : typeof value === "boolean" ? ( +
+ {[true, false].map((choice) => ( + + ))} +
+ ) : ( + + setEdits({ + ...edits, + [index]: + typeof field.value === "number" + ? event.target.value === "" + ? NaN + : Number(event.target.value) + : event.target.value, + }) + } + /> + )} +
+ ); + }; return (
- {yamlSource ? ( - <> - - - - ) : ( - - )} + + } /> -
-
- Unverified recipe - -
-
-
-            {command}
-          
- {yamlSource && ( -
- View source YAML - {error ? ( -

{error}

- ) : ( -
-                  {yaml || "Loading YAML…"}
-                
+ ))} +
+
+ +
+ Unverified recipe +
+ + {mode === "serving" && ( + )} - + {environment && ( + + )} +
+ + Commands + {yamlSource ? "config.yaml" : "Python example"} + +
+ {showEnv && ( +
+              {environment}
+            
)} -
+ {(error || failure) && ( +

+ {error || failure} +

+ )} + +
+ {mode === "serving" ? "Serve" : mode === "sdk" ? "Python SDK" : "Generate"} +
+
+              {launch}
+            
+

Includes your settings. Run from an installed FastVideo checkout.

+ {clientCommand && ( +
setShowClient(event.currentTarget.open)} + > + Example request + +
+                  {clientCommand}
+                
+
+ )} +
+ +
+              {displayedSource || "Loading example…"}
+            
+
+

{notice}

- Installation and setup + + Installation and setup {mlx ? "MLX" : "CUDA"} + {mode === "serving" && recipe.serving && (
             {recipe.serving.install}
           
)} + {mode === "serving" && recipe.serving?.prepare && ( + <> +
+              {recipe.serving.prepare}
+            
+ + + )} Model setup guide ↗
-
-
-

Recipe

-
-
- {model.recipes.map((entry) => ( - - ))} -
-
-
+

Hardware

-
- {recipe.gpu_types.map((platform) => ( - - {platform === "Apple Silicon" ? "MLX" : platform} - +
+ {[ + ...new Set(model.recipes.map((entry) => (entry.gpu_types.includes("Apple Silicon") ? "MLX" : "CUDA"))), + ].map((platform) => ( + ))}
+
+ id === hardware)?.[0] ?? devices[0][0]) + } + onChange={chooseHardware} + /> +
+ Resources + {example?.controls.map((field, i) => (field.name === "num_gpus" ? control(field, i) : null))} + {!example?.controls.some((field) => field.name === "num_gpus") && ( +

{mlx ? "1 Apple Silicon host" : `${recipe.hardware?.gpu_count ?? 1} GPU in the source example`}

+ )} +
+

- {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.

-
-

Run mode

-
-
- - {recipe.serving && ( - - )} + {model.recipes.filter((entry) => entry.gpu_types.includes(mlx ? "Apple Silicon" : "NVIDIA")).length > 1 && ( +
+

Recipe

+
+ {model.recipes + .filter((entry) => entry.gpu_types.includes(mlx ? "Apple Silicon" : "NVIDIA")) + .map((entry) => ( + + ))}
+ )} +
+

Generation settings

+
+
+ {example?.controls.map((field, i) => + field.name !== "num_gpus" && field.path?.[0] !== "server" ? control(field, i) : null, + )} +
+ {example && !example.controls.length && ( +

+ This example uses its model defaults. Open the Python example to customize its pipeline. +

+ )} +
+ {example?.controls.some((field) => field.path?.[0] === "server") && ( +
+ + Advanced Server settings + +
+

Server

+
+ {example.controls.map((field, i) => (field.path?.[0] === "server" ? control(field, i) : null))} +
+
+
+ )}
-
- Model guide → +
- View source ↗ + View original example ↗ - - Run from the FastVideo checkout. Source recipe. Interactive YAML configuration is not available for this model - yet. - +
); diff --git a/docs-site/lib/cookbook-commands.ts b/docs-site/lib/cookbook-commands.ts new file mode 100644 index 000000000..25e43e695 --- /dev/null +++ b/docs-site/lib/cookbook-commands.ts @@ -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 = {}, +) { + 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; + const output = (defaults.output ?? {}) as Record; + const generator = result.config.generator as Record; + const pipeline = generator.pipeline as Record | undefined; + const inputs = { ...((defaults.inputs ?? {}) as Record) }; + 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", + ), + }; +} diff --git a/docs-site/lib/cookbook-pages.ts b/docs-site/lib/cookbook-pages.ts index bf15c36f0..041ea9761 100644 --- a/docs-site/lib/cookbook-pages.ts +++ b/docs-site/lib/cookbook-pages.ts @@ -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 = { @@ -75,6 +92,7 @@ const familyInfo: Record = { "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) || diff --git a/docs-site/lib/cookbook.ts b/docs-site/lib/cookbook.ts index fd066d26f..54ae851f4 100644 --- a/docs-site/lib/cookbook.ts +++ b/docs-site/lib/cookbook.ts @@ -36,6 +36,8 @@ export interface Catalog { id: string; title: string; summary?: string; + workload: string; + env: Record; base_config: Record; runtime: { label: string; @@ -48,12 +50,21 @@ export interface Catalog { guide?: { url: string; label?: string }; } export interface Result { + config: Record; 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, + ): { command: string }; loadIndex(url: string, options?: { signal?: AbortSignal }): Promise; loadDeployment(index: Index, id: string, options?: { signal?: AbortSignal }): Promise; getOptions(catalog: Catalog, config?: Catalog["base_config"], edits?: Record): Field[]; diff --git a/docs-site/navigation.json b/docs-site/navigation.json index 41b88323b..06ba8f7cc 100644 --- a/docs-site/navigation.json +++ b/docs-site/navigation.json @@ -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" diff --git a/docs-site/package.json b/docs-site/package.json index e3a5ea717..b6eb4d4fa 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -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", diff --git a/docs-site/scripts/prepare-content.mjs b/docs-site/scripts/prepare-content.mjs index d9b7042fd..7fd24cc1c 100644 --- a/docs-site/scripts/prepare-content.mjs +++ b/docs-site/scripts/prepare-content.mjs @@ -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); diff --git a/docs-site/scripts/prepare-python-docs.py b/docs-site/scripts/prepare-python-docs.py index 11b82ba1c..3f86b69a9 100644 --- a/docs-site/scripts/prepare-python-docs.py +++ b/docs-site/scripts/prepare-python-docs.py @@ -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, diff --git a/docs-site/tests/commands.test.mjs b/docs-site/tests/commands.test.mjs new file mode 100644 index 000000000..91e00f075 --- /dev/null +++ b/docs-site/tests/commands.test.mjs @@ -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); +}); diff --git a/docs-site/tests/cookbook.spec.ts b/docs-site/tests/cookbook.spec.ts index 9f44fe642..73a7a77e3 100644 --- a/docs-site/tests/cookbook.spec.ts +++ b/docs-site/tests/cookbook.spec.ts @@ -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 }) => { diff --git a/docs-site/tests/test_python_reference.py b/docs-site/tests/test_python_reference.py index ac9d82db8..173b7be32 100644 --- a/docs-site/tests/test_python_reference.py +++ b/docs-site/tests/test_python_reference.py @@ -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")]: diff --git a/docs-site/theme.css b/docs-site/theme.css index 84888b1e5..6cf0794c8 100644 --- a/docs-site/theme.css +++ b/docs-site/theme.css @@ -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; diff --git a/docs/assets/cookbook-recipes.json b/docs/assets/cookbook-recipes.json index 8b26bf97c..0038beda5 100644 --- a/docs/assets/cookbook-recipes.json +++ b/docs/assets/cookbook-recipes.json @@ -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." + } } ] } diff --git a/docs/contributing/cookbook_configuration.md b/docs/contributing/cookbook_configuration.md index f2f746ada..41d38d127 100644 --- a/docs/contributing/cookbook_configuration.md +++ b/docs/contributing/cookbook_configuration.md @@ -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=` 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. diff --git a/docs/cookbook/design.md b/docs/cookbook/design.md index ffe770489..3eae75275 100644 --- a/docs/cookbook/design.md +++ b/docs/cookbook/design.md @@ -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`. diff --git a/docs/cookbook/recipes/fasth3-trim.yaml b/docs/cookbook/recipes/fasth3-trim.yaml new file mode 100644 index 000000000..62b68c69c --- /dev/null +++ b/docs/cookbook/recipes/fasth3-trim.yaml @@ -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 diff --git a/docs/js/cookbook-config.mjs b/docs/js/cookbook-config.mjs index 48742382e..867c0c129 100644 --- a/docs/js/cookbook-config.mjs +++ b/docs/js/cookbook-config.mjs @@ -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", diff --git a/docs/tests/cookbook-config.test.mjs b/docs/tests/cookbook-config.test.mjs index bb7f2cb03..703a795ae 100644 --- a/docs/tests/cookbook-config.test.mjs +++ b/docs/tests/cookbook-config.test.mjs @@ -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)" }; diff --git a/examples/serving/openai_fasth3_trim_spark.yaml b/examples/serving/openai_fasth3_trim_spark.yaml new file mode 100644 index 000000000..c517873f8 --- /dev/null +++ b/examples/serving/openai_fasth3_trim_spark.yaml @@ -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