361 lines
16 KiB
JavaScript
361 lines
16 KiB
JavaScript
(() => {
|
|
const patternCharacters = "0123456789ABCDEF";
|
|
|
|
const loadRecipes = (url) =>
|
|
fetch(url).then((response) => {
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
return response.json();
|
|
});
|
|
|
|
const initPatterns = (root) => {
|
|
root.querySelectorAll("[data-cookbook-pattern]").forEach((pattern, patternIndex) => {
|
|
if (pattern.dataset.patternReady) return;
|
|
pattern.dataset.patternReady = "true";
|
|
let value = "";
|
|
for (let index = 0; index < 1800; index += 1) {
|
|
const characterIndex = (index * 7 + patternIndex * 11) % patternCharacters.length;
|
|
value += patternCharacters.charAt(characterIndex);
|
|
if (index % 4 === 3 && index % 32 !== 31) value += " ";
|
|
if (index % 32 === 31) value += "\n";
|
|
}
|
|
pattern.textContent = value;
|
|
});
|
|
};
|
|
|
|
const groupIdFor = (recipe) => recipe.group || recipe.id;
|
|
|
|
const gpuCountLabel = (hardware) => {
|
|
const count = hardware?.gpu_count;
|
|
return `${count} GPU${count === 1 ? "" : "s"}`;
|
|
};
|
|
|
|
const runtimeFor = (recipe) => {
|
|
const platform = recipe.hardware?.platform || "cuda";
|
|
if (platform === "mlx") {
|
|
return {
|
|
id: "mlx",
|
|
label: "Apple Silicon · MLX",
|
|
hint:
|
|
recipe.hardware?.minimum_memory ||
|
|
[recipe.hardware?.accelerator, recipe.hardware?.system_memory].filter(Boolean).join(" · ") ||
|
|
"Memory not recorded",
|
|
};
|
|
}
|
|
if (platform === "mps") {
|
|
return {
|
|
id: "mps",
|
|
label: "Apple Silicon · MPS",
|
|
hint: recipe.hardware?.minimum_memory || recipe.hardware?.system_memory || "Memory not recorded",
|
|
};
|
|
}
|
|
const hardware = recipe.hardware || {};
|
|
return {
|
|
id: "cuda",
|
|
label: "NVIDIA CUDA",
|
|
hint: hardware.accelerator
|
|
? `${hardware.accelerator} · ${gpuCountLabel(hardware)}`
|
|
: `${gpuCountLabel(hardware)} configured · GPU model not recorded`,
|
|
};
|
|
};
|
|
|
|
const runtimeSummary = (recipe) => {
|
|
const runtime = runtimeFor(recipe);
|
|
const hardware = recipe.hardware || {};
|
|
if (runtime.id === "mlx") {
|
|
return [hardware.accelerator || "Apple Silicon", hardware.system_memory || hardware.minimum_memory, "MLX"]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
}
|
|
if (runtime.id === "mps") {
|
|
return [hardware.accelerator || "Apple Silicon", hardware.system_memory || hardware.minimum_memory, "PyTorch MPS"]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
}
|
|
if (hardware.accelerator) return [hardware.accelerator, gpuCountLabel(hardware)].join(" · ");
|
|
return `NVIDIA CUDA · ${gpuCountLabel(hardware)} configured · GPU model and VRAM not recorded`;
|
|
};
|
|
|
|
const renderHardwareEvidence = (container, badge, recipe) => {
|
|
const hardware = recipe.hardware || {};
|
|
const runtime = runtimeFor(recipe);
|
|
const isValidated = hardware.evidence === "validated";
|
|
container.classList.toggle("cookbook-hardware-state--verified", isValidated);
|
|
container.classList.toggle("cookbook-hardware-state--source", !isValidated);
|
|
badge.classList.toggle("cookbook-badge--verified", isValidated);
|
|
badge.classList.toggle("cookbook-badge--configured", !isValidated);
|
|
badge.textContent = isValidated ? "Recorded run" : "Source config";
|
|
|
|
const heading = document.createElement("strong");
|
|
heading.textContent = isValidated ? "Recorded hardware" : "Source configuration";
|
|
const details = document.createElement("span");
|
|
|
|
if (isValidated) {
|
|
const recorded = [
|
|
hardware.accelerator,
|
|
runtime.id === "cuda" ? gpuCountLabel(hardware) : hardware.system_memory,
|
|
].filter(Boolean);
|
|
const statements = [`${recorded.join(" · ")}.`];
|
|
if (hardware.minimum_memory) statements.push(`Documented minimum: ${hardware.minimum_memory}.`);
|
|
if (hardware.peak_memory) statements.push(`Measured: ${hardware.peak_memory}.`);
|
|
if (!hardware.minimum_memory) statements.push("This recorded device is not a minimum requirement.");
|
|
details.textContent = ` ${statements.join(" ")}`;
|
|
} else if (runtime.id === "cuda") {
|
|
details.textContent = ` NVIDIA CUDA · ${gpuCountLabel(hardware)}. The source does not record the GPU model or VRAM.`;
|
|
} else {
|
|
details.textContent = ` ${runtime.label}. The source does not record a device or memory requirement.`;
|
|
}
|
|
|
|
container.replaceChildren(heading, details);
|
|
if (hardware.evidence_url) {
|
|
const evidenceLink = document.createElement("a");
|
|
evidenceLink.href = hardware.evidence_url;
|
|
evidenceLink.textContent = "View run evidence";
|
|
evidenceLink.setAttribute("aria-label", `View recorded hardware evidence for ${recipe.label}`);
|
|
container.append(" ", evidenceLink);
|
|
}
|
|
};
|
|
|
|
const compactLifecycle = (root) => {
|
|
const lifecycle = root.querySelector(".cookbook-lifecycle");
|
|
if (!lifecycle || lifecycle.dataset.compact) return;
|
|
lifecycle.dataset.compact = "true";
|
|
const stages = [...lifecycle.querySelectorAll(".cookbook-lifecycle__stage")];
|
|
const active = stages.find((stage) => stage.classList.contains("cookbook-lifecycle__stage--active"));
|
|
const planned = stages
|
|
.filter((stage) => stage !== active)
|
|
.map((stage) => stage.childNodes[0]?.textContent?.trim())
|
|
.filter(Boolean);
|
|
const summary = document.createElement("span");
|
|
summary.className = "cookbook-lifecycle__summary";
|
|
summary.textContent = `Next: ${planned.join(", ")}`;
|
|
lifecycle.replaceChildren(...(active ? [active] : []), summary);
|
|
};
|
|
|
|
const initFamilyBuilder = async (root) => {
|
|
const family = root.dataset.family;
|
|
if (!family) return;
|
|
|
|
compactLifecycle(root);
|
|
|
|
const builderHeading = root.querySelector(".cookbook-builder__intro h2");
|
|
const builderIntro = root.querySelector(".cookbook-builder__intro > p");
|
|
if (builderHeading) builderHeading.textContent = "Pick a recipe and runtime";
|
|
if (builderIntro) {
|
|
builderIntro.textContent =
|
|
"Start with the result you want, then choose one of the runtimes FastVideo actually maintains for it.";
|
|
}
|
|
|
|
const selectionLabels = root.querySelectorAll(".cookbook-selection-row__label");
|
|
if (selectionLabels[0]) {
|
|
selectionLabels[0].querySelector("strong").textContent = "Recipe";
|
|
selectionLabels[0].querySelector("span").textContent = "Task and checkpoint";
|
|
}
|
|
if (selectionLabels[1]) {
|
|
selectionLabels[1].querySelector("strong").textContent = "Runtime";
|
|
selectionLabels[1].querySelector("span").textContent = "Maintained paths only";
|
|
}
|
|
|
|
const modelOptions = root.querySelector("[data-cookbook-model-options]");
|
|
const hardwareOptions = root.querySelector("[data-cookbook-hardware-options]");
|
|
const description = root.querySelector("[data-cookbook-description]");
|
|
const hardwareNote = root.querySelector(".cookbook-hardware-note");
|
|
const label = root.querySelector("[data-cookbook-label]");
|
|
const model = root.querySelector("[data-cookbook-model]");
|
|
const task = root.querySelector("[data-cookbook-task]");
|
|
const hardwareValue = root.querySelector("[data-cookbook-gpus]");
|
|
const artifact = root.querySelector("[data-cookbook-artifact]");
|
|
const evidenceCell = root.querySelector("[data-cookbook-evidence]");
|
|
const source = root.querySelector("[data-cookbook-source]");
|
|
const modelLink = root.querySelector("[data-cookbook-model-link]");
|
|
const command = root.querySelector("[data-cookbook-command]");
|
|
const status = root.querySelector("[data-cookbook-status]");
|
|
const hardwareState = root.querySelector("[data-cookbook-hardware-state]");
|
|
const hardwareBadge = root.querySelector("[data-cookbook-hardware-badge]");
|
|
const count = root.querySelector("[data-cookbook-count]");
|
|
const result = root.querySelector(".cookbook-result");
|
|
const commandBlock = root.querySelector(".cookbook-command");
|
|
|
|
modelOptions.setAttribute("aria-label", "Recipe");
|
|
hardwareOptions.setAttribute("aria-label", "Runtime");
|
|
|
|
if (hardwareNote) {
|
|
hardwareNote.textContent = "Exact device and memory details appear only when a recorded run supports them.";
|
|
}
|
|
const runtimeFactLabel = hardwareValue?.closest("div")?.querySelector("dt");
|
|
if (runtimeFactLabel) runtimeFactLabel.textContent = "Hardware";
|
|
|
|
let recipes;
|
|
try {
|
|
({ recipes } = await loadRecipes(root.dataset.recipes));
|
|
} catch (error) {
|
|
if (status) status.textContent = "Recipes could not be loaded. Use the maintained examples link below.";
|
|
console.error("Failed to load FastVideo cookbook recipes", error);
|
|
return;
|
|
}
|
|
|
|
const familyRecipes = recipes.filter((recipe) => recipe.family === family);
|
|
if (!familyRecipes.length) return;
|
|
|
|
const byId = new Map(familyRecipes.map((recipe) => [recipe.id, recipe]));
|
|
const groups = new Map();
|
|
familyRecipes.forEach((recipe) => {
|
|
const groupId = groupIdFor(recipe);
|
|
if (!groups.has(groupId)) groups.set(groupId, []);
|
|
groups.get(groupId).push(recipe);
|
|
});
|
|
if (count) count.textContent = `${familyRecipes.length} maintained recipes`;
|
|
|
|
modelOptions.replaceChildren();
|
|
groups.forEach((groupRecipes, groupId) => {
|
|
const representative = groupRecipes[0];
|
|
const option = document.createElement("button");
|
|
option.type = "button";
|
|
option.dataset.recipeGroup = groupId;
|
|
option.setAttribute("aria-pressed", "false");
|
|
const optionLabel = document.createElement("strong");
|
|
optionLabel.textContent = representative.group_label || representative.label;
|
|
const optionTask = document.createElement("span");
|
|
optionTask.textContent = representative.group_task || representative.task;
|
|
option.append(optionLabel, optionTask);
|
|
modelOptions.append(option);
|
|
});
|
|
|
|
const query = new URLSearchParams(window.location.search);
|
|
const requestedRecipe = query.get("recipe");
|
|
const defaultRecipeId = familyRecipes[0].id;
|
|
let selectedRecipeId = requestedRecipe && byId.has(requestedRecipe) ? requestedRecipe : defaultRecipeId;
|
|
let selectedGroupId = groupIdFor(byId.get(selectedRecipeId));
|
|
|
|
const renderRuntimeOptions = () => {
|
|
const groupRecipes = groups.get(selectedGroupId) || [];
|
|
hardwareOptions.replaceChildren();
|
|
groupRecipes.forEach((recipe) => {
|
|
const runtime = runtimeFor(recipe);
|
|
const option = document.createElement("button");
|
|
option.type = "button";
|
|
option.dataset.recipeId = recipe.id;
|
|
option.dataset.runtimeId = runtime.id;
|
|
option.setAttribute("aria-pressed", "false");
|
|
const optionLabel = document.createElement("strong");
|
|
optionLabel.textContent = runtime.label;
|
|
const optionHint = document.createElement("span");
|
|
optionHint.textContent = runtime.hint;
|
|
option.append(optionLabel, optionHint);
|
|
hardwareOptions.append(option);
|
|
});
|
|
};
|
|
|
|
let notes = root.querySelector("[data-cookbook-notes]");
|
|
if (!notes) {
|
|
notes = document.createElement("aside");
|
|
notes.className = "cookbook-recipe-notes";
|
|
notes.dataset.cookbookNotes = "";
|
|
notes.hidden = true;
|
|
result.insertBefore(notes, commandBlock);
|
|
}
|
|
|
|
const render = ({ groupChanged = false, historyMode = "replace" } = {}) => {
|
|
if (!byId.has(selectedRecipeId)) selectedRecipeId = defaultRecipeId;
|
|
let recipe = byId.get(selectedRecipeId);
|
|
if (groupChanged || groupIdFor(recipe) !== selectedGroupId) {
|
|
const currentRuntime = runtimeFor(recipe).id;
|
|
const groupRecipes = groups.get(selectedGroupId) || [];
|
|
recipe = groupRecipes.find((candidate) => runtimeFor(candidate).id === currentRuntime) || groupRecipes[0];
|
|
selectedRecipeId = recipe.id;
|
|
}
|
|
|
|
selectedGroupId = groupIdFor(recipe);
|
|
renderRuntimeOptions();
|
|
const runtime = runtimeFor(recipe);
|
|
|
|
modelOptions.querySelectorAll("button").forEach((option) => {
|
|
const selected = option.dataset.recipeGroup === selectedGroupId;
|
|
option.classList.toggle("cookbook-option--selected", selected);
|
|
option.setAttribute("aria-pressed", String(selected));
|
|
});
|
|
hardwareOptions.querySelectorAll("button").forEach((option) => {
|
|
const selected = option.dataset.recipeId === recipe.id;
|
|
option.classList.toggle("cookbook-option--selected", selected);
|
|
option.setAttribute("aria-pressed", String(selected));
|
|
});
|
|
|
|
description.textContent = recipe.summary;
|
|
label.textContent = recipe.label;
|
|
model.textContent = recipe.model;
|
|
task.textContent = recipe.task;
|
|
hardwareValue.textContent = runtimeSummary(recipe);
|
|
if (artifact) artifact.textContent = recipe.expected_artifact || "Not yet documented for this recipe.";
|
|
if (evidenceCell) {
|
|
evidenceCell.textContent = recipe.evidence || "Source-backed";
|
|
evidenceCell.classList.toggle("cookbook-badge--verified", recipe.evidence === "Verified");
|
|
evidenceCell.classList.toggle("cookbook-badge--source-backed", recipe.evidence !== "Verified");
|
|
}
|
|
source.href = `https://github.com/hao-ai-lab/FastVideo/blob/main/${recipe.source}`;
|
|
modelLink.href = `https://huggingface.co/${recipe.model}`;
|
|
command.textContent = recipe.command;
|
|
|
|
renderHardwareEvidence(hardwareState, hardwareBadge, recipe);
|
|
|
|
const limitations = recipe.limitations || [];
|
|
notes.replaceChildren();
|
|
notes.hidden = limitations.length === 0;
|
|
if (limitations.length) {
|
|
const notesHeading = document.createElement("strong");
|
|
notesHeading.textContent = "Know before you run";
|
|
const notesList = document.createElement("ul");
|
|
limitations.forEach((item) => {
|
|
const listItem = document.createElement("li");
|
|
listItem.textContent = item;
|
|
notesList.append(listItem);
|
|
});
|
|
notes.append(notesHeading, notesList);
|
|
}
|
|
|
|
const nextQuery = new URLSearchParams(window.location.search);
|
|
nextQuery.set("recipe", recipe.id);
|
|
nextQuery.set("runtime", runtime.id);
|
|
nextQuery.delete("gpus");
|
|
const nextUrl = `${window.location.pathname}?${nextQuery.toString()}${window.location.hash}`;
|
|
if (historyMode === "push") window.history.pushState({}, "", nextUrl);
|
|
else if (historyMode === "replace") window.history.replaceState({}, "", nextUrl);
|
|
status.textContent = `${recipe.label} selected for ${runtime.label}.`;
|
|
};
|
|
|
|
modelOptions.addEventListener("click", (event) => {
|
|
const option = event.target.closest("button[data-recipe-group]");
|
|
if (!option) return;
|
|
selectedGroupId = option.dataset.recipeGroup;
|
|
render({ groupChanged: true, historyMode: "push" });
|
|
});
|
|
hardwareOptions.addEventListener("click", (event) => {
|
|
const option = event.target.closest("button[data-recipe-id]");
|
|
if (!option) return;
|
|
selectedRecipeId = option.dataset.recipeId;
|
|
selectedGroupId = groupIdFor(byId.get(selectedRecipeId));
|
|
render({ historyMode: "push" });
|
|
});
|
|
|
|
render();
|
|
|
|
window.addEventListener("popstate", () => {
|
|
const nextQuery = new URLSearchParams(window.location.search);
|
|
const nextRecipe = nextQuery.get("recipe");
|
|
selectedRecipeId = nextRecipe && byId.has(nextRecipe) ? nextRecipe : defaultRecipeId;
|
|
selectedGroupId = groupIdFor(byId.get(selectedRecipeId));
|
|
render({ historyMode: "none" });
|
|
});
|
|
};
|
|
|
|
const init = () => {
|
|
initPatterns(document);
|
|
document.querySelectorAll("[data-cookbook][data-family]").forEach((root) => {
|
|
if (root.dataset.initialized) return;
|
|
root.dataset.initialized = "true";
|
|
initFamilyBuilder(root);
|
|
});
|
|
};
|
|
|
|
if (window.document$) window.document$.subscribe(init);
|
|
else document.addEventListener("DOMContentLoaded", init);
|
|
})();
|