mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
fix(scripts): render template-only blocks in catalog previews (#3098)
* fix(scripts): render template-only blocks in catalog previews The catalog preview renderer treated any file containing `__timelines` as a standalone composition and rendered it as index.html directly. The 12 VS Code snippet blocks register their timeline inside a `<template>`, which stays inert until a host mounts it, so every one of them failed with "Composition has zero duration" and no preview could be produced from the registry at all. Six of the previews on the docs CDN were hand-made from a project still mounting Monokai, so Dark+, High Contrast, High Contrast Light, Solarized Light, Visual Studio Dark and Visual Studio Light all showed Monokai's video. Detect standalone-ness on the document with template content stripped, mount the mirrored install-layout copy so a block's own `../assets/*` references resolve, and capture posters opaque: `format: "png"` is the engine's transparent mode and forces `background-image: none` on every composition root, which erased the desktop backdrop these blocks paint. Publishing gets the missing half too: preview URLs are stable and the objects are uploaded `immutable` with a one-year max-age, so a re-upload alone never reaches a reader. * fix(scripts): install ffmpeg in the preview job and fix the sibling renderer The canary this PR added caught its own regression: the poster transcode shells out to ffmpeg, which ubuntu-latest does not ship and this job never needed, so both canaries failed with `spawnSync ffmpeg ENOENT`. Install it the way every other render job does. `encodeForWeb` has always shelled out to the same binary; the job only got away with it because `--skip-video` skipped that path. generate-template-previews.ts captures posters through the same transparent `format: "png"` mode, so any template painting its own backdrop loses it exactly as the code snippets did. Fixing one renderer and leaving its sibling on the broken call would just move the bug. Also fold the three separate parses of registry-item.json into one read: they had drifted into three different failure behaviours for the same file.
This commit is contained in:
@@ -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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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 || {};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/"
|
||||
|
||||
Reference in New Issue
Block a user