mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
3a7fcd10e0
* feat(registry): add 25 image carousel blocks (5 families × 5 variants) Five carousel families, each with 5 style variants: - Orbit (1–5): image cards on a spinning 3D Fibonacci sphere - Path (1–5): cards following animated CSS motion paths - Circle (1–5): circular carousel layouts - Vision (1–5): Apple Vision-style spatial presentations - Text Circle (1–5): circular carousels with text overlays All blocks are 1920×1080 at 6s, with 12–24 configurable image slots. Includes catalog preview thumbnails for each block. Co-Authored-By: Jake Moran <jake.moran@heygen.com> * refactor(registry): host carousel block images on the CDN The registry is served straight out of this repository (DEFAULT_REGISTRY_URL points at raw.githubusercontent.com), so every byte a block ships is permanent history. The 25 carousel blocks added 421 JPEGs, 27.7 MiB in a checkout, and made the diff 472 files. Only 23 of those images were distinct: the same 12-24 placeholders were copied into every block. files[] entries gain an optional `url`. When set, the installer fetches the bytes from there instead of joining the registry base. `path` does not change and still says where the file lands relative to the item, so composition HTML, target mirroring and `hyperframes add` behave exactly as before. Keys are content-addressed, so the 396 manifest entries resolve to 23 objects, and a changed image gets a new URL rather than a stale one cached behind `immutable, max-age=31536000`. The catalog preview renderer copies an item's directory and renders it, so it needs the same materialisation step. Without it the preview draws every card blank and reports success, which is worse than failing. Also drops registry/catalog/, 25 hand-made thumbnails referenced by nothing; catalog previews are rendered by CI and served from docs/images/catalog. Verified: all 23 objects return 200 from the CDN with hashes matching their keys; `hyperframes add carousel-orbit-1` against a local registry installs 24 real JPEGs; the preview render produces the album art, and produces blank cards when the fetch step is removed. * style(registry): format the carousel composition HTML `oxfmt --check .` covers the whole tree, and these 25 files were never run through it. The pre-commit hook only formats staged files, so nothing local caught it. * feat(catalog): publish the carousel blocks without republishing their images These 25 blocks had no Catalog page. Every other item in the registry has one, so they shipped invisible: installable by name, unfindable by browsing. Generating them naively undid the change they were added by. The Catalog payload copies an item's assets into docs/public/, which is tracked, so the 396 images this PR just removed came back as 43 MB one directory over — worse than the 3 MB they started as, because each block got its own copy. The copy exists because these compositions assemble `img.src` at run time out of a variable value, so there is no `src="..."` in the markup for the payload's asset scan to resolve. An unpredictable path can only be satisfied by serving every file beside it, which is what `needsOwnDirectory` asks for. An absolute URL needs no directory: the scan already skips any `https:` reference. So for the payload path only, hosted files are left undownloaded and the composition's variable defaults are rewritten to their URLs. The preview renderer still downloads them, because it paints real frames and a missing file is a blank card. The explorer posts every value to the preview frame on mount, including untouched ones, so the page's variable list carries the URLs too. Left as local paths they would have overridden the payload's own defaults and asked the frame for a file that was deliberately never published. Result: 25 pages, 25 payloads, zero bytes of image added. Verified: a spike item declaring no assets at all rendered its 24 covers from the CDN, proving the variable-default path; payload generation for a carousel block now writes no item directory and no shared asset; the preview render still produces the album art. mint validate and mint broken-links pass on the new pages. test:scripts is green. * refactor(catalog): split the hosted-asset step out of prepareProjectDir Two functions rather than one: finding the composition and rewriting its variable defaults are separate jobs, and inlining the mode branch pushed prepareProjectDir past the complexity gate it was already sitting on. Behaviour is unchanged. Re-verified both paths after the split: the payload for a carousel block still writes no item directory and no shared asset, and the preview render still produces the album art. * feat(catalog): give the carousels their own shelf 25 image carousels landed in Showcases and were 53% of it, so the scenes that shelf exists for disappeared underneath them. That is the same shape the 24 editor themes made, and it gets the same fix they got. Keyed on the first tag, which is this file's stated grouping rule, rather than on the name. `screen-flow-carousel` leads with `product-demo` and stays on the shelf that says what it is for; a future carousel that is not named `carousel-*` still lands here. Showcases 47 -> 22, Carousels 25, and no existing item changed shelf. * fix(registry): centre the circle-5 carousel path in its composition Its ring was centred at x=3832.6 in a 3840-wide composition, so it sat on the right edge and most of it fell outside the frame. Only a few cards were ever visible, cropped, with two thirds of the composition empty. The exported path carried absolute coordinates from a layout that was never recentred. Shifting the four vertices by (-1912.597, -4.340) puts the ring on the composition centre. Handles are relative, so only the anchor points move and the shape is unchanged. carousel-text-circle-5 shares the identical path and had the identical fault. The other three circle variants sit within 12% of centre, which reads as authored placement rather than the same bug, so they are left alone. * fix(catalog): rebuild the circle-5 payloads after recentring the path The Catalog preview plays the payload, not the composition on disk, so recentring the source changed nothing a reader sees. The payload still carried the old vertices and the ring still hung off the right edge of the frame. Verified the consumer this time, not just the producer: both payloads now resolve to a path centre of x=1920. The other 23 rebuild byte-identical, so the formatting pass did not reach them. * feat(catalog): promote Carousels to its own section It was a shelf inside Scenes & demos, which is where a scene type belongs by kind but not by weight. At 25 items it is larger than Data & charts (17) and Blocks (13), each of which is already a section holding a single shelf, so the catalog's own precedent puts it one level up. Pulling it out also takes the largest section in the catalog from 120 items to 95, which is the reason the shelf was added in the first place. The two circle-5 pages change because their embedded source block carries the recentred path; nothing else in them moved. --------- Co-authored-by: Jake Moran <jake.moran@heygen.com> Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
603 lines
24 KiB
TypeScript
603 lines
24 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* Generate Catalog Preview Images + Videos
|
|
*
|
|
* Renders preview thumbnails and videos for registry blocks and components.
|
|
* Examples use the separate generate-template-previews.ts script.
|
|
*
|
|
* - Blocks: renders the block's standalone HTML via a wrapper index.html
|
|
* - Components: renders the component's demo.html via a wrapper index.html
|
|
*
|
|
* Output: docs/images/catalog/<type>/<name>.png + <name>.mp4
|
|
* (docs/images/ is gitignored — files are served from the CDN. After running
|
|
* this script, run `bun run upload:docs-images` to publish.)
|
|
*
|
|
* Usage:
|
|
* npx tsx scripts/generate-catalog-previews.ts # all items
|
|
* npx tsx scripts/generate-catalog-previews.ts --only data-chart # single item
|
|
* npx tsx scripts/generate-catalog-previews.ts --type block # blocks only
|
|
* npx tsx scripts/generate-catalog-previews.ts --skip-video # thumbnails only
|
|
*/
|
|
|
|
import {
|
|
readdirSync,
|
|
readFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
cpSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
statSync,
|
|
} from "node:fs";
|
|
import { execFileSync } from "node:child_process";
|
|
import { join, resolve, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createCatalogPreviewTempDir } from "./catalog-preview-temp.js";
|
|
import { runAsCommand } from "./entrypoint.ts";
|
|
// Import from source — bun workspace linking doesn't resolve for scripts outside packages/.
|
|
import {
|
|
captureFrame,
|
|
closeCaptureSession,
|
|
createRenderJob,
|
|
executeRenderJob,
|
|
} from "../packages/producer/src/index.js";
|
|
import { compileForRender } from "../packages/producer/src/services/htmlCompiler.js";
|
|
import { isContainedIn, resolveContainedCopies } from "./registry-target-paths.mjs";
|
|
import { withHostedDefaults } from "./registry-hosted-assets.ts";
|
|
import type { RegistryItem } from "../packages/core/src/index.js";
|
|
import { openOpaqueCapture } from "./preview-capture.js";
|
|
|
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = resolve(scriptDir, "..");
|
|
const registryDir = resolve(repoRoot, "registry");
|
|
|
|
if (!process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) {
|
|
process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = resolve(
|
|
repoRoot,
|
|
"packages/core/dist/hyperframe.manifest.json",
|
|
);
|
|
}
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
export type ItemKind = "block" | "component";
|
|
|
|
export interface CatalogItem {
|
|
name: string;
|
|
kind: ItemKind;
|
|
/** Directory containing the item's files in the registry. */
|
|
sourceDir: string;
|
|
/** The HTML file to render (relative to sourceDir). */
|
|
entryFile: string;
|
|
}
|
|
|
|
// ── Discovery ──────────────────────────────────────────────────────────────
|
|
|
|
export function discoverItems(
|
|
kindFilter: ItemKind | null,
|
|
nameFilter: string | null,
|
|
): CatalogItem[] {
|
|
const items: CatalogItem[] = [];
|
|
|
|
// Blocks and components only — examples use the existing generate-template-previews.ts.
|
|
const kinds: { kind: ItemKind; dir: string }[] = [
|
|
{ kind: "block", dir: join(registryDir, "blocks") },
|
|
{ kind: "component", dir: join(registryDir, "components") },
|
|
];
|
|
|
|
for (const { kind, dir } of kinds) {
|
|
if (kindFilter && kindFilter !== kind) continue;
|
|
if (!existsSync(dir)) continue;
|
|
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
if (!e.isDirectory()) continue;
|
|
if (nameFilter && e.name !== nameFilter) continue;
|
|
|
|
const sourceDir = join(dir, e.name);
|
|
const manifestPath = join(sourceDir, "registry-item.json");
|
|
if (!existsSync(manifestPath)) continue;
|
|
|
|
// Authored demos show transparent overlays against representative media.
|
|
let entryFile: string;
|
|
if (existsSync(join(sourceDir, "demo.html"))) {
|
|
entryFile = "demo.html";
|
|
} else if (kind === "component") {
|
|
continue;
|
|
} else {
|
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
const compFile = manifest.files?.find(
|
|
(f: { type: string }) => f.type === "hyperframes:composition",
|
|
);
|
|
entryFile = compFile?.path ?? `${e.name}.html`;
|
|
}
|
|
|
|
if (!existsSync(join(sourceDir, entryFile))) continue;
|
|
items.push({ name: e.name, kind, sourceDir, entryFile });
|
|
}
|
|
}
|
|
|
|
if (nameFilter && items.length === 0) {
|
|
const allNames = discoverItems(null, null).map((i) => i.name);
|
|
console.error(`Item "${nameFilter}" not found. Available: ${allNames.join(", ")}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
// ── Preview generation ─────────────────────────────────────────────────────
|
|
|
|
function outputDir(kind: ItemKind): string {
|
|
const typeDir = kind === "block" ? "blocks" : "components";
|
|
return resolve(repoRoot, "docs/images/catalog", typeDir);
|
|
}
|
|
|
|
/**
|
|
* Download the item's CDN-hosted files into the copied project.
|
|
*
|
|
* `cpSync` only carries what is committed. A file declaring `url` deliberately
|
|
* is not, so without this the preview renders the composition with every image
|
|
* missing and the failure looks like a layout bug rather than an absent file.
|
|
*
|
|
* Same containment rule as the target mirror, and for the same reason: this
|
|
* runs on `pull_request`, so `files[].path` is a contributor's string deciding
|
|
* where bytes land on the runner.
|
|
*/
|
|
function hostedFilesOf(projectDir: string): { path: string; url: string }[] {
|
|
const manifestPath = join(projectDir, "registry-item.json");
|
|
if (!existsSync(manifestPath)) return [];
|
|
|
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as {
|
|
files?: { path?: string; url?: string }[];
|
|
};
|
|
return (manifest.files ?? []).filter(
|
|
(file): file is { path: string; url: string } =>
|
|
typeof file.path === "string" &&
|
|
typeof file.url === "string" &&
|
|
file.url.startsWith("https://") &&
|
|
isContainedIn(projectDir, file.path),
|
|
);
|
|
}
|
|
|
|
async function fetchHostedFiles(projectDir: string): Promise<void> {
|
|
for (const file of hostedFilesOf(projectDir)) {
|
|
const res = await fetch(file.url);
|
|
if (!res.ok) {
|
|
throw new Error(`Hosted asset fetch failed: ${file.url} — HTTP ${res.status}`);
|
|
}
|
|
const destPath = resolve(projectDir, file.path);
|
|
mkdirSync(dirname(destPath), { recursive: true });
|
|
writeFileSync(destPath, new Uint8Array(await res.arrayBuffer()));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rewrite the composition's variable defaults from local names to CDN URLs.
|
|
*
|
|
* These compositions build `img.src` at run time out of the variable value, so
|
|
* there is no `src="…"` in the markup for the payload's asset scan to find.
|
|
* That is why an item shipping images was published with a copy of its entire
|
|
* directory: the only way to satisfy a path nobody can predict is to serve
|
|
* every file next to it. Absolute URLs need no directory at all — the payload's
|
|
* scan skips any `https:` reference — so 24 images per block stop being 24
|
|
* files per block in `docs/public/`.
|
|
*
|
|
* Only the entry composition is touched. Nothing else in the copied project
|
|
* declares variables, and rewriting a file the payload never reads would be a
|
|
* change with no reader.
|
|
*/
|
|
/** Downloading is the default: a caller that says nothing wants to render. */
|
|
async function materializeHostedAssets(
|
|
projectDir: string,
|
|
mode: PrepareOptions["hostedAssets"],
|
|
): Promise<void> {
|
|
if (mode === "cdn") return pointHostedAssetsAtCdn(projectDir);
|
|
await fetchHostedFiles(projectDir);
|
|
}
|
|
|
|
function pointHostedAssetsAtCdn(projectDir: string): void {
|
|
const manifestPath = join(projectDir, "registry-item.json");
|
|
if (!existsSync(manifestPath)) return;
|
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
|
|
|
|
const entryPath = compositionPathOf(projectDir, manifest);
|
|
if (entryPath === undefined) return;
|
|
|
|
const html = readFileSync(entryPath, "utf-8");
|
|
const rewritten = rewriteVariableDefaults(html, manifest);
|
|
if (rewritten !== html) writeFileSync(entryPath, rewritten, "utf-8");
|
|
}
|
|
|
|
/** The item's own composition file, when it is where the manifest says it is. */
|
|
function compositionPathOf(projectDir: string, manifest: RegistryItem): string | undefined {
|
|
const entry = manifest.files?.find((file) => file.type === "hyperframes:composition");
|
|
if (entry === undefined) return undefined;
|
|
const entryPath = join(projectDir, entry.path);
|
|
return existsSync(entryPath) ? entryPath : undefined;
|
|
}
|
|
|
|
function rewriteVariableDefaults(html: string, manifest: RegistryItem): string {
|
|
return html.replace(
|
|
/(\sdata-composition-variables=')([^']*)(')/i,
|
|
(whole, open: string, encoded: string, close: string) => {
|
|
try {
|
|
const variables = JSON.parse(decodeHtml(encoded)) as { default?: unknown }[];
|
|
return `${open}${encodeHtml(JSON.stringify(withHostedDefaults(variables, manifest)))}${close}`;
|
|
} catch {
|
|
// A manifest whose attribute is not parseable JSON is a broken item, and
|
|
// it fails loudly a moment later when the runtime reads the same string.
|
|
// Rewriting nothing keeps this from being the error anyone sees first.
|
|
return whole;
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/** The attribute is single-quoted, so only `'` has to survive the round trip. */
|
|
function decodeHtml(value: string): string {
|
|
return value.replace(/'/g, "'").replace(/&/g, "&");
|
|
}
|
|
|
|
function encodeHtml(value: string): string {
|
|
return value.replace(/&/g, "&").replace(/'/g, "'");
|
|
}
|
|
|
|
/**
|
|
* Preview the item in the same layout users get after installation: some
|
|
* components reference assets by their registry target path rather than by the
|
|
* flat source path stored beside the manifest.
|
|
*/
|
|
function mirrorRegistryTargets(projectDir: string): void {
|
|
const manifestPath = join(projectDir, "registry-item.json");
|
|
if (!existsSync(manifestPath)) return;
|
|
|
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as {
|
|
files?: { path?: string; target?: string }[];
|
|
};
|
|
|
|
// registry-item.json is untrusted: catalog-previews.yml runs on pull_request
|
|
// for any registry change, so the manifest arrives from the PR. Containment
|
|
// lives in its own module so the traversal cases stay testable without this
|
|
// file's producer imports.
|
|
for (const [from, to] of resolveContainedCopies(projectDir, manifest.files, existsSync)) {
|
|
mkdirSync(dirname(to), { recursive: true });
|
|
cpSync(from, to);
|
|
}
|
|
}
|
|
|
|
export interface PrepareOptions {
|
|
/**
|
|
* Inline sub-compositions ahead of time. On by default, because a render
|
|
* needs one self-contained document.
|
|
*
|
|
* The interactive preview turns it off: compiling resolves each mounted
|
|
* component's variables into the markup and CSS, so nothing is left for a
|
|
* reader to change. Left uncompiled, the mount survives and the runtime
|
|
* loads it live, which is the only state where `data-variable-values` still
|
|
* means anything.
|
|
*/
|
|
compile?: boolean;
|
|
/**
|
|
* The mounted entry is a bare component snippet, not a staged scene.
|
|
*
|
|
* A snippet sizes its own type and leaves placement to whatever you paste it
|
|
* into: its root is content with no canvas behind it and no vertical
|
|
* placement. Mounted into the plain wrapper it lands against white in the
|
|
* top-left corner and clips. This supplies the part its authored demo would
|
|
* have: a dark canvas, the dark theme its own tokens are written against, and
|
|
* the component centred with room around it.
|
|
*/
|
|
uiFragment?: boolean;
|
|
/**
|
|
* How a `files[]` entry that declares `url` reaches the project.
|
|
*
|
|
* `"download"` writes the bytes in, which a frame render needs: it paints a
|
|
* real page and a missing file is a blank card.
|
|
*
|
|
* `"cdn"` leaves them out and rewrites the composition's variable defaults to
|
|
* the URLs instead. That is for the Catalog payload, which is fetched by a
|
|
* browser rather than rendered here — downloading would only put the bytes
|
|
* back in `docs/public/`, which is the repository again by another name.
|
|
*/
|
|
hostedAssets?: "download" | "cdn";
|
|
}
|
|
|
|
export async function prepareProjectDir(
|
|
item: CatalogItem,
|
|
options: PrepareOptions = {},
|
|
): Promise<string> {
|
|
const tmpDir = createCatalogPreviewTempDir(item.name);
|
|
cpSync(item.sourceDir, tmpDir, { recursive: true });
|
|
await materializeHostedAssets(tmpDir, options.hostedAssets);
|
|
mirrorRegistryTargets(tmpDir);
|
|
|
|
// The HyperFrames producer navigates to index.html at the project root.
|
|
// Blocks and component demos are standalone HTML files, not index.html.
|
|
// If the entry file is a standalone HTML (has its own timeline registration),
|
|
// just rename it to index.html. Otherwise create a wrapper.
|
|
if (!existsSync(join(tmpDir, "index.html")) && existsSync(join(tmpDir, item.entryFile))) {
|
|
const entryContent = readFileSync(join(tmpDir, item.entryFile), "utf-8");
|
|
// A registration inside <template> does NOT make the file standalone: the
|
|
// template's markup and scripts stay inert until a host composition mounts
|
|
// it via data-composition-src. Rendering such a block as index.html paints
|
|
// a blank page and fails with "Composition has zero duration", so match on
|
|
// the document with template content removed and let those blocks fall
|
|
// through to the wrapper below.
|
|
const hasTimeline = entryContent
|
|
.replace(/<template\b[\s\S]*?<\/template>/gi, "")
|
|
.includes("__timelines");
|
|
if (hasTimeline) {
|
|
// Standalone block — copy to index.html and render directly.
|
|
// For social overlays with transparent backgrounds, inject a dark bg
|
|
// so the overlay card is visible against something.
|
|
let content = entryContent;
|
|
const hasSocialTag = (() => {
|
|
try {
|
|
const m = JSON.parse(readFileSync(join(tmpDir, "registry-item.json"), "utf-8"));
|
|
return (m.tags ?? []).includes("social");
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
if (hasSocialTag) {
|
|
// Dark bg for transparent overlays
|
|
if (content.includes("background: transparent")) {
|
|
content = content.replace("background: transparent", "background: #1a1a2e");
|
|
}
|
|
// Reposition bottom-anchored overlays to center for preview.
|
|
// Social overlays use "bottom: Npx" positioning — replace with
|
|
// "top: 50%; transform: translate(-50%, -50%)" for a centered preview.
|
|
content = content.replace(
|
|
/bottom:\s*\d+px;\s*\n(\s*)left:\s*50%;\s*\n(\s*)transform:\s*translateX\(-50%\)/,
|
|
"top: 50%;\n$1left: 50%;\n$2transform: translate(-50%, -50%)",
|
|
);
|
|
// Scale down large centered cards (like Spotify) that use
|
|
// margin-based centering with large negative margins.
|
|
if (/margin-top:\s*-[3-9]\d\dpx/.test(content)) {
|
|
content = content.replace(
|
|
/(<body[^>]*>)/,
|
|
"$1\n<style>body { transform: scale(0.55); transform-origin: center center; }</style>",
|
|
);
|
|
}
|
|
}
|
|
writeFileSync(join(tmpDir, "index.html"), content, "utf-8");
|
|
}
|
|
}
|
|
if (!existsSync(join(tmpDir, "index.html"))) {
|
|
// One read for every field the wrapper needs. A malformed manifest cannot
|
|
// reach here — `discoverItems` parses the same file without a guard — so
|
|
// the only case this absorbs is the file being absent, which is what each
|
|
// `??` default below already stood for.
|
|
const manifest: {
|
|
dimensions?: { width?: number; height?: number };
|
|
duration?: number;
|
|
tags?: string[];
|
|
files?: { path?: string; target?: string }[];
|
|
} = (() => {
|
|
try {
|
|
return JSON.parse(readFileSync(join(tmpDir, "registry-item.json"), "utf-8"));
|
|
} catch {
|
|
return {};
|
|
}
|
|
})();
|
|
|
|
const width = manifest.dimensions?.width ?? 1920;
|
|
const height = manifest.dimensions?.height ?? 1080;
|
|
const duration = manifest.duration ?? 5;
|
|
|
|
// Dark background for social overlays so transparent cards are visible.
|
|
const tags = manifest.tags ?? [];
|
|
const isSocialOverlay = tags.includes("social") || tags.includes("overlay");
|
|
const bgColor = options.uiFragment ? "#0a0a0a" : isSocialOverlay ? "#1a1a2e" : "#ffffff";
|
|
|
|
// Mount the mirrored install-layout copy when one exists. Blocks reference
|
|
// their own assets the way they will after `hyperframes add`
|
|
// (`../assets/background.jpeg` from `compositions/`), which only resolves
|
|
// from the target path — the flat source copy at the project root resolves
|
|
// it outside the project and silently renders without the asset.
|
|
const entryTarget = manifest.files?.find((f) => f.path === item.entryFile)?.target;
|
|
const entrySrc =
|
|
entryTarget && existsSync(join(tmpDir, entryTarget)) ? entryTarget : item.entryFile;
|
|
|
|
// `inset: 0` is load-bearing. The runtime positions a mount absolutely and
|
|
// leaves it to size itself, so without it the mount shrinks to the
|
|
// component plus padding and there is nothing for centring to centre in.
|
|
// `place-items: center stretch` centres it vertically while letting it span
|
|
// the width, so a component's own alignment variable still reads.
|
|
const staging = options.uiFragment
|
|
? `\n [data-composition-src] { inset: 0; display: grid; place-items: center stretch; box-sizing: border-box; padding: ${Math.round(height / 11)}px; }`
|
|
: "";
|
|
const theme = options.uiFragment ? ' data-hf-theme="dark"' : "";
|
|
|
|
const wrapper = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=${width}, height=${height}" />
|
|
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
|
<style>* { margin: 0; padding: 0; } html, body { width: ${width}px; height: ${height}px; overflow: hidden; background: ${bgColor}; }${staging}</style>
|
|
</head>
|
|
<body>
|
|
<div data-composition-id="preview-root" data-width="${width}" data-height="${height}" data-start="0" data-duration="${duration}"${theme}>
|
|
<div data-composition-id="${item.name}" data-composition-src="${entrySrc}" data-start="0" data-duration="${duration}" data-track-index="0" data-width="${width}" data-height="${height}"></div>
|
|
</div>
|
|
<script>
|
|
window.__timelines = window.__timelines || {};
|
|
window.__timelines["preview-root"] = gsap.timeline({ paused: true });
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
writeFileSync(join(tmpDir, "index.html"), wrapper, "utf-8");
|
|
}
|
|
|
|
const indexPath = join(tmpDir, "index.html");
|
|
const indexHtml = readFileSync(indexPath, "utf-8");
|
|
if (options.compile !== false && indexHtml.includes("data-composition-src")) {
|
|
const compiled = await compileForRender(tmpDir, indexPath, join(tmpDir, "_downloads"));
|
|
writeFileSync(indexPath, compiled.html, "utf-8");
|
|
}
|
|
|
|
return tmpDir;
|
|
}
|
|
|
|
/** Pull a `data-<attr>` pixel value out of the wrapper markup, or fall back. */
|
|
function wrapperDimension(html: string, attr: "width" | "height", fallback: number): number {
|
|
const match = html.match(new RegExp(`data-${attr}="(\\d+)"`))?.[1];
|
|
return match ? parseInt(match, 10) : fallback;
|
|
}
|
|
|
|
async function generateThumbnail(item: CatalogItem, projectDir: string): Promise<void> {
|
|
const outDir = outputDir(item.kind);
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
|
// Read dimensions from the wrapper index.html (which may differ from native
|
|
// dimensions for portrait overlays that are scaled to fit landscape).
|
|
const wrapperHtml = readFileSync(join(projectDir, "index.html"), "utf-8");
|
|
const width = wrapperDimension(wrapperHtml, "width", 1920);
|
|
const height = wrapperDimension(wrapperHtml, "height", 1080);
|
|
|
|
const framesDir = join(projectDir, "_thumb_frames");
|
|
const { fileServer, session, duration } = await openOpaqueCapture({ projectDir, width, height });
|
|
try {
|
|
// Capture after the treatment appears, capped for long compositions.
|
|
const captureTime = Math.min(3.0, duration * 0.6);
|
|
const result = await captureFrame(session, 0, captureTime);
|
|
execFileSync(
|
|
"ffmpeg",
|
|
["-v", "error", "-y", "-i", result.path, join(outDir, `${item.name}.png`)],
|
|
{
|
|
stdio: "inherit",
|
|
},
|
|
);
|
|
console.log(` ✓ ${item.name}.png (${result.captureTimeMs}ms)`);
|
|
|
|
await closeCaptureSession(session);
|
|
} finally {
|
|
fileServer.close();
|
|
rmSync(framesDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function generateVideo(item: CatalogItem, projectDir: string): Promise<void> {
|
|
const outDir = outputDir(item.kind);
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
|
const outMp4 = join(outDir, `${item.name}.mp4`);
|
|
const masterMp4 = join(outDir, `${item.name}.master.mp4`);
|
|
const job = createRenderJob({
|
|
fps: { num: 24, den: 1 },
|
|
quality: "draft",
|
|
format: "mp4",
|
|
});
|
|
await executeRenderJob(job, projectDir, masterMp4);
|
|
encodeForWeb(masterMp4, outMp4);
|
|
rmSync(masterMp4, { force: true });
|
|
console.log(` ✓ ${item.name}.mp4 (${(statSync(outMp4).size / 1048576).toFixed(1)} MB)`);
|
|
}
|
|
|
|
/**
|
|
* The render output is a master, not a deliverable. Publishing it directly put
|
|
* 25 Mbps files on the docs CDN — one 20-second preview was 60 MB, which a
|
|
* reader on a phone pays for the moment they press play. This pass is the
|
|
* difference between a master and something you serve.
|
|
*/
|
|
function encodeForWeb(input: string, output: string): void {
|
|
execFileSync(
|
|
"ffmpeg",
|
|
[
|
|
"-v",
|
|
"error",
|
|
"-y",
|
|
"-i",
|
|
input,
|
|
// 1280 wide is twice the 590px docs column: sharp on retina, no pixels
|
|
// nobody sees.
|
|
"-vf",
|
|
"scale='min(1280,iw)':-2",
|
|
"-c:v",
|
|
"libx264",
|
|
"-profile:v",
|
|
"high",
|
|
"-crf",
|
|
"28",
|
|
"-preset",
|
|
"slow",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
// faststart puts the index first so playback can begin before the whole
|
|
// file has arrived.
|
|
"-movflags",
|
|
"+faststart",
|
|
// ffmpeg ignores these when the input carries no audio stream.
|
|
"-c:a",
|
|
"aac",
|
|
"-b:a",
|
|
"128k",
|
|
"-ac",
|
|
"2",
|
|
output,
|
|
],
|
|
{ stdio: "inherit" },
|
|
);
|
|
}
|
|
|
|
// ── CLI ────────────────────────────────────────────────────────────────────
|
|
|
|
function parseArgs(): { only: string | null; type: ItemKind | null; skipVideo: boolean } {
|
|
let only: string | null = null;
|
|
let type: ItemKind | null = null;
|
|
let skipVideo = false;
|
|
|
|
for (let i = 2; i < process.argv.length; i++) {
|
|
const arg = process.argv[i];
|
|
if (arg === "--only" && process.argv[i + 1]) {
|
|
i++;
|
|
only = process.argv[i] ?? null;
|
|
}
|
|
if (arg === "--type" && process.argv[i + 1]) {
|
|
i++;
|
|
const val = process.argv[i];
|
|
if (val === "block" || val === "component") {
|
|
type = val;
|
|
} else {
|
|
console.error(`Invalid --type: "${val}". Must be block or component.`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
if (arg === "--skip-video") skipVideo = true;
|
|
}
|
|
|
|
return { only, type, skipVideo };
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const { only, type, skipVideo } = parseArgs();
|
|
const items = discoverItems(type, only);
|
|
|
|
console.log(
|
|
`Generating catalog previews for ${items.length} item(s)${skipVideo ? " (thumbnails only)" : " + videos"}...\n`,
|
|
);
|
|
|
|
for (const item of items) {
|
|
console.log(`[${item.kind}] ${item.name}`);
|
|
const projectDir = await prepareProjectDir(item);
|
|
try {
|
|
await generateThumbnail(item, projectDir);
|
|
if (!skipVideo) {
|
|
await generateVideo(item, projectDir);
|
|
}
|
|
} catch (err) {
|
|
console.error(` ✗ ${item.name}: ${err instanceof Error ? err.message : err}`);
|
|
} finally {
|
|
rmSync(projectDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
console.log("\nDone.");
|
|
}
|
|
|
|
// Only render when run as a command. This module also exports discoverItems
|
|
// and prepareProjectDir for the payload generator, and an unguarded main()
|
|
// would render every preview the moment that script imported them.
|
|
runAsCommand(import.meta.url, main);
|