[docs] Make cookbook copy actions explicit and hardware opt-in (#1992)

This commit is contained in:
Aryan Kumar
2026-10-20 09:00:56 -07:00
committed by GitHub
parent d4f144581d
commit 137d9110ca
12 changed files with 406 additions and 183 deletions
@@ -1,57 +0,0 @@
import { useState } from "react";
/** A UI experiment, deliberately separated from the serving schema and generated YAML. */
export default function ComponentPlan({ onDraftChange }: { onDraftChange?: (draft: boolean) => void }) {
const [selection, setSelection] = useState<Record<string, string>>({});
const groups = [
{ name: "Weights", values: ["Recipe default", "FP8", "NVFP4"] },
{ name: "Text encoder", values: ["Recipe default", "NVFP4 encoder"] },
{ name: "Video decoder", values: ["Recipe default", "FP16 VAE", "NVFP4 VAE"] },
];
return (
<details className="component-plan">
<summary>
Explore components <span>Design preview</span>
</summary>
<div className="component-plan-fields">
{groups.map((group) => (
<div key={group.name}>
<span className="component-plan-label">{group.name}</span>
<div className="option-chips" role="group" aria-label={group.name}>
{group.values.map((value) => (
<button
key={value}
type="button"
aria-pressed={(selection[group.name] ?? "Recipe default") === value}
onClick={() => {
const next = { ...selection, [group.name]: value };
setSelection(next);
onDraftChange?.(Object.values(next).some((value) => value !== "Recipe default"));
}}
>
{value}
</button>
))}
</div>
</div>
))}
</div>
<p>
Preview only. Alternate components need a compatible recipe; some releases are planned. These choices do not
change YAML.
</p>
{Object.values(selection).some((value) => value !== "Recipe default") && (
<button
type="button"
className="component-plan-reset"
onClick={() => {
setSelection({});
onDraftChange?.(false);
}}
>
Restore recipe components
</button>
)}
</details>
);
}
@@ -1,16 +1,20 @@
import { useEffect, useId, useState } from "react";
import { Input } from "@/components/ui/input";
type Purpose = "target" | "filter";
export default function HardwarePicker({
backend,
options,
value,
onChange,
purpose = "target",
}: {
backend: string;
options: string[][];
value: string;
onChange: (value: string) => void;
purpose?: Purpose;
}) {
const [search, setSearch] = useState("");
const [adding, setAdding] = useState(false);
@@ -21,6 +25,8 @@ export default function HardwarePicker({
}, []);
const id = useId();
const known = options.filter((option) => option.join(" ").toLowerCase().includes(search.toLowerCase()));
const selected = value.startsWith("custom:") ? value.slice(7) : options.find(([key]) => key === value)?.[1];
const savedUnknown = !!value && !selected;
function add() {
const name = search.trim();
if (!name) return;
@@ -29,10 +35,15 @@ export default function HardwarePicker({
setSearch("");
setAdding(false);
}
function clear() {
setCustom("");
setSearch("");
onChange("");
}
return (
<div className="hardware-picker">
<fieldset className="hardware-matrix">
<legend className="sr-only">Hardware target</legend>
<legend className="sr-only">Hardware {purpose}</legend>
{known.map(([key, title, detail]) => (
<label key={key} data-selected={value === key}>
<input
@@ -63,9 +74,21 @@ export default function HardwarePicker({
</label>
)}
</fieldset>
<button type="button" className="hardware-add" aria-expanded={adding} onClick={() => setAdding(!adding)}>
+ Find or add {backend === "mlx" ? "a Mac" : "a GPU"}
</button>
<div className="hardware-picker-actions">
<button type="button" className="hardware-add" aria-expanded={adding} onClick={() => setAdding(!adding)}>
+ Find or add {backend === "mlx" ? "a Mac" : "a GPU"}
</button>
{(selected || savedUnknown) && (
<button type="button" className="hardware-clear" onClick={clear}>
Clear hardware {purpose}
</button>
)}
</div>
{!selected && (
<p className="hardware-current" role="status">
{savedUnknown ? "Saved hardware target is not in this list." : `No hardware ${purpose} selected.`}
</p>
)}
{adding && (
<form
className="hardware-search"
@@ -83,7 +106,11 @@ export default function HardwarePicker({
placeholder={backend === "mlx" ? "e.g. M4 Max" : "e.g. RTX 4090"}
maxLength={80}
/>
<p>Search the list or add your device. Memory lookup and fit checks are not connected yet.</p>
<p>
{purpose === "filter"
? "Saved in this URL and used to filter by runtime. Fit checks appear only when an estimate covers the device."
: "Saved in this URL as your setup target. Device choice does not change generated configuration."}
</p>
<button type="submit" disabled={!search.trim()}>
Add device
</button>
@@ -0,0 +1,97 @@
import { useState } from "react";
export type Validity = "valid" | "invalid" | "loading";
export type Fit = "fits" | "tight" | "exceeds" | "unsupported" | "unknown";
export type Evidence = "recorded" | "none" | "stale";
const validityLabel: Record<Validity, string> = {
valid: "Configuration valid",
invalid: "Configuration invalid",
loading: "Checking configuration",
};
const fitLabel: Record<Fit, string> = {
fits: "Fits estimate",
tight: "Tight fit",
exceeds: "Likely exceeds memory",
unsupported: "Unsupported combination",
unknown: "Fit unknown",
};
const evidenceLabel: Record<Evidence, string> = {
recorded: "Run evidence available",
none: "No recorded run",
stale: "Run evidence does not match these settings",
};
/**
* Three separate facts: the recipe schema accepts the values, a memory estimate
* covers the configuration, and a recorded run matches it. No positive claim is
* made until the shared API supplies the corresponding fact.
*/
export default function RecipeFacts({
validity,
fit = "unknown",
evidence = "none",
subject = "configuration",
detail,
validityText,
validityDetail,
}: {
validity: Validity;
fit?: Fit;
evidence?: Evidence;
subject?: "configuration" | "example";
detail?: string;
validityText?: string;
validityDetail?: string;
}) {
const [open, setOpen] = useState(false);
const id = `recipe-facts-${validity}-${fit}-${evidence}`;
return (
<div className="recipe-facts">
<div className="recipe-fact-list" aria-label="Recipe facts">
<span data-state={validity}>
{validity === "valid" ? (validityText ?? `Valid ${subject}`) : validityLabel[validity]}
</span>
<span data-state={fit}>{fitLabel[fit]}</span>
<span data-state={evidence}>{evidenceLabel[evidence]}</span>
</div>
<button
type="button"
className="recipe-facts-toggle"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
>
Details
</button>
{open && (
<div className="recipe-facts-detail" id={id}>
<p>
<strong>{validityLabel[validity]}.</strong>{" "}
{validityDetail ??
(validity === "valid"
? "Selected values pass the recipe schema and runtime topology checks."
: validity === "invalid"
? "Output is withheld until the highlighted fields are corrected."
: "Values are still being checked.")}
</p>
<p>
<strong>{fitLabel[fit]}.</strong>{" "}
{fit === "unknown"
? "No memory estimate covers this deployment, device, and request yet. This is not a fit claim."
: "The shared capacity API supplied this estimate; it is not a measured guarantee."}
</p>
<p>
<strong>{evidenceLabel[evidence]}.</strong>{" "}
{evidence === "none"
? "Successful runs on other checkpoints, settings, software revisions, or GPU layouts do not establish this configuration."
: evidence === "stale"
? "A recorded run exists, but edited settings fall outside what it covers."
: "A recorded run matches the deployment and settings shown."}
</p>
{detail && <p>{detail}</p>}
</div>
)}
</div>
);
}
+47 -29
View File
@@ -6,9 +6,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import SourceRecipe from "./SourceRecipe";
import ComponentPlan from "@/components/cookbook/ComponentPlan";
import HardwarePicker from "@/components/cookbook/HardwarePicker";
import ModelHeader from "@/components/cookbook/ModelHeader";
import RecipeFacts from "@/components/cookbook/RecipeFacts";
import { configAndLaunch, inferenceOutput, sdkCommand } from "@/lib/cookbook-commands";
import {
api,
@@ -193,18 +193,16 @@ function Editor({
selectRuntime: (id: string) => void;
}) {
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") ??
(catalog.id.startsWith("fasth3-trim/") ? "spark" : "unspecified"),
);
const [hardware, setHardware] = useState(() => {
const requested = new URL(window.location.href).searchParams.get("hardware");
return requested && requested !== "unspecified" ? requested : "";
});
const [edits, setEdits] = useState<Record<string, Value>>({});
const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
const [reset, setReset] = useState(0);
@@ -214,8 +212,6 @@ function Editor({
let failure = "";
let failedPath: string | undefined;
try {
if (componentDraft)
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 =
@@ -247,10 +243,10 @@ function Editor({
setEdits(next);
setParseErrors(errors);
}
async function copy(text: string) {
async function copy(text: string, message = "Copied to clipboard.") {
try {
await navigator.clipboard.writeText(text);
setNotice("Copied to clipboard.");
setNotice(message);
} catch {
setNotice("Copy failed. Select and copy the text below.");
}
@@ -280,7 +276,8 @@ function Editor({
["spark", "DGX Spark", "GB10"],
]
).filter(([id]) => !nvfp4 || id !== "h100");
const selectedHardware = hardwareOptions.find(([value]) => value === hardware) ?? hardwareOptions[0];
const selectedHardware = hardwareOptions.find(([value]) => value === hardware);
const hardwareLabel = hardware.startsWith("custom:") ? hardware.slice(7) : selectedHardware?.[1];
const model = modelDirectory.find((model) => model.id === catalog.id.split("/")[0])!;
const renderField = (field: Field) => (
<FieldControl
@@ -294,9 +291,16 @@ function Editor({
function chooseHardware(value: string) {
setHardware(value);
const url = new URL(window.location.href);
url.searchParams.set("hardware", value);
if (value) url.searchParams.set("hardware", value);
else url.searchParams.delete("hardware");
window.history.replaceState(null, "", url);
}
const modes = mlx || catalog.runtime.interface === "websocket" ? ["serving"] : ["sdk", "inference", "serving"];
const modeNote = mlx
? "Apple Silicon runs through the native MLX server in this recipe. There is no CUDA CLI or Python SDK launch for this deployment."
: catalog.runtime.interface === "websocket"
? "This deployment uses the native WebSocket server. Only serving is recorded for it."
: "";
const advancedGroups = groups.filter(
(group) => group === "server" || group.includes("parallelism") || group.startsWith("streaming"),
);
@@ -309,7 +313,11 @@ function Editor({
model={model}
actions={
<>
<Button variant="outline" disabled={!output} onClick={() => output && copy(output.yaml)}>
<Button
variant="outline"
disabled={!output}
onClick={() => output && copy(output.yaml, "Copied config.yaml.")}
>
Copy YAML
</Button>
<Button disabled={!output} onClick={download}>
@@ -320,7 +328,7 @@ function Editor({
/>
{
<div className="recipe-run-modes option-chips" role="group" aria-label="Run mode">
{(mlx ? ["serving"] : ["sdk", "inference", "serving"]).map((mode) => (
{modes.map((mode) => (
<button
type="button"
key={mode}
@@ -335,17 +343,19 @@ function Editor({
))}
</div>
}
{modeNote && <p className="recipe-mode-note">{modeNote}</p>}
<section className="recipe-command-box" aria-label="Generated configuration">
<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!))}
onClick={() =>
output && copy(configAndLaunch(output.yaml, launchCommand!), "Copied config.yaml and launch command.")
}
>
Copy
Copy config + command
</button>
{runMode === "serving" && (
<button
@@ -358,12 +368,12 @@ function Editor({
setShowClient(!showClient);
}}
>
cURL
Show request
</button>
)}
{Object.keys(catalog.env).length > 0 && (
<button type="button" aria-pressed={showEnv} onClick={() => setShowEnv(!showEnv)}>
Env
Show environment
</button>
)}
</div>
@@ -404,7 +414,8 @@ function Editor({
<code>{launchCommand}</code>
</pre>
<p className="matrix-note">
Includes your settings and writes config.yaml. Run from an installed FastVideo checkout.
Uses your settings; Copy config + command writes config.yaml first. Run from an installed FastVideo
checkout.
</p>
{runMode === "serving" && (
<details
@@ -414,8 +425,8 @@ function Editor({
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 type="button" onClick={() => copy(result.clientCommand, "Copied example request.")}>
Copy request command
</button>
<pre tabIndex={0} aria-label="Client command">
<code>{result.clientCommand}</code>
@@ -479,7 +490,7 @@ function Editor({
<HardwarePicker
backend={mlx ? "mlx" : "nvidia"}
options={hardwareOptions}
value={hardware.startsWith("custom:") ? hardware : selectedHardware[0]}
value={hardware}
onChange={chooseHardware}
/>
<div className="matrix-resources">
@@ -490,7 +501,7 @@ function Editor({
href={sitePath(
mlx
? "/getting_started/installation/mlx"
: selectedHardware[0] === "spark"
: hardware === "spark"
? "/getting_started/installation/spark"
: "/getting_started/installation/gpu",
)}
@@ -499,7 +510,14 @@ function Editor({
</a>
</div>
</div>
<p className="matrix-note">Hardware fit has not been verified. GPU count updates the YAML.</p>
{hardwareLabel ? (
<p className="matrix-note">
{mlx
? `${hardwareLabel} is saved as your target. Apple Silicon uses unified memory; dedicated GPU memory does not apply.`
: `${hardwareLabel} is saved as your target. It does not change the generated YAML; use GPU count and parallelism for the run.`}
</p>
) : null}
<RecipeFacts validity={failure ? "invalid" : result ? "valid" : "loading"} fit="unknown" evidence="none" />
</MatrixRow>
{catalog.workload === "i2v" && runMode !== "serving" && (
<MatrixRow title="Inputs">
@@ -538,7 +556,9 @@ function Editor({
)}
</div>
)}
<ComponentPlan key={reset} onDraftChange={setComponentDraft} />
<p className="matrix-note">
Components come from the maintained recipe. They are not individually selectable in the cookbook yet.
</p>
</MatrixRow>
)}
{standardGroups.map((group) => (
@@ -570,7 +590,6 @@ function Editor({
setParseErrors(
Object.fromEntries(Object.entries(parseErrors).filter(([path]) => !path.includes("parallelism"))),
);
setComponentDraft(false);
setNotice("Parallelism set to automatic.");
}}
>
@@ -588,7 +607,6 @@ function Editor({
onClick={() => {
setEdits({});
setSourceImage("");
setComponentDraft(false);
setParseErrors({});
setReset(reset + 1);
setNotice("Restored the baseline configuration.");
+85 -36
View File
@@ -5,6 +5,7 @@ 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";
@@ -50,9 +51,13 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
const [outputTab, setOutputTab] = useState("commands");
const [error, setError] = useState("");
const [edits, setEdits] = useState<Record<number, string | number | boolean>>({});
const [hardware, setHardware] = useState("unspecified");
const [hardware, setHardware] = useState(() => {
const requested = new URL(window.location.href).searchParams.get("hardware");
return requested && requested !== "unspecified" ? requested : "";
});
useEffect(() => {
setHardware(new URL(window.location.href).searchParams.get("hardware") ?? "unspecified");
const requested = new URL(window.location.href).searchParams.get("hardware");
setHardware(requested && requested !== "unspecified" ? requested : "");
}, []);
const recipe = model.recipes.find((recipe) => recipe.id === selected)!;
const yamlSource =
@@ -115,14 +120,15 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
return () => controller.abort();
}, [sourceUrl, yamlSource, recipe]);
function selectMode(next: string) {
const reset = (next === "serving") !== (mode === "serving");
setMode(next);
if ((next === "serving") !== (mode === "serving")) setEdits({});
setNotice("");
if (reset) setEdits({});
setNotice(reset ? "Serving uses a different native example, so generation edits were reset." : "");
}
async function copy(value: string) {
async function copy(value: string, message = "Copied to clipboard.") {
try {
await navigator.clipboard.writeText(value);
setNotice("Copied to clipboard.");
setNotice(message);
} catch {
setNotice("Copy failed. Select and copy the text below.");
}
@@ -140,14 +146,22 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
)
failure = "Enter valid numeric settings before copying the example.";
}
let config: Record<string, unknown> | undefined;
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;
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.";
}
source = stringify(config);
} else if (example) {
source = pythonSource(source, example.controls, edits);
}
@@ -169,19 +183,24 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
})
.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;
// 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: "t2v",
workload,
},
parse(source),
).command
@@ -202,10 +221,14 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
["rtx", "RTX PRO 6000", "Workstation"],
["spark", "DGX Spark", "GB10"],
];
const hardwareLabel = hardware.startsWith("custom:")
? hardware.slice(7)
: devices.find(([id]) => id === hardware)?.[1];
const chooseHardware = (value: string) => {
setHardware(value);
const url = new URL(window.location.href);
url.searchParams.set("hardware", value);
if (value) url.searchParams.set("hardware", value);
else url.searchParams.delete("hardware");
window.history.replaceState(null, "", url);
};
const control = (field: Control, index: number) => {
@@ -276,7 +299,11 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
model={model}
actions={
<>
<Button variant="outline" disabled={!ready} onClick={() => copy(displayedSource)}>
<Button
variant="outline"
disabled={!ready}
onClick={() => copy(displayedSource, yamlSource ? "Copied config.yaml." : "Copied Python example.")}
>
Copy {yamlSource ? "YAML" : "Python"}
</Button>
<Button
@@ -305,10 +332,18 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
<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
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
@@ -321,12 +356,12 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
setShowClient(!showClient);
}}
>
cURL
Show request
</button>
)}
{environment && (
<button type="button" aria-pressed={showEnv} onClick={() => setShowEnv(!showEnv)}>
Env
Show environment
</button>
)}
</div>
@@ -352,7 +387,10 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
<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>
<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"
@@ -361,8 +399,8 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
onToggle={(event) => setShowClient(event.currentTarget.open)}
>
<summary>Example request</summary>
<button type="button" onClick={() => copy(clientCommand)}>
Copy client command
<button type="button" onClick={() => copy(clientCommand, "Copied example request.")}>
Copy request command
</button>
<pre tabIndex={0} aria-label="Client command">
<code>{clientCommand}</code>
@@ -415,13 +453,13 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
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({});
setNotice(`Switched to the ${platform} recipe; generation edits were reset.`);
}}
>
{platform}
@@ -432,11 +470,7 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
<HardwarePicker
backend={mlx ? "mlx" : "nvidia"}
options={devices}
value={
hardware.startsWith("custom:")
? hardware
: (devices.find(([id]) => id === hardware)?.[0] ?? devices[0][0])
}
value={hardware}
onChange={chooseHardware}
/>
<div className="matrix-resources">
@@ -445,11 +479,26 @@ export default function SourceRecipe({ model }: { model: DirectoryModel }) {
{!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>
<p className="matrix-note">
Hardware fit has not been verified. Settings edit the source example; device choice is a preference.
</p>
{hardwareLabel ? (
<p className="matrix-note">
{mlx
? `${hardwareLabel} is saved as your target. Apple Silicon uses unified memory; dedicated GPU memory does not apply.`
: `${hardwareLabel} is saved as your target. It does not change the example; use GPU count in the source settings.`}
</p>
) : null}
<RecipeFacts
validity={failure ? "invalid" : example ? "valid" : "loading"}
validityText={yamlSource ? "YAML parses" : "Example settings applied"}
validityDetail="This page checks that the YAML parses and that edited numeric fields are finite and supported by the example. It does not run the full recipe schema or runtime topology validation."
fit="unknown"
evidence="none"
subject={yamlSource ? "configuration" : "example"}
/>
</div>
</div>
{model.recipes.filter((entry) => entry.gpu_types.includes(mlx ? "Apple Silicon" : "NVIDIA")).length > 1 && (
+70 -18
View File
@@ -1,6 +1,12 @@
import { expect, test, type Page } from "@playwright/test";
const route = "/FastVideo/cookbook/minimax-h3/fasth3-8step";
// The cookbook has no behavioral dependency on third-party scripts. Keep load
// deterministic when GitHub/Trendshift are slow or unreachable.
test.beforeEach(async ({ page }) => {
await page.route(/^https?:\/\/(?!127\.0\.0\.1|localhost)/, (request) => request.abort());
});
async function settings(page: Page, section: string) {
await expect(page.locator(".recipe-matrix")).toBeVisible();
if (section === "Serving" || section === "Hardware") {
@@ -75,12 +81,12 @@ test("copy buttons use their own generated output", async ({ page, context }) =>
server,
")",
].join("\n");
await page.getByRole("button", { name: "Copy", exact: true }).click();
await page.getByRole("button", { name: "Copy config + command", exact: true }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(launch);
await page.locator(".recipe-request-code > summary").click();
const client = await page.getByLabel("Client command", { exact: true }).innerText();
expect(client).toContain(":9001/");
await page.getByRole("button", { name: "Copy client command", exact: true }).click();
await page.getByRole("button", { name: "Copy request command", exact: true }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(client);
expect(new Set([yaml, launch, client]).size).toBe(3);
});
@@ -207,7 +213,9 @@ test("hardware preferences stay distinct from schema-backed GPU count and topolo
const yaml = page.getByLabel("Generated YAML", { exact: true });
await expect(yaml).toContainText('"num_gpus": 1');
await expect(yaml).not.toContainText('"hardware"');
await expect(page.getByText("Unverified recipe", { exact: true })).toBeVisible();
await expect(page.getByText("Valid configuration", { exact: true })).toBeVisible();
await expect(page.getByText("Fit unknown", { exact: true })).toBeVisible();
await expect(page.getByText("No recorded run", { exact: true })).toBeVisible();
await page.getByLabel("GPU count", { exact: true }).fill("0");
await expect(yaml).toHaveCount(0);
await expect(page.getByLabel("GPU count", { exact: true })).toHaveAttribute("aria-invalid", "true");
@@ -261,13 +269,13 @@ test("image and audio models have logos and dedicated source recipe pages", asyn
).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 expect(page.getByRole("button", { name: "Copy example + command", 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();
await expect(page.getByRole("button", { name: "Copy example + command", exact: true })).toBeEnabled();
});
test("provider labels match FastVideo logos and CompactH3 is replaced by FastH3 Trim", async ({ page }) => {
@@ -282,7 +290,6 @@ test("provider labels match FastVideo logos and CompactH3 is replaced by FastH3
"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 }) => {
@@ -296,11 +303,12 @@ test("FastH3 Trim edits drive inference, serving, and complete copyable launch s
});
});
await page.goto("/FastVideo/cookbook/minimax-h3/fasth3-trim");
await page.getByRole("radio", { name: "DGX Spark GB10", exact: true }).check();
await expect(page.getByRole("radio", { name: "DGX Spark GB10", exact: true })).toBeChecked();
await expect(page.getByRole("radio", { name: "H100 Data center", exact: true })).toHaveCount(0);
await 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();
await page.getByRole("button", { name: "Copy config + command", 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");
@@ -332,19 +340,19 @@ test("Wan I2V requires an image for CLI and SDK examples without changing server
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: "Copy config + command", 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 expect(page.getByRole("button", { name: "Copy config + command", 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.getByRole("button", { name: "Copy config + command", exact: true })).toBeEnabled();
await expect(page.getByLabel("Generated YAML", { exact: true })).not.toContainText("image_path");
});
@@ -368,12 +376,12 @@ test("source-backed serving pages expose their actual server settings", async ({
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 page.getByRole("button", { name: "Show request", 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();
await expect(page.getByRole("button", { name: "Copy config + command", exact: true })).toBeEnabled();
});
test("source model hardware preferences survive hydration and reload", async ({ page }) => {
@@ -386,7 +394,7 @@ test("source model hardware preferences survive hydration and reload", async ({
expect(errors).toEqual([]);
});
test("custom devices persist and component previews cannot silently generate incompatible YAML", async ({ page }) => {
test("custom devices persist and no preview control can block generated YAML", async ({ page }) => {
await page.goto(route);
await page.getByRole("button", { name: "+ Find or add a GPU", exact: true }).click();
await page.getByLabel("Device name", { exact: true }).fill("RTX 4090");
@@ -394,10 +402,54 @@ test("custom devices persist and component previews cannot silently generate inc
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();
await page.locator(".component-plan > summary").click();
await chip(page, "Text encoder", "NVFP4 encoder");
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeDisabled();
await expect(page.getByRole("alert")).toContainText("compatible serving recipe");
await page.getByRole("button", { name: "Restore recipe components", exact: true }).click();
await expect(page.locator(".component-plan")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Download YAML", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
await expect(page.getByRole("radio", { name: "RTX 4090 Custom device", exact: true })).toHaveCount(0);
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
});
test("hardware is never preselected and a target does not change generated YAML", async ({ page }) => {
await page.goto(route);
await expect(page.locator(".hardware-picker")).toBeVisible({ timeout: 30000 });
await expect(page.locator('input[type="radio"]:checked')).toHaveCount(0);
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
await output(page, "config.yaml");
const yaml = page.getByLabel("Generated YAML", { exact: true });
const baseline = await yaml.innerText();
await page.getByRole("radio", { name: /RTX 5090/ }).check();
await expect(page.getByRole("radio", { name: /RTX 5090/ })).toBeChecked();
await expect(page.getByText(/RTX 5090 is saved as your target/)).toBeVisible();
await expect(yaml).toHaveText(baseline);
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
await expect(page.locator('input[type="radio"]:checked')).toHaveCount(0);
await expect(page.getByText("No hardware target selected.", { exact: true })).toBeVisible();
});
test("a saved target missing from the list stays visible and clearable", async ({ page }) => {
await page.goto(`${route}?hardware=retired-gpu`);
await expect(page.getByText("Saved hardware target is not in this list.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Clear hardware target", exact: true }).click();
await expect(page).not.toHaveURL(/hardware=/);
});
test("recipe facts separate validity, fit, and run evidence with inspectable assumptions", async ({ page }) => {
await page.goto(route);
const facts = page.getByLabel("Recipe facts");
await expect(facts.getByText("Valid configuration", { exact: true })).toBeVisible();
await expect(facts.getByText("Fit unknown", { exact: true })).toBeVisible();
await expect(facts.getByText("No recorded run", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Details", exact: true }).click();
await expect(page.getByText(/This is not a fit claim/)).toBeVisible();
await expect(page.getByText(/do not establish this configuration/)).toBeVisible();
});
test("WebSocket deployments expose only the serving interface", async ({ page }) => {
await page.goto("/FastVideo/cookbook/ltx/ltx2-distilled");
const modes = page.getByRole("group", { name: "Run mode" });
await expect(modes.getByRole("button")).toHaveCount(1);
await expect(modes.getByRole("button", { name: "Serving", exact: true })).toHaveAttribute("aria-pressed", "true");
await expect(modes.getByRole("button", { name: "Python SDK", exact: true })).toHaveCount(0);
await expect(modes.getByRole("button", { name: "CLI", exact: true })).toHaveCount(0);
await expect(page.getByText(/Only serving is recorded for it/)).toBeVisible();
});
@@ -0,0 +1,15 @@
# Cookbook UI evidence
Captured 2026-10-19 against the local production build (`blume build`) served by
`blume preview --host 127.0.0.1 --port 4327`, Playwright Chromium headless shell
1248, viewport 1440×1000. Third-party requests were aborted; no product behavior
depends on them.
| Before | After | What changed |
| ------------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `before/directory-desktop.png` | `after/directory-desktop.png` | Command labels and source/model entry points; no preselected hardware. |
| `before/fasth3-desktop.png` | `after/fasth3-desktop.png` | Hardware is explicitly unset, the component preview is gone, and fit/evidence facts sit at the hardware decision point instead of above the command. |
Narrow, dark, and MLX states were checked locally but are not committed. The
interaction tests cover those states, copied YAML/commands, reload persistence,
and the unselected/default behavior.
Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+60 -38
View File
@@ -721,19 +721,58 @@
border-block-end: 1px solid var(--recipe-line);
font-size: 0.6875rem;
}
.recipe-unverified {
display: inline-flex;
.recipe-facts {
display: flex;
margin-block-start: 0.75rem;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
gap: 0.375rem 0.75rem;
min-inline-size: 0;
}
.recipe-fact-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem 0.75rem;
color: var(--blume-muted-foreground);
}
.recipe-unverified::before {
.recipe-fact-list span {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.recipe-fact-list span::before {
content: "";
inline-size: 0.375rem;
block-size: 0.375rem;
border-radius: 50%;
background: currentColor;
}
.recipe-fact-list span[data-state="valid"],
.recipe-fact-list span[data-state="fits"] {
color: var(--blume-foreground);
}
.recipe-facts-toggle {
font-size: 0.625rem;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.recipe-facts-detail {
flex-basis: 100%;
max-inline-size: 46rem;
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
line-height: 1.65;
}
.recipe-facts-detail p + p {
margin-block-start: 0.375rem;
}
.recipe-mode-note {
margin: -0.25rem 0 1rem;
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
line-height: 1.6;
}
.recipe-tabs {
display: flex;
gap: 1rem;
@@ -1166,13 +1205,29 @@
background: var(--blume-background);
border-color: var(--recipe-control);
}
.hardware-add {
.hardware-picker-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem 0.75rem;
margin-block-start: 0.5rem;
}
.hardware-add,
.hardware-clear {
min-block-size: 2rem;
font-size: 0.6875rem;
color: var(--blume-muted-foreground);
cursor: pointer;
}
.hardware-clear {
text-decoration: underline;
text-underline-offset: 3px;
}
.hardware-current {
margin-block-start: 0.5rem;
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
}
.hardware-search {
display: grid;
gap: 0.5rem;
@@ -1199,39 +1254,6 @@
.hardware-matrix strong {
overflow-wrap: anywhere;
}
.component-plan > summary {
padding: 0;
cursor: pointer;
font-size: 0.75rem;
}
.component-plan > summary span {
margin-inline-start: 0.5rem;
color: var(--blume-muted-foreground);
font-size: 0.625rem;
}
.component-plan-fields {
display: grid;
gap: 1rem;
margin-block: 1rem;
}
.component-plan-label {
display: block;
margin-block-end: 0.5rem;
font-size: 0.6875rem;
}
.component-plan > p {
color: var(--blume-muted-foreground);
font-size: 0.6875rem;
line-height: 1.6;
}
.component-plan-reset {
margin-block-start: 0.5rem;
font-size: 0.6875rem;
min-block-size: 2rem;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.release-intro {
font-size: 0.875rem;
color: var(--blume-muted-foreground);