|
94 | 94 | return `${count} GPU${count === 1 ? "" : "s"}`; |
95 | 95 | }; |
96 | 96 |
|
| 97 | + const knobsFor = (recipe) => recipe.knobs || []; |
| 98 | + |
| 99 | + const knobOptions = (knob) => |
| 100 | + knob.options.map((option) => |
| 101 | + option !== null && typeof option === "object" ? option : { value: option, label: String(option) }, |
| 102 | + ); |
| 103 | + |
| 104 | + const knobDefaultLabel = (knob) => { |
| 105 | + const match = knobOptions(knob).find((option) => option.value === knob.default); |
| 106 | + return match ? match.label : String(knob.default); |
| 107 | + }; |
| 108 | + |
| 109 | + // Knob flags are always shown explicitly in the displayed command, even at |
| 110 | + // their default value, so the command stays copy-pasteable and precise |
| 111 | + // about what it runs -- not just "trust the script's own default". |
| 112 | + const appendKnobFlags = (commandText, knobs, knobValues) => { |
| 113 | + const flags = knobs |
| 114 | + .filter((knob) => knobValues[knob.key] !== undefined) |
| 115 | + .map((knob) => `${knob.flag} ${knobValues[knob.key]}`); |
| 116 | + if (!flags.length) return commandText; |
| 117 | + const lines = commandText.split("\n"); |
| 118 | + lines[lines.length - 1] = `${lines[lines.length - 1]} ${flags.join(" ")}`; |
| 119 | + return lines.join("\n"); |
| 120 | + }; |
| 121 | + |
97 | 122 | const runtimeFor = (recipe) => { |
98 | 123 | const platform = recipe.hardware?.platform || "cuda"; |
99 | 124 | if (platform === "mlx") { |
|
223 | 248 | const servingPanel = root.querySelector("[data-cookbook-serving]"); |
224 | 249 | const usage = root.querySelector("[data-cookbook-usage]"); |
225 | 250 | const servingAvailability = root.querySelector("[data-cookbook-serving-availability]"); |
| 251 | + const knobsContainer = root.querySelector("[data-cookbook-knobs]"); |
226 | 252 |
|
227 | 253 | modelOptions.setAttribute("aria-label", "Recipe"); |
228 | 254 | hardwareOptions.setAttribute("aria-label", "Runtime"); |
|
289 | 315 | const clientDetails = servingPanel?.querySelector(".cookbook-serving__code"); |
290 | 316 | if (clientDetails && query.has("client")) clientDetails.open = true; |
291 | 317 |
|
| 318 | + const knobDefs = new Map(); |
| 319 | + familyRecipes.forEach((recipe) => knobsFor(recipe).forEach((knob) => { |
| 320 | + if (!knobDefs.has(knob.key)) knobDefs.set(knob.key, knob); |
| 321 | + })); |
| 322 | + const knobValues = {}; |
| 323 | + knobDefs.forEach((knob, key) => { |
| 324 | + const fromQuery = query.get(key); |
| 325 | + const validValues = knobOptions(knob).map((option) => String(option.value)); |
| 326 | + const useQueryValue = fromQuery !== null && validValues.includes(fromQuery); |
| 327 | + const raw = useQueryValue ? fromQuery : knob.default; |
| 328 | + knobValues[key] = typeof knob.default === "number" ? Number(raw) : raw; |
| 329 | + }); |
| 330 | + |
| 331 | + const renderKnobs = (recipe, hidden) => { |
| 332 | + if (!knobsContainer) return; |
| 333 | + const knobs = knobsFor(recipe); |
| 334 | + const renderedKeys = [...knobsContainer.querySelectorAll("[data-knob-row]")].map((row) => row.dataset.knobRow); |
| 335 | + if (renderedKeys.join(",") !== knobs.map((knob) => knob.key).join(",")) { |
| 336 | + knobsContainer.replaceChildren(); |
| 337 | + knobs.forEach((knob) => { |
| 338 | + const row = document.createElement("div"); |
| 339 | + row.className = "cookbook-selection-row"; |
| 340 | + row.dataset.knobRow = knob.key; |
| 341 | + const labelWrap = document.createElement("div"); |
| 342 | + labelWrap.className = "cookbook-selection-row__label"; |
| 343 | + const strongLabel = document.createElement("strong"); |
| 344 | + strongLabel.textContent = knob.label; |
| 345 | + const hintLabel = document.createElement("span"); |
| 346 | + hintLabel.textContent = knob.hint || ""; |
| 347 | + labelWrap.append(strongLabel, hintLabel); |
| 348 | + const grid = document.createElement("div"); |
| 349 | + grid.className = "cookbook-option-grid cookbook-option-grid--hardware"; |
| 350 | + grid.setAttribute("role", "group"); |
| 351 | + grid.setAttribute("aria-label", knob.label); |
| 352 | + knobOptions(knob).forEach((option) => { |
| 353 | + const optionButton = document.createElement("button"); |
| 354 | + optionButton.type = "button"; |
| 355 | + optionButton.dataset.knobKey = knob.key; |
| 356 | + optionButton.dataset.knobValue = String(option.value); |
| 357 | + optionButton.setAttribute("aria-pressed", "false"); |
| 358 | + const optionLabel = document.createElement("strong"); |
| 359 | + optionLabel.textContent = option.label; |
| 360 | + optionButton.append(optionLabel); |
| 361 | + grid.append(optionButton); |
| 362 | + }); |
| 363 | + row.append(labelWrap, grid); |
| 364 | + knobsContainer.append(row); |
| 365 | + }); |
| 366 | + } |
| 367 | + knobsContainer.hidden = hidden || knobs.length === 0; |
| 368 | + knobsContainer.querySelectorAll("button[data-knob-key]").forEach((optionButton) => { |
| 369 | + const selected = String(knobValues[optionButton.dataset.knobKey]) === optionButton.dataset.knobValue; |
| 370 | + optionButton.classList.toggle("cookbook-option--selected", selected); |
| 371 | + optionButton.setAttribute("aria-pressed", String(selected)); |
| 372 | + }); |
| 373 | + }; |
| 374 | + |
292 | 375 | const renderRuntimeOptions = () => { |
293 | 376 | const groupRecipes = groups.get(selectedGroupId) || []; |
294 | 377 | const renderedIds = [...hardwareOptions.querySelectorAll("[data-recipe-id]")].map((option) => option.dataset.recipeId); |
|
340 | 423 | const useServer = Boolean(profile && usagePreference === "server"); |
341 | 424 | // The measured local profile and the server config have separate evidence. |
342 | 425 | const activeRecipe = useServer ? { ...recipe, hardware: profile.hardware, evidence: "Source-backed" } : recipe; |
| 426 | + const knobs = knobsFor(recipe); |
| 427 | + renderKnobs(recipe, useServer); |
343 | 428 |
|
344 | 429 | if (usage) { |
345 | 430 | usage.querySelectorAll("[data-cookbook-mode]").forEach((option) => { |
|
418 | 503 | source.href = `https://github.com/hao-ai-lab/FastVideo/blob/main/${useServer ? profile.source : recipe.source}`; |
419 | 504 | source.textContent = useServer ? "View server configuration" : "Open example source"; |
420 | 505 | modelLink.href = `https://huggingface.co/${recipe.model}`; |
421 | | - command.textContent = recipe.command; |
| 506 | + command.textContent = useServer ? recipe.command : appendKnobFlags(recipe.command, knobs, knobValues); |
422 | 507 |
|
423 | 508 | renderHardwareEvidence(hardwareState, hardwareBadge, activeRecipe); |
424 | 509 |
|
425 | | - const limitations = useServer ? [ |
| 510 | + const knobCaveats = useServer ? [] : knobs |
| 511 | + .filter((knob) => String(knobValues[knob.key]) !== String(knob.default)) |
| 512 | + .map((knob) => `${knob.label} is set away from its recorded default (${knobDefaultLabel(knob)}). ` + |
| 513 | + "The script accepts this value, but it has not been benchmarked here."); |
| 514 | + const limitations = [...(useServer ? [ |
426 | 515 | profile.runtime === "mlx" |
427 | 516 | ? "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 | 517 | : "This server config has no recorded serving benchmark. Compilation is disabled, unlike the measured Python performance profile.", |
429 | 518 | `${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 | 519 | "Generation is serialized. Job metadata is held in memory and is lost when the server restarts.", |
431 | | - ] : recipe.limitations || []; |
| 520 | + ] : recipe.limitations || []), ...knobCaveats]; |
432 | 521 | notes.replaceChildren(); |
433 | 522 | notes.hidden = limitations.length === 0; |
434 | 523 | if (limitations.length) { |
|
447 | 536 | nextQuery.set("recipe", recipe.id); |
448 | 537 | nextQuery.set("runtime", runtime.id); |
449 | 538 | nextQuery.delete("gpus"); |
| 539 | + knobDefs.forEach((knob, key) => { |
| 540 | + if (knobs.some((activeKnob) => activeKnob.key === key)) nextQuery.set(key, String(knobValues[key])); |
| 541 | + else nextQuery.delete(key); |
| 542 | + }); |
450 | 543 | if (usage) { |
451 | 544 | nextQuery.set("use", useServer ? "server" : "python"); |
452 | 545 | if (useServer && clientDetails?.open) nextQuery.set("client", selectedClient); |
|
484 | 577 | selectedClient = option.dataset.cookbookClient; |
485 | 578 | render({ historyMode: "push" }); |
486 | 579 | }); |
| 580 | + knobsContainer?.addEventListener("click", (event) => { |
| 581 | + const option = event.target.closest("button[data-knob-key]"); |
| 582 | + if (!option) return; |
| 583 | + const knob = knobDefs.get(option.dataset.knobKey); |
| 584 | + knobValues[option.dataset.knobKey] = typeof knob.default === "number" |
| 585 | + ? Number(option.dataset.knobValue) : option.dataset.knobValue; |
| 586 | + render({ historyMode: "push" }); |
| 587 | + }); |
487 | 588 |
|
488 | 589 | render(); |
489 | 590 |
|
|
496 | 597 | usagePreference = workflow(nextQuery.get("use")); |
497 | 598 | selectedClient = ["python", "javascript", "curl"].includes(nextQuery.get("client")) ? nextQuery.get("client") : "curl"; |
498 | 599 | if (clientDetails) clientDetails.open = nextQuery.has("client"); |
| 600 | + knobDefs.forEach((knob, key) => { |
| 601 | + const fromQuery = nextQuery.get(key); |
| 602 | + const validValues = knobOptions(knob).map((option) => String(option.value)); |
| 603 | + if (fromQuery !== null && validValues.includes(fromQuery)) { |
| 604 | + knobValues[key] = typeof knob.default === "number" ? Number(fromQuery) : fromQuery; |
| 605 | + } |
| 606 | + }); |
499 | 607 | render({ historyMode: "none" }); |
500 | 608 | }; |
501 | 609 | bindFamilyPopstate(); |
|
0 commit comments