Files
hao-ai-lab-FastVideo/docs-site/islands/SourceRecipe.tsx
T

564 lines
22 KiB
TypeScript

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 RecipeFacts from "@/components/cookbook/RecipeFacts";
import { configAndLaunch, fileAndLaunch, sdkCommand, pythonSource } from "@/lib/cookbook-commands";
import { sitePath, type DirectoryModel } from "@/lib/cookbook-pages";
import { api } from "@/lib/cookbook";
import {
gpuCountFrom,
readHardwareSelection,
resolveHardware,
useHardwareCatalog,
writeHardwareSelection,
type HardwareSelection,
} from "@/lib/hardware";
interface Control {
name: string;
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("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<HardwareSelection>(() =>
readHardwareSelection(new URL(window.location.href).searchParams),
);
const hardwareCatalog = useHardwareCatalog();
const recipe = model.recipes.find((recipe) => recipe.id === selected)!;
const yamlSource =
mode === "serving" ? recipe.serving?.source : /\.ya?ml$/.test(recipe.source) ? recipe.source : undefined;
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();
setError("");
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, recipe]);
function selectMode(next: string) {
const reset = (next === "serving") !== (mode === "serving");
setMode(next);
if (reset) setEdits({});
setNotice(reset ? "Serving uses a different native example, so generation edits were reset." : "");
}
async function copy(value: string, message = "Copied to clipboard.") {
try {
await navigator.clipboard.writeText(value);
setNotice(message);
} catch {
setNotice("Copy failed. Select and copy the text below.");
}
}
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.";
}
let config: Record<string, unknown> | undefined;
if (example && yamlSource) {
try {
const root = parse(source) as Record<string, unknown>;
config = root;
for (const [key, value] of Object.entries(edits)) {
const path = example.controls[Number(key)].path!;
const parent = path
.slice(0, -1)
.reduce<Record<string, unknown>>((item, name) => item[name] as Record<string, unknown>, root);
parent[path.at(-1)!] = value;
}
source = stringify(root);
} catch {
failure = "The checked-in YAML could not be parsed, so runnable output is withheld.";
}
} 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.replaceAll(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;
// The SDK consumes only generator + request; serving and CLI keep server and every native field.
const displayedSource =
config && sdk
? stringify({
generator: config.generator as Record<string, unknown>,
request: config.request ?? config.default_request,
})
: source;
const launch = sdk ? command.replace(/fastvideo generate --config config\.yaml$/, sdkCommand) : command;
const ready = !!example && !failure;
const workload = recipe.task.toLowerCase().includes("image") ? "i2v" : "t2v";
const clientCommand =
mode === "serving" && ready
? api.sampleRequest(
{
runtime: { interface: "rest", server_defaults: { host: "127.0.0.1", port: 8000 } },
model: { id: model.model },
workload,
},
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 hardwareRecord = resolveHardware(hardware, hardwareCatalog.records);
const gpuCount = config ? gpuCountFrom(config) : (recipe.hardware?.gpu_count ?? 1);
const chooseHardware = (selection: HardwareSelection) => {
setHardware(selection);
const url = new URL(window.location.href);
writeHardwareSelection(url, selection);
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={
<>
<Button
variant="outline"
disabled={!ready}
onClick={() => copy(displayedSource, yamlSource ? "Copied config.yaml." : "Copied Python example.")}
>
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>
</>
}
/>
<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>
<section className="recipe-command-box" aria-label="Source recipe">
<Tabs.Root value={outputTab} onValueChange={setOutputTab}>
<div className="recipe-command-toolbar">
<div className="recipe-command-tools">
<button
type="button"
disabled={!ready}
onClick={() =>
copy(
launchScript,
yamlSource ? "Copied config.yaml and launch command." : "Copied example and launch command.",
)
}
>
{yamlSource ? "Copy config + command" : "Copy example + command"}
</button>
{mode === "serving" && (
<button
type="button"
disabled={!clientCommand}
aria-expanded={showClient}
aria-controls="source-client-command"
onClick={() => {
setOutputTab("commands");
setShowClient(!showClient);
}}
>
Show request
</button>
)}
{environment && (
<button type="button" aria-pressed={showEnv} onClick={() => setShowEnv(!showEnv)}>
Show environment
</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>
)}
{(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">
Uses your settings; the copy action includes the source file and launch command. 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, "Copied example request.")}>
Copy request 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 <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="Configuration settings">
<div className="matrix-row">
<h2>Hardware</h2>
<div className="matrix-row-content">
<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;
const target = model.recipes.find((entry) =>
entry.gpu_types.includes(platform === "MLX" ? "Apple Silicon" : "NVIDIA"),
)!;
setSelected(target.id);
setMode("sdk");
setEdits({});
setNotice(`Switched to the ${platform} recipe; generation edits were reset.`);
}}
>
{platform}
</button>
))}
</div>
<div className="hardware-resource-grid">
<HardwarePicker
backend={mlx ? "mlx" : "cuda"}
catalog={hardwareCatalog.records}
status={hardwareCatalog.status}
error={hardwareCatalog.error}
reload={hardwareCatalog.reload}
value={hardware}
onChange={chooseHardware}
gpuCount={gpuCount}
/>
<div className="matrix-resources">
<span className="matrix-resource-title">Resources</span>
{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>
)}
<a href={sitePath(mlx ? "/getting_started/installation/mlx" : "/getting_started/installation/gpu")}>
Setup guide ↗
</a>
</div>
</div>
<RecipeFacts
validity={failure ? "invalid" : example ? "valid" : "loading"}
validityText={yamlSource ? "YAML parses" : "Example settings applied"}
validityDetail="Checks that the YAML parses and that edited numbers are valid. It does not run the recipe."
subject={yamlSource ? "configuration" : "example"}
/>
</div>
</div>
{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-bottom-actions">
<a
href={`https://github.com/hao-ai-lab/FastVideo/blob/main/${mode === "serving" ? recipe.serving?.source : recipe.source}`}
>
View original example ↗
</a>
<Button
variant="ghost"
onClick={() => {
setEdits({});
setNotice("Restored the baseline example.");
}}
>
Reset changes
</Button>
</div>
</div>
);
}