fix(scripts): render template-only blocks in catalog previews - #3098
Conversation
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
left a comment
There was a problem hiding this comment.
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
-
[NIT]scripts/generate-catalog-previews.ts:173-175— the<template\b[\s\S]*?<\/template>/gistrip 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__timelinesin 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. -
[NIT]scripts/generate-catalog-previews.ts:333-339— matches the existingencodeForWebconvention of calling bareffmpegviaexecFileSync; runner already has ffmpeg on PATH (encodeForWebhas been shipping this way). The workflow'sFFMPEG_BINenv var is a HyperFrames-side pointer, not consulted here — fine as-is. -
[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 forformat: "png"to get an alpha channel out. -
[NOTE]scripts/generate-template-previews.ts:139still callscreateCaptureSession(..., format: "png")with the same engine that forcesbackground-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. -
[NOTE]Fallow audit is red with 7 findings — I read the deltas:discoverItems(line 76),parseArgs(414), andmain(441) are byte-identical to base, and the 19-line clone againstgenerate-template-previews.ts:139is pre-existing scaffolding. The genuinely PR-introduced surface isentrySrc(line 241, minor, CRAP 30.0 at threshold) and a small complexity bump inprepareProjectDir. Not a functional blocker; if Fallow needs to be quieted, hoistingentrySrcand thehasSocialTagIIFEs into areadManifestField(tmpDir, ...)helper would dropprepareProjectDir'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, especiallyRender catalog previewswhich 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
left a comment
There was a problem hiding this comment.
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
entrySrcIIFE at:236-252. Readsregistry-item.jsonand prefersfiles[].targetover the flat source path when the manifest declares one.try/catchswallows manifest-missing/malformed cases cleanly,existsSyncon the resolved target guards the "manifest says X but the mirror wasn't produced" case, fallback toitem.entryFilematches pre-PR behaviour for the anonymous case. This addresses a specific and specific-enough failure mode:../assets/background.jpegfrom acompositions/*.htmlfile 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 commentformat: "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: 95thenffmpegtranscode to.png) preserves the URL the catalog pages reference without changing the format the mode does the wrong thing under.execFileSyncis 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/), andset -euo pipefailat:14will 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 assumess3 syncis enough. - Canary rows in
catalog-previews.yml:71-81.RENDERER_CHANGEDis computed againstscripts/generate-catalog-previews.tsandscripts/registry-target-paths.mjs— the latter is a genuine renderer dependency (generate-catalog-previews.ts:48importsresolveContainedCopiesfrom 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.jsonisJSON.parse'd at:219(guarded byexistsSyncin the outer scope) and again inside theentrySrcIIFE at:243— the two blocks both live underif (!existsSync(join(tmpDir, "index.html"))), so hoisting the parsedmto a shared local (or moving the IIFE inside the earlierif (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_CHANGEDis scoped to the two script files, so if a change lands only in the wrapper HTML string insidegenerate-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.shdocstring at:12says "Requires AWS credentials for the heygen engineering account". The invalidation adds a permission the sync doesn't need (cloudfront:CreateInvalidationalongsides3: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.
…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.
|
Pushed Via, finding 2 — the runner does not have ffmpeg. Rames, sibling renderer / Via finding 4. Fixed rather than deferred — Rames, duplicate manifest parse. Folded the three Rames, upload script permissions. Called out in the docstring: the script now needs 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: 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: Via finding 5 / canary trigger fragility. Noted — if the wrapper is ever extracted to its own file, |
What
Makes
scripts/generate-catalog-previews.tsable 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:
prepareProjectDirclassified 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 viadata-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:
hyperframes add(../assets/background.jpegfromcompositions/). The wrapper mounted the flat source copy at the project root, where that path resolves outside the project, so the desktop backdrop silently vanished.format: "png", which is the engine's transparent capture mode. It injectsbackground-image: none !importanton every[data-composition-id], so any block painting its own backdrop lost it and the poster came out empty with an alpha channel.How
<template>content stripped, so template-only blocks fall through to the wrapper that mounts them properly.data-composition-srcat the mirrored install-layout copy when the manifest declares one, so a block's own relative asset paths resolve.format: "jpeg", quality 95) and transcode to the.pngthe catalog pages already reference. No page or URL changes.upload-docs-images.shnow invalidates the CDN after syncing. Preview URLs are stable and objects are uploadedimmutablewith 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.ymlrenders 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
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-plusrenders light-theme chrome as dark, because its theme object overrideseditor.backgroundbut not the workbench colors. That is authored in the block, predates this change, and is identical in the preview it replaces.