Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/catalog-previews.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ jobs:
with:
chrome-version: stable

# The renderer shells out to ffmpeg for both halves of a preview: the
# poster transcode and the web encode of the mp4. Neither ran here before
# (`--skip-video` skipped the encode, and the poster copy was a plain
# file copy), so the job never needed it and ubuntu-latest does not ship
# it.
- name: Install ffmpeg
run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends ffmpeg

- name: Render changed block/component previews
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
Expand All @@ -68,6 +76,19 @@ jobs:
| sed 's|^registry/[^/]*/\([^/]*\)/.*|\1|' \
| sort -u)

# A renderer change reaches every item, so it cannot be trusted to a
# PR that happens to also touch a block. Two canaries cover the two
# shapes the renderer has to tell apart: a block whose scene lives in
# a <template> (mounted through a wrapper) and one that registers its
# timeline at body level (rendered directly). Getting that wrong is
# silent — the wrong-shaped block renders blank, not red.
RENDERER_CHANGED=$(git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD \
-- scripts/generate-catalog-previews.ts scripts/registry-target-paths.mjs)
if [ -n "$RENDERER_CHANGED" ]; then
CHANGED_ITEMS=$(printf '%s\n' $CHANGED_ITEMS \
code-snippet-visual-studio-dark code-snippet-apple-terminal-pro | sort -u)
fi

if [ -z "$CHANGED_ITEMS" ]; then
echo "No block/component changes detected."
exit 0
Expand Down
72 changes: 53 additions & 19 deletions scripts/generate-catalog-previews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,15 @@ async function prepareProjectDir(item: CatalogItem): Promise<string> {
// 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");
const hasTimeline = entryContent.includes("__timelines");
// 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
Expand Down Expand Up @@ -203,28 +211,41 @@ async function prepareProjectDir(item: CatalogItem): Promise<string> {
}
}
if (!existsSync(join(tmpDir, "index.html"))) {
const manifestPath = join(tmpDir, "registry-item.json");
let width = 1920;
let height = 1080;
let duration = 5;
if (existsSync(manifestPath)) {
const m = JSON.parse(readFileSync(manifestPath, "utf-8"));
width = m.dimensions?.width ?? width;
height = m.dimensions?.height ?? height;
duration = m.duration ?? duration;
}

// Dark background for social overlays so transparent cards are visible.
const tags: string[] = (() => {
// 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")).tags ?? [];
return JSON.parse(readFileSync(join(tmpDir, "registry-item.json"), "utf-8"));
} catch {
return [];
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 = 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;

const wrapper = `<!doctype html>
<html lang="en">
<head>
Expand All @@ -235,7 +256,7 @@ async function prepareProjectDir(item: CatalogItem): Promise<string> {
</head>
<body>
<div data-composition-id="preview-root" data-width="${width}" data-height="${height}" data-start="0" data-duration="${duration}">
<div data-composition-id="${item.name}" data-composition-src="${item.entryFile}" data-start="0" data-duration="${duration}" data-track-index="0" data-width="${width}" data-height="${height}"></div>
<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 || {};
Expand Down Expand Up @@ -280,11 +301,18 @@ async function generateThumbnail(item: CatalogItem, projectDir: string): Promise
fps: { num: 30, den: 1 },
});
try {
// `format: "png"` is the engine's TRANSPARENT capture mode: it forces
// `background-image: none !important` on every `[data-composition-id]`, so
// any block whose scene paints its own backdrop (the VS Code snippets sit
// on a desktop wallpaper) loses it and the poster comes out empty. These
// posters are opaque page images, never a compositing layer — capture
// opaque and transcode to the .png the catalog pages reference.
const session = await createCaptureSession(fileServer.url, framesDir, {
width,
height,
fps: { num: 30, den: 1 },
format: "png",
format: "jpeg",
quality: 95,
});
await initializeSession(session);

Expand All @@ -298,7 +326,13 @@ async function generateThumbnail(item: CatalogItem, projectDir: string): Promise
// 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);
cpSync(result.path, join(outDir, `${item.name}.png`));
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);
Expand Down
14 changes: 12 additions & 2 deletions scripts/generate-template-previews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
cpSync,
rmSync,
} from "node:fs";
import { execFileSync } from "node:child_process";
import { join, resolve, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -147,11 +148,16 @@ async function generateThumbnail(templateId: string, projectDir: string): Promis
fps: { num: 30, den: 1 },
});
try {
// Opaque capture, for the reason spelled out in generate-catalog-previews.ts:
// `format: "png"` is the engine's TRANSPARENT mode and forces
// `background-image: none !important` on every composition root, silently
// dropping any backdrop the template paints for itself.
const session = await createCaptureSession(fileServer.url, framesDir, {
width: config.width,
height: config.height,
fps: 30,
format: "png",
format: "jpeg",
quality: 95,
});
await initializeSession(session);

Expand All @@ -164,7 +170,11 @@ async function generateThumbnail(templateId: string, projectDir: string): Promis

const t = Math.min(config.captureTime, duration * 0.8);
const result = await captureFrame(session, 0, t);
cpSync(result.path, join(outputDir, `${templateId}.png`));
execFileSync(
"ffmpeg",
["-v", "error", "-y", "-i", result.path, join(outputDir, `${templateId}.png`)],
{ stdio: "inherit" },
);
console.log(` ✓ ${templateId}.png (${result.captureTimeMs}ms)`);

await closeCaptureSession(session);
Expand Down
15 changes: 14 additions & 1 deletion scripts/upload-docs-images.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
# `scripts/generate-catalog-previews.ts` or `scripts/generate-template-previews.ts`,
# run this script to publish the new files.
#
# Requires AWS credentials for the heygen engineering account (profile: engineering-767398024897).
# Requires AWS credentials for the heygen engineering account (profile: engineering-767398024897)
# with both s3:PutObject and cloudfront:CreateInvalidation — the sync alone does
# not reach a reader, see the invalidation step below.
# Contributors without AWS access: open a PR with the HTML/MDX changes and a
# maintainer will run the generators + this upload before merging.

Expand All @@ -28,4 +30,15 @@ aws --profile "$PROFILE" s3 sync "$SRC" "$DEST" \
--cache-control "public, max-age=31536000, immutable" \
--metadata-directive REPLACE

# Preview URLs are stable, and the objects go up `immutable` with a one-year
# max-age, so a re-upload alone changes nothing a reader sees: the edge keeps
# serving the old file until the TTL expires. Republishing a corrected preview
# is not done until the cache is dropped.
DISTRIBUTION="${DOCS_CDN_DISTRIBUTION_ID:-E2BSLVSZ7FG3U0}"
echo "Invalidating $DISTRIBUTION"
aws --profile "$PROFILE" cloudfront create-invalidation \
--distribution-id "$DISTRIBUTION" \
--paths "/hyperframes-oss/docs/images/*" \
--query "Invalidation.Id" --output text

echo "Done. Files are live at https://static.heygen.ai/hyperframes-oss/docs/images/"
Loading