|
220 | 220 | const count = root.querySelector("[data-cookbook-count]"); |
221 | 221 | const result = root.querySelector(".cookbook-result"); |
222 | 222 | const commandBlock = root.querySelector(".cookbook-command"); |
| 223 | + const servingPanel = root.querySelector("[data-cookbook-serving]"); |
| 224 | + const usage = root.querySelector("[data-cookbook-usage]"); |
| 225 | + const servingAvailability = root.querySelector("[data-cookbook-serving-availability]"); |
223 | 226 |
|
224 | 227 | modelOptions.setAttribute("aria-label", "Recipe"); |
225 | 228 | hardwareOptions.setAttribute("aria-label", "Runtime"); |
|
237 | 240 | const familyRecipes = recipes.filter((recipe) => recipe.family === family); |
238 | 241 | if (!familyRecipes.length) return; |
239 | 242 |
|
| 243 | + let servingProfiles = {}; |
| 244 | + let servingLoadFailed = false; |
| 245 | + if (servingPanel && familyRecipes.some((recipe) => recipe.serving)) { |
| 246 | + try { |
| 247 | + const dataUrl = new URL(root.dataset.recipes, document.baseURI); |
| 248 | + servingProfiles = await loadRecipes(new URL("cookbook-serving.json", dataUrl)); |
| 249 | + } catch (error) { |
| 250 | + servingLoadFailed = true; |
| 251 | + console.error("Failed to load FastVideo serving profiles", error); |
| 252 | + } |
| 253 | + } |
| 254 | + |
240 | 255 | const byId = new Map(familyRecipes.map((recipe) => [recipe.id, recipe])); |
241 | 256 | const groups = new Map(); |
242 | 257 | familyRecipes.forEach((recipe) => { |
|
263 | 278 |
|
264 | 279 | const query = new URLSearchParams(window.location.search); |
265 | 280 | const requestedRecipe = query.get("recipe"); |
266 | | - const defaultRecipeId = familyRecipes[0].id; |
| 281 | + const defaultRecipeId = byId.has(root.dataset.defaultRecipe) ? root.dataset.defaultRecipe : familyRecipes[0].id; |
267 | 282 | let selectedRecipeId = requestedRecipe && byId.has(requestedRecipe) ? requestedRecipe : defaultRecipeId; |
268 | 283 | let selectedGroupId = groupIdFor(byId.get(selectedRecipeId)); |
269 | 284 | let renderedRuntimeGroup = null; |
| 285 | + // Keep previously shared local/openai links working after renaming workflows. |
| 286 | + const workflow = (value) => ["local", "python"].includes(value) ? "python" : "server"; |
| 287 | + let usagePreference = workflow(query.get("use")); |
| 288 | + let selectedClient = ["python", "javascript", "curl"].includes(query.get("client")) ? query.get("client") : "curl"; |
| 289 | + const clientDetails = servingPanel?.querySelector(".cookbook-serving__code"); |
| 290 | + if (clientDetails && query.has("client")) clientDetails.open = true; |
270 | 291 |
|
271 | 292 | const renderRuntimeOptions = () => { |
272 | 293 | const groupRecipes = groups.get(selectedGroupId) || []; |
| 294 | + const renderedIds = [...hardwareOptions.querySelectorAll("[data-recipe-id]")].map((option) => option.dataset.recipeId); |
| 295 | + if (renderedIds.join(",") === groupRecipes.map((recipe) => recipe.id).join(",")) return; |
273 | 296 | hardwareOptions.replaceChildren(); |
274 | 297 | groupRecipes.forEach((recipe) => { |
275 | 298 | const runtime = runtimeFor(recipe); |
|
313 | 336 | renderedRuntimeGroup = selectedGroupId; |
314 | 337 | } |
315 | 338 | const runtime = runtimeFor(recipe); |
| 339 | + const profile = servingPanel && servingProfiles[recipe.id]; |
| 340 | + const useServer = Boolean(profile && usagePreference === "server"); |
| 341 | + // The measured local profile and the server config have separate evidence. |
| 342 | + const activeRecipe = useServer ? { ...recipe, hardware: profile.hardware, evidence: "Source-backed" } : recipe; |
| 343 | + |
| 344 | + if (usage) { |
| 345 | + usage.querySelectorAll("[data-cookbook-mode]").forEach((option) => { |
| 346 | + const selected = option.dataset.cookbookMode === (useServer ? "server" : "python"); |
| 347 | + option.disabled = option.dataset.cookbookMode === "server" && !profile; |
| 348 | + option.classList.toggle("cookbook-option--selected", selected); |
| 349 | + option.setAttribute("aria-pressed", String(selected)); |
| 350 | + }); |
| 351 | + servingAvailability.textContent = profile |
| 352 | + ? "The playground and API clients share one server process. Both workflows can run on your own machine." |
| 353 | + : servingLoadFailed |
| 354 | + ? "Server examples could not be loaded. Open the H3 server guide below, or use Python directly." |
| 355 | + : "This recipe uses Python directly. For the playground and API clients, choose FastH3 Preview with CUDA or MLX."; |
| 356 | + servingPanel.hidden = !useServer; |
| 357 | + commandBlock.hidden = useServer; |
| 358 | + root.querySelector("[data-cookbook-python-note]").hidden = useServer; |
| 359 | + } |
| 360 | + |
| 361 | + if (useServer) { |
| 362 | + const isMLX = profile.runtime === "mlx"; |
| 363 | + servingPanel.querySelector("[data-cookbook-server-lifetime]").textContent = isMLX |
| 364 | + ? "Start once, then change prompts in the playground or your app. MLX reuses its pipeline and prompt cache, but loads and releases model components between phases to limit unified-memory use. It does not keep all weights resident." |
| 365 | + : "Start once, then change prompts in the playground or your app. CUDA requests reuse the loaded model. The Python SDK can also reuse a generator within one process."; |
| 366 | + servingPanel.querySelector("[data-cookbook-install-guide]").href = isMLX |
| 367 | + ? "../../getting_started/installation/mps/#run-fasth3-preview" : "../../getting_started/installation/gpu/"; |
| 368 | + servingPanel.querySelector("[data-cookbook-prepare]").hidden = !profile.prepare; |
| 369 | + servingPanel.querySelector("[data-cookbook-server-prepare]").textContent = profile.prepare; |
| 370 | + servingPanel.querySelector("[data-cookbook-server-install]").textContent = profile.install; |
| 371 | + servingPanel.querySelector("[data-cookbook-server-command]").textContent = profile.command; |
| 372 | + servingPanel.querySelector("[data-cookbook-health-command]").textContent = profile.health_command; |
| 373 | + servingPanel.querySelector("[data-cookbook-playground]").href = profile.playground_url; |
| 374 | + const client = profile.clients[selectedClient]; |
| 375 | + const filename = client.source.split("/").pop(); |
| 376 | + servingPanel.querySelector("[data-cookbook-client-install]").textContent = client.install; |
| 377 | + const clientCode = servingPanel.querySelector("[data-cookbook-client-code]"); |
| 378 | + clientCode.className = `language-${selectedClient === "curl" ? "bash" : selectedClient}`; |
| 379 | + clientCode.textContent = client.code; |
| 380 | + servingPanel.querySelector("[data-cookbook-client-filename]").textContent = filename; |
| 381 | + servingPanel.querySelector("[data-cookbook-client-source]").href = `https://github.com/hao-ai-lab/FastVideo/blob/main/${client.source}`; |
| 382 | + const runner = { python: "python", javascript: "node", curl: "bash" }[selectedClient]; |
| 383 | + servingPanel.querySelector("[data-cookbook-client-run]").textContent = `Save as ${filename} and run ${runner} ${filename}. The MP4 is saved with the job ID as its filename.`; |
| 384 | + servingPanel.querySelectorAll("[data-cookbook-client]").forEach((option) => { |
| 385 | + option.setAttribute("aria-pressed", String(option.dataset.cookbookClient === selectedClient)); |
| 386 | + }); |
| 387 | + } |
316 | 388 |
|
317 | 389 | modelOptions.querySelectorAll("button").forEach((option) => { |
318 | 390 | const selected = option.dataset.recipeGroup === selectedGroupId; |
|
323 | 395 | const selected = option.dataset.recipeId === recipe.id; |
324 | 396 | option.classList.toggle("cookbook-option--selected", selected); |
325 | 397 | option.setAttribute("aria-pressed", String(selected)); |
| 398 | + const candidate = byId.get(option.dataset.recipeId); |
| 399 | + const candidateProfile = servingProfiles[candidate.id]; |
| 400 | + const candidateRuntime = runtimeFor(candidateProfile && usagePreference === "server" |
| 401 | + ? { ...candidate, hardware: candidateProfile.hardware } : candidate); |
| 402 | + option.querySelector("span").textContent = candidateRuntime.hint; |
326 | 403 | }); |
327 | 404 |
|
328 | | - description.textContent = recipe.summary; |
329 | | - label.textContent = recipe.label; |
| 405 | + description.textContent = useServer |
| 406 | + ? `FastH3 Preview generates video with audio. This server profile uses the checked-in ${profile.runtime.toUpperCase()} configuration.` |
| 407 | + : recipe.summary; |
| 408 | + label.textContent = useServer ? `${recipe.group_label || recipe.label} · Server` : recipe.label; |
330 | 409 | model.textContent = recipe.model; |
331 | 410 | task.textContent = recipe.task; |
332 | | - hardwareValue.textContent = runtimeSummary(recipe); |
333 | | - if (artifact) artifact.textContent = recipe.expected_artifact || "Not yet documented for this recipe."; |
| 411 | + hardwareValue.textContent = runtimeSummary(activeRecipe); |
| 412 | + if (artifact) artifact.textContent = useServer ? "MP4 with audio" : recipe.expected_artifact || "Not yet documented for this recipe."; |
334 | 413 | if (evidenceCell) { |
335 | | - evidenceCell.textContent = recipe.evidence || "Source-backed"; |
336 | | - evidenceCell.classList.toggle("cookbook-badge--verified", recipe.evidence === "Verified"); |
337 | | - evidenceCell.classList.toggle("cookbook-badge--source-backed", recipe.evidence !== "Verified"); |
| 414 | + evidenceCell.textContent = activeRecipe.evidence || "Source-backed"; |
| 415 | + evidenceCell.classList.toggle("cookbook-badge--verified", activeRecipe.evidence === "Verified"); |
| 416 | + evidenceCell.classList.toggle("cookbook-badge--source-backed", activeRecipe.evidence !== "Verified"); |
338 | 417 | } |
339 | | - source.href = `https://github.com/hao-ai-lab/FastVideo/blob/main/${recipe.source}`; |
| 418 | + source.href = `https://github.com/hao-ai-lab/FastVideo/blob/main/${useServer ? profile.source : recipe.source}`; |
| 419 | + source.textContent = useServer ? "View server configuration" : "Open example source"; |
340 | 420 | modelLink.href = `https://huggingface.co/${recipe.model}`; |
341 | 421 | command.textContent = recipe.command; |
342 | 422 |
|
343 | | - renderHardwareEvidence(hardwareState, hardwareBadge, recipe); |
| 423 | + renderHardwareEvidence(hardwareState, hardwareBadge, activeRecipe); |
344 | 424 |
|
345 | | - const limitations = recipe.limitations || []; |
| 425 | + const limitations = useServer ? [ |
| 426 | + profile.runtime === "mlx" |
| 427 | + ? "This MLX server config has no recorded hardware run. Measurements from the Python recipe are not server memory requirements. Only text-to-video/audio is wired; reference inputs and fast modes are not exposed here." |
| 428 | + : "This server config has no recorded serving benchmark. Compilation is disabled, unlike the measured Python performance profile.", |
| 429 | + `${profile.sampling.width} × ${profile.sampling.height} · ${profile.sampling.num_frames} frames · ${profile.sampling.fps} fps. The server supplies these defaults; the client sends the model and prompt.`, |
| 430 | + "Generation is serialized. Job metadata is held in memory and is lost when the server restarts.", |
| 431 | + ] : recipe.limitations || []; |
346 | 432 | notes.replaceChildren(); |
347 | 433 | notes.hidden = limitations.length === 0; |
348 | 434 | if (limitations.length) { |
|
361 | 447 | nextQuery.set("recipe", recipe.id); |
362 | 448 | nextQuery.set("runtime", runtime.id); |
363 | 449 | nextQuery.delete("gpus"); |
| 450 | + if (usage) { |
| 451 | + nextQuery.set("use", useServer ? "server" : "python"); |
| 452 | + if (useServer && clientDetails?.open) nextQuery.set("client", selectedClient); |
| 453 | + else nextQuery.delete("client"); |
| 454 | + } |
364 | 455 | const nextUrl = `${window.location.pathname}?${nextQuery.toString()}${window.location.hash}`; |
365 | 456 | if (historyMode === "push") window.history.pushState({}, "", nextUrl); |
366 | 457 | else if (historyMode === "replace") window.history.replaceState({}, "", nextUrl); |
367 | | - status.textContent = `${recipe.label} selected for ${runtime.label}.`; |
| 458 | + const modeSummary = useServer ? " with a persistent server" : ""; |
| 459 | + status.textContent = `${recipe.label} selected for ${runtime.label}${modeSummary}.`; |
368 | 460 | }; |
369 | 461 |
|
370 | 462 | modelOptions.addEventListener("click", (event) => { |
|
380 | 472 | selectedGroupId = groupIdFor(byId.get(selectedRecipeId)); |
381 | 473 | render({ historyMode: "push" }); |
382 | 474 | }); |
| 475 | + usage?.addEventListener("click", (event) => { |
| 476 | + const option = event.target.closest("button[data-cookbook-mode]"); |
| 477 | + if (!option || option.disabled) return; |
| 478 | + usagePreference = option.dataset.cookbookMode; |
| 479 | + render({ historyMode: "push" }); |
| 480 | + }); |
| 481 | + servingPanel?.addEventListener("click", (event) => { |
| 482 | + const option = event.target.closest("button[data-cookbook-client]"); |
| 483 | + if (!option) return; |
| 484 | + selectedClient = option.dataset.cookbookClient; |
| 485 | + render({ historyMode: "push" }); |
| 486 | + }); |
383 | 487 |
|
384 | 488 | render(); |
385 | 489 |
|
|
389 | 493 | const nextRecipe = nextQuery.get("recipe"); |
390 | 494 | selectedRecipeId = nextRecipe && byId.has(nextRecipe) ? nextRecipe : defaultRecipeId; |
391 | 495 | selectedGroupId = groupIdFor(byId.get(selectedRecipeId)); |
| 496 | + usagePreference = workflow(nextQuery.get("use")); |
| 497 | + selectedClient = ["python", "javascript", "curl"].includes(nextQuery.get("client")) ? nextQuery.get("client") : "curl"; |
| 498 | + if (clientDetails) clientDetails.open = nextQuery.has("client"); |
392 | 499 | render({ historyMode: "none" }); |
393 | 500 | }; |
394 | 501 | bindFamilyPopstate(); |
|
0 commit comments