Skip to content

fix(scripts): render template-only blocks in catalog previews - #3098

Merged
miguel-heygen merged 2 commits into
mainfrom
fix-catalog-preview-template-blocks
Aug 7, 2026
Merged

fix(scripts): render template-only blocks in catalog previews#3098
miguel-heygen merged 2 commits into
mainfrom
fix-catalog-preview-template-blocks

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What

Makes scripts/generate-catalog-previews.ts able to render the blocks whose scene lives in a <template>, and republishes the 12 VS Code code-snippet previews from the registry.

Reported: Solarized Light, Visual Studio Dark and Visual Studio Light showed Monokai's video on their catalog pages. Auditing the rest of the family found three more with the same symptom — Dark+, High Contrast, High Contrast Light — six wrong in total.

Why

The block HTML was never wrong. The renderer was, and it could not produce these previews at all:

✗ code-snippet-visual-studio-dark: [FrameCapture] Composition has zero duration.
  → No GSAP timeline registered (window.__timelines is empty).

prepareProjectDir classified an entry file as a standalone composition if the text contained __timelines. All 12 VS Code snippet blocks register their timeline inside <template id="…-template">, where the markup and scripts stay inert until a host composition mounts them via data-composition-src. Matching the raw text put every one of them on the standalone path, rendering a page whose body is a single unmounted template — a blank frame and a zero-duration failure.

Because nothing could come out of the pipeline, the published previews were made by hand, and six of them came from a project still mounting Monokai. The content on those pages was right; only the video was another block's.

Two further defects surfaced once the blocks actually rendered, both of which would have shipped a visibly worse preview than the one being replaced:

  • Blocks reference their own assets the way they will after hyperframes add (../assets/background.jpeg from compositions/). The wrapper mounted the flat source copy at the project root, where that path resolves outside the project, so the desktop backdrop silently vanished.
  • The poster capture requested format: "png", which is the engine's transparent capture mode. It injects background-image: none !important on every [data-composition-id], so any block painting its own backdrop lost it and the poster came out empty with an alpha channel.

How

  • Test standalone-ness on the document with <template> content stripped, so template-only blocks fall through to the wrapper that mounts them properly.
  • Point the wrapper's data-composition-src at the mirrored install-layout copy when the manifest declares one, so a block's own relative asset paths resolve.
  • Capture posters opaque (format: "jpeg", quality 95) and transcode to the .png the catalog pages already reference. No page or URL changes.
  • upload-docs-images.sh now invalidates the CDN after syncing. Preview URLs are stable and objects are uploaded immutable with a one-year max-age, so a corrected file that is only re-uploaded never reaches a reader — verified during this fix, where the CDN kept serving the old bytes until the invalidation landed.
  • catalog-previews.yml renders two canaries whenever the renderer itself changes: one template-only block and one that registers at body level. The two shapes fail silently in opposite directions, and the existing job only rendered items a PR happened to touch.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)

Regenerated all 12 VS Code snippet previews from the registry and read the theme name and workbench colors off frame 3s of each: every one now shows its own theme, on its own backdrop. Previously 6 of 12 showed Monokai.

Audited the rest of the code family against the CDN for the same class of mismatch — 12 Apple Terminal snippets and 9 other code blocks (code-diff, code-highlight, code-morph, code-scroll, code-typing, code-3d-extrude, code-particle-assemble, code-shader-dissolve, code-snippet-flight). All distinct and matching their titles; no other block was affected.

Corrected previews are uploaded and the CDN was invalidated, so the catalog pages are already right. Re-running the generator reproduces them.

Not covered: code-snippet-light-plus renders light-theme chrome as dark, because its theme object overrides editor.background but not the workbench colors. That is authored in the block, predates this change, and is identical in the preview it replaces.

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.

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE @ 008da8b — R1

The fix strips <template>...</template> out of the entry text before probing for __timelines, so registrations that only exist inside inert templates fall through to the wrapper path that actually mounts them via data-composition-src. Two adjacent defects are bundled — the wrapper now points at the manifest's install-layout target (so ../assets/… relatives resolve), and the poster capture uses format: "jpeg" + quality: 95 transcoded to .png through ffmpeg to escape the engine's coupling of PNG output with transparent-background mode. The CI canary pair (code-snippet-visual-studio-dark template-only + code-snippet-apple-terminal-pro body-level) covers the exact "silent blank frame in the wrong-shaped block" failure the PR is fixing, so future renderer edits get exercised on both branches.

Findings

  1. [NIT] scripts/generate-catalog-previews.ts:173-175 — the <template\b[\s\S]*?<\/template>/gi strip is right for HTML-body markup, and non-greedy handles multiple templates cleanly. Two adversarial edges I chased and did not find in the registry: (a) a JS string literal containing "<template>…</template>" alongside __timelines in the same entry file would get its inner text stripped and misclassify; (b) an unclosed <template> (no </template>) yields no strip and re-enters the pre-fix path. Both require authoring shapes that don't occur across the current block registry — noting only so the constraint is visible if the classifier is ever reused elsewhere.

  2. [NIT] scripts/generate-catalog-previews.ts:333-339 — matches the existing encodeForWeb convention of calling bare ffmpeg via execFileSync; runner already has ffmpeg on PATH (encodeForWeb has been shipping this way). The workflow's FFMPEG_BIN env var is a HyperFrames-side pointer, not consulted here — fine as-is.

  3. [NIT] scripts/generate-catalog-previews.ts:308-320 — JPEG95 → PNG transcode is lossy on the pixel edge, which matters most for the sharp glyphs in code-snippet posters. Author verified all 12 outputs manually against theme names + workbench colors, so this is acceptable, but worth remembering the next time someone reaches for format: "png" to get an alpha channel out.

  4. [NOTE] scripts/generate-template-previews.ts:139 still calls createCaptureSession(..., format: "png") with the same engine that forces background-image: none !important. Any template that paints its own backdrop would repeat the "empty poster" symptom fixed here. Miguel's audit only found VS Code snippets affected across the block registry, and templates are a separate surface — flagging as a follow-up scope, not a blocker on this PR.

  5. [NOTE] Fallow audit is red with 7 findings — I read the deltas: discoverItems (line 76), parseArgs (414), and main (441) are byte-identical to base, and the 19-line clone against generate-template-previews.ts:139 is pre-existing scaffolding. The genuinely PR-introduced surface is entrySrc (line 241, minor, CRAP 30.0 at threshold) and a small complexity bump in prepareProjectDir. Not a functional blocker; if Fallow needs to be quieted, hoisting entrySrc and the hasSocialTag IIFEs into a readManifestField(tmpDir, ...) helper would drop prepareProjectDir's cyclomatic count without changing behavior.

CI

  • Green: Lint, Format, Producer unit/integration, SDK, Studio load smoke, runtime contract, preview-regression, player-perf.
  • Red: Fallow audit — mostly baseline noise (see finding 5).
  • Pending: Render catalog previews, Build, Typecheck, Analyze (javascript-typescript), Test, CLI smoke (required), Tests on windows-latest, Render on windows-latest — worth confirming green before merge, especially Render catalog previews which is the canary this PR wires up.

Verdict: CORRECT / A-. Small, self-contained, well-explained, and each of the three fixes is the minimum-surface change that solves its defect. The docs-CDN invalidation and the two-shape canary are quality-of-life upgrades that keep this class of "silent wrong-video" from recurring.

— Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 008da8b62.

Small surface area, and every hunk is a proven cause for a symptom the PR names. Bug narrative is exactly the shape a scripts-tooling fix should have — one root cause (entryContent.includes("__timelines") matching a registration that's actually inside a <template> and therefore inert until mount) that cascaded through the pipeline, plus two blast-radius defects that would have shipped a visibly wrong preview even after the classification was fixed.

Root fix — template-stripped standalone detection. scripts/generate-catalog-previews.ts:175.replace(/<template\b[\s\S]*?<\/template>/gi, "") before the .includes("__timelines") check. Verified on the actual registry: registry/blocks/code-snippet-visual-studio-dark/code-snippet-visual-studio-dark.html opens <template id="vscode-visual-studio-dark-template"> at :9 and window.__timelines["vscode-visual-studio-dark"] = … sits inside it at :1022, so the strip empties the <template>…</template> region and the .includes returns false — the wrapper path takes over. The complementary shape (code-snippet-apple-terminal-pro.html) has __timelines at body level with zero <template> occurrences, so the strip is a no-op there and the standalone path still fires. Both shapes are the canaries the workflow will now render.

The regex is the pragmatic call here: <template> bodies in the block HTML are hand-authored well-formed markup, and grepping the workspace for __timelines shows this is the ONE call site doing the classification (grep -rn "__timelines" scripts/ returns only this line plus wrapper-generation strings and test fixtures). No sibling that would need the same strip. A DOM parse would be more robust in theory but heavier for a script that already handles many similar text-transforms downstream.

What lands cleanly beyond the root fix:

  • The manifest-target entrySrc IIFE at :236-252. Reads registry-item.json and prefers files[].target over the flat source path when the manifest declares one. try/catch swallows manifest-missing/malformed cases cleanly, existsSync on the resolved target guards the "manifest says X but the mirror wasn't produced" case, fallback to item.entryFile matches pre-PR behaviour for the anonymous case. This addresses a specific and specific-enough failure mode: ../assets/background.jpeg from a compositions/*.html file resolves through the mirrored install-layout path but not through the flat source root. Names the exact reason the block's desktop backdrop was vanishing, which is the sort of comment I'd want as-is if I were the next reader.
  • The transparent-vs-opaque poster capture at :305-336. The comment format: "png" is the engine's TRANSPARENT capture mode: it forces background-image: none !important on every [data-composition-id] is the load-bearing bit — this is a HF convention that isn't obvious from the argument name, and the fix (format: "jpeg", quality: 95 then ffmpeg transcode to .png) preserves the URL the catalog pages reference without changing the format the mode does the wrong thing under. execFileSync is already imported (:32 — used one function down at :373), so the new call adds no dependency. The 95-quality JPEG → PNG round-trip is a lossy step in service of a visually-indistinguishable-for-photographic-content trade — fine for preview posters, worth naming only if these ever get repurposed as pixel-diff sources.
  • CDN invalidation at upload-docs-images.sh:35-40. DISTRIBUTION="${DOCS_CDN_DISTRIBUTION_ID:-E2BSLVSZ7FG3U0}" gives an env override with a hardcoded fallback, --paths "/hyperframes-oss/docs/images/*" matches the object key prefix at CloudFront (which prepends /), and set -euo pipefail at :14 will surface an invalidation failure rather than silently succeeding with stale cache. The comment naming "a re-upload alone changes nothing a reader sees" is exactly the sort of "why this line exists, discovered painfully" note that pays off the next time someone assumes s3 sync is enough.
  • Canary rows in catalog-previews.yml:71-81. RENDERER_CHANGED is computed against scripts/generate-catalog-previews.ts and scripts/registry-target-paths.mjs — the latter is a genuine renderer dependency (generate-catalog-previews.ts:48 imports resolveContainedCopies from it), so watching both is correct. Two canaries covering the two shapes that fail in opposite silent directions is the right defensive posture for a script whose failure mode is "wrong-shaped block renders blank, not red". Names the failure-modes-are-opposite-directions bit in the comment, which is the load-bearing observation.

Small notes (nits, none block):

  • Duplicate manifest parse. registry-item.json is JSON.parse'd at :219 (guarded by existsSync in the outer scope) and again inside the entrySrc IIFE at :243 — the two blocks both live under if (!existsSync(join(tmpDir, "index.html"))), so hoisting the parsed m to a shared local (or moving the IIFE inside the earlier if (existsSync(manifestPath))) drops the redundant read. Cost is nothing today, but a manifest schema change touched to both readers keeps them coupled.
  • Canary triggers don't cover a wrapper-only edit. RENDERER_CHANGED is scoped to the two script files, so if a change lands only in the wrapper HTML string inside generate-catalog-previews.ts — say, the <script> inline block at :263-271 — that's captured (same file). But if a future refactor extracts the wrapper into its own file (e.g. scripts/preview-wrapper.html), the canary trigger stops firing without an update. Not a bug today, just a fragility to name when re-organising this area.
  • upload-docs-images.sh docstring at :12 says "Requires AWS credentials for the heygen engineering account". The invalidation adds a permission the sync doesn't need (cloudfront:CreateInvalidation alongside s3:PutObject); a contributor whose sync used to work may find the invalidation step fails. Optional to name here.

Curiosity, not a review point: the format: "png" = transparent convention isn't self-documenting from the caller's POV — a contributor writing a new capture site would reasonably assume that's the format field. Is there a follow-up unit that would land a format: "png-opaque" or a distinct alpha: boolean flag on createCaptureSession, so this workaround (opaque jpeg then transcode) becomes unnecessary? Not to solve here, and the comment does most of the work — just noting that this is the second HF workaround this week that goes through ffmpeg to sidestep an engine mode-name mismatch.

Series note: this is Miguel's sixth open HF PR today (#3091, #3092, #3094, #3096, #3097, and this one). No obvious semantic coordination with the parity refactors — this is docs-tooling scoped, they're runtime/producer — but it does complete a "everything a user sees on the block catalog pages is right" cross-cut that touches Ash's original report end-to-end (renderer fixes + republish + invalidate + canary). LGTM from my side.

Review by Rames D Jusso

…enderer

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.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Pushed 3708a31f7. Thanks both — the canary earned its keep immediately: Render catalog previews went red on the two blocks it renders, with spawnSync ffmpeg ENOENT.

Via, finding 2 — the runner does not have ffmpeg. encodeForWeb has always shelled out to a bare ffmpeg, but this job passes --skip-video, so that path never ran and the poster step was a plain file copy. Moving the poster onto ffmpeg made the job need a binary it had never needed, and ubuntu-latest does not ship one. Added the same apt-get install ffmpeg step preview-regression.yml and the CI integration lane already use.

Rames, sibling renderer / Via finding 4. Fixed rather than deferred — generate-template-previews.ts captures through the same transparent format: "png" mode, so patching one renderer and leaving the other on the broken call just moves the bug. Same two-line change plus the transcode.

Rames, duplicate manifest parse. Folded the three JSON.parse(registry-item.json) reads into one. They had drifted into three different failure behaviours for the same file: one threw, two swallowed. A malformed manifest cannot reach that block anyway — discoverItems parses it unguarded first — so the single reader only absorbs the file being absent, which is what each ?? default already stood for. This also clears the entrySrc Fallow finding and drops prepareProjectDir's count; fallow audit --changed-since origin/main now exits 0 locally, with the remaining findings inherited.

Rames, upload script permissions. Called out in the docstring: the script now needs cloudfront:CreateInvalidation alongside s3:PutObject.

Via finding 1 — regex edges. Agreed on both, and neither occurs today. Worth noting the classifier has a cheap upgrade if it ever gets reused: hasTimeline only needs to be true for a registration that runs at load, so a future version could look for the composition root outside <template> rather than for the string.

Via finding 3 / Via's closing question — the JPEG round-trip. Both point at the same thing, and I agree it is the real fix: format: "png" meaning "transparent" is the defect, and every workaround downstream is paying for it. A transparent?: boolean on createCaptureSession defaulting to format === "png" would preserve every current caller and let both preview scripts ask for an opaque PNG directly, dropping the lossy hop and the ffmpeg dependency this PR just added to the job. I left it out here deliberately: it touches the capture options shared with the render hot path, which is a wider blast radius than a docs-preview fix should carry. Filing it as a follow-up.

Via finding 5 / canary trigger fragility. Noted — if the wrapper is ever extracted to its own file, RENDERER_CHANGED needs that path added.

@miguel-heygen
miguel-heygen merged commit 218eff7 into main Aug 7, 2026
47 checks passed
@miguel-heygen
miguel-heygen deleted the fix-catalog-preview-template-blocks branch August 7, 2026 22:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants