## What
Adds `landscape-4k` (3840×2160) and `portrait-4k` (2160×3840) presets to `CanvasResolution` and `CANVAS_DIMENSIONS` in `@hyperframes/core`. Foundation for end-to-end 4K rendering support.
## Why
There is no codified way today to mark a composition as 4K. The string union `CanvasResolution = "landscape" | "portrait"` is the only enum used by templates, generators, and stage-zoom math; without 4K members, scaffolds and helpers always emit 1080p dimensions even when the underlying engine + encoder pipeline can already handle larger viewports (Chrome `setViewport`, ffmpeg `libx264`/`libvpx-vp9`/`prores_ks` all scale fine).
This is PR 1 of a 3-PR stack making 4K a first-class option:
1. **PR #660 (this)** — core types/constants + parser detection.
2. **PR #661** — `hyperframes init --resolution 4k` flag to scaffold 4K projects.
3. **PR #662** — byte-budget the frame data-URI cache so 4K renders don't OOM.
## How
- `CanvasResolution` extended additively (no breaking change).
- `CANVAS_DIMENSIONS` gains `landscape-4k` / `portrait-4k` entries.
- `parseResolutionFromHtml` accepts `data-resolution="landscape-4k|portrait-4k"` and infers 4K from `data-composition-width`/`data-composition-height` (long side ≥ 2560 → UHD variant).
- `parseResolutionFromCss` reuses the same dimension-aware classifier so inline `#stage { width/height }` styles are detected too.
- Helper extracted: `resolveResolutionFromDimensions(w, h)`.
- `cli/info.ts` cleanup: replaces a nested `parsed.resolution === "portrait" ? 1080 : 1920` ternary with a `CANVAS_DIMENSIONS[parsed.resolution]` lookup so it stays correct for 4K.
Stage-CSS generators (`templates/base.ts`, `generators/hyperframes.ts`) already index `CANVAS_DIMENSIONS[resolution]`, so they pick up the new presets automatically.
## Test plan
- [x] Unit tests added/updated — 4 new parser tests, 1 expanded constants test
- [x] Manual testing performed — `bun run --cwd packages/core test` (679 pass), `bun run --cwd packages/cli test` (277 pass)
- [ ] Documentation updated (deferred to PR #661 where the user-facing flag lands)
The first dynamic `await import("./render.js")` cold-load takes >5 s on
Windows runners — long enough to blow vitest's default 5 s timeout in
whichever test ran it first. Subsequent imports are <10 ms because the
module is now cached, so only test #1 ever times out.
The downstream failure is more subtle: when test #1 times out, vitest
moves on, but its leaked async function eventually hits the synchronous
`producer.createRenderJob(...)` line and pushes a stale config to
`producerState.createdJobs`. That push lands AFTER test #2's `beforeEach`
clears the array, so test #2's `createdJobs[0]` is the leaked test #1
entry instead of its own. That's why test #2 saw `browserGpuMode: 'software'`
when it expected `'auto'`.
Hoist the import into `beforeAll` (matching the pattern the existing
`parseVariablesArg` and `validateVariablesAgainstProject` describe blocks
in this file already use). Cold-load happens once outside any test's
timeout window, every test stays fast, no leaked promise can corrupt
state.
Failing run: https://github.com/heygen-com/hyperframes/actions/runs/25470257972/job/74732502915
Started failing on main with the merge of #642 (auto-detect-browser-gpu),
which added the "forwards browserGpuMode='auto'" test as test #2.
Drive-by fix: hf#631 (composition flag) merged with two test calls
using `browserGpu: false`, but hf#642 (browserGpuMode auto) merged
shortly after and removed that field from RenderOptions in favour of
the tri-state `browserGpuMode`. Main has been failing typecheck since
hf#642 landed (every PR inherits the failure).
Renaming `browserGpu: false` → `browserGpuMode: "software"` matches
the new shape; both tests still verify what they were written for
(forwards entryFile / omits entryFile to createRenderJob).
The producer already supports `format: "png-sequence"` end-to-end (see
RenderConfig in renderOrchestrator.ts), but the CLI's VALID_FORMAT
validator rejects it before the flag reaches the producer. Surface it
the same way `mov` and `webm` are surfaced.
Behaviour:
- `--format png-sequence` accepted alongside mp4/webm/mov.
- Auto-output path uses no extension (FORMAT_EXT["png-sequence"] = "")
since the producer treats outputPath as a directory of frame_NNNNNN.png.
- `printRenderComplete` sums the contained file sizes when outputPath
is a directory, instead of reporting the platform-dependent inode
size.
- DockerRenderOptions.format type extended; existing buildDockerRunArgs
is unchanged because it forwards the string verbatim.
Tests:
- renderLocal forwards `format: "png-sequence"` to createRenderJob.
- buildDockerRunArgs propagates `--format png-sequence` to the
container.
Docs:
- Rendering guide: format flag table, format comparison table, new
"PNG sequence (no encoding)" section, "How it works" extended.
- CLI package docs: format flag table updated.
After hf#641 inlined the runtime IIFE into every bundle, lint tools
inspecting bundled output (including Abhay's c2v eval) started flagging
empty `catch {}` blocks across the runtime. The source had explanatory
comments inside, but esbuild's minifier strips them — the IIFE ships
~10 visible patterns of `}catch{}` and consumers' linters fire on each.
Each empty catch is intentional best-effort error swallowing —
postMessage to a parent frame that may not exist, `media.play()` /
`pause()` that throw under autoplay restrictions, timeline `seek()` on
a disposed timeline, anime.js / lottie feature detection on hosts that
don't load those libraries, etc. The right behaviour stays "tried,
didn't work, move on", but doing it visibly improves three things:
- lint clean: helper call is a real statement; no `no-empty` warnings
survive minification
- debuggable: flip `window.__hfDebug = true` in DevTools to see every
swallow site with `console.debug` (silent in prod by default)
- observable: studio / embeddings can install
`window.__hf.onSwallowed = handler` to collect runtime swallow
events without polluting the page console
Implementation: `packages/core/src/runtime/diagnostics.ts` exports
`swallow(label, err?)`. 41 catch sites across 12 runtime files
converted via mechanical pass (auto-generated `runtime.<module>.siteN`
labels — labels can be tightened site-by-site as a follow-up; the
shape of the change is what matters here).
Verification:
- core 674/674 (incl. 6 new diagnostics tests covering silent default,
__hfDebug logging, legacy __HYPERFRAMES_DEBUG flag, handler hook,
handler-throws-doesn't-recurse, both-active)
- typecheck clean
- format / lint clean
- runtime IIFE rebuilds successfully (`bun run build:hyperframes-runtime`)
Refs Abhay's c2v eval — bundler artefacts now lint-clean with the
runtime body inlined.
## What
Adds a new guide at `docs/guides/deploy.mdx` covering the two official one-click deployment templates:
- [heygen-com/hyperframes-vercel-template](https://github.com/heygen-com/hyperframes-vercel-template) (Vercel Sandbox + Vercel Blob)
- [heygen-com/hyperframes-cloudflare-template](https://github.com/heygen-com/hyperframes-cloudflare-template) (Cloudflare Containers + R2)
Wires the new page into `docs.json` under Guides, slotted right after `guides/rendering` (logical follow-on: render locally → deploy to the cloud).
## Why
The two templates currently only exist as GitHub READMEs, so they're invisible to anyone reading the docs site. New users who want a hosted preview + render endpoint have no entry point in the docs to discover them.
## How
Single guide with:
- A comparison table (compute / storage / deploy button) so readers can pick at a glance.
- `<Tabs>` for per-platform details (deploy button, what you get, performance, pricing, "why this primitive").
- Shared architecture diagram and the common "pre-baked renderer" cost pattern.
- "Swapping the composition" steps that work for both templates.
- "When to use a template vs. roll your own" framing for queues, multi-tenant, self-hosted.
Single-page approach (vs. one page per template) chosen because content overlaps heavily — easier to discover and lower maintenance until more templates land.
## Test plan
- [x] No code changes — docs only
- [x] `bunx oxfmt --check` and `bunx oxlint` pass on changed files (no rules apply to `.mdx` / `.json` here)
- [ ] Render the docs site locally to verify nav placement and Mintlify components (`<Tabs>`, `<Steps>`, `<CardGroup>`, `<Note>`) render correctly
- [ ] Verify the Vercel and Cloudflare deploy buttons in the page open the correct template URLs
* feat(cli): add --composition flag to render specific compositions
Expose the existing entryFile config in the producer through
a new --composition / -c CLI flag. This lets users render
individual composition files without restructuring their project:
hyperframes render -c compositions/intro.html -o intro.mp4
The flag validates the file exists before starting the render,
threads through both local and Docker render paths, and is
documented in the CLI help, examples, and docs.
* fix(cli): address PR review — path traversal guard, forward tests, tripwire
- Add path-containment check mirroring hyperframeLint.ts: reject
--composition paths that escape the project directory
- Normalize leading ./ from composition paths for clean render plan output
- Improve error message: suggest .html file path instead of compositions command
- Add description note about <template> sub-composition constraint
- Add render.test.ts: entryFile forwarded to createRenderJob (forward + omit)
- Update dockerRunArgs tripwire test with entryFile coverage
Remove dollar amounts, render-time figures, and credit allowances from the
Vercel/Cloudflare tabs. These were sourced from the template READMEs but go
stale fast (pricing changes) and are load-bearing on a single composition's
quirks (perf isn't proportional to duration). Keep qualitative framing and
link out to the canonical pricing pages instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Reword frontmatter description to describe capability, not providers
- Soften "deploy in one click" — Cloudflare requires Workers Paid plan
- Qualify Cloudflare ~25s perf number as a local-Docker measurement
on a 6-vCPU host, not a standard-4 production figure
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the two official one-click deployment templates
(heygen-com/hyperframes-vercel-template, heygen-com/hyperframes-cloudflare-template)
in the docs site. Previously they only existed as GitHub READMEs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(player): add volume/mute controls to the player
Adds a mute toggle button and volume slider to the controls bar,
positioned between the time display and speed selector. The slider
expands on hover for a compact default footprint.
- `volume` attribute/property (0–1, clamped) with `volumechange` event
- `muted` attribute now syncs to the controls UI (icon updates)
- Three volume icons: high, low, muted — updates reactively
- Volume forwarded to parent-frame audio proxies and iframe runtime
via `set-volume` postMessage control
- 9 new tests covering volume clamping, events, controls rendering,
mute toggle, and iframe message forwarding
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(player): wire set-volume through runtime bridge + address review feedback
Addresses the blocker from PR review: the iframe runtime now handles
`set-volume` messages end-to-end (types → bridge → init → media sync).
Runtime side:
- Add `set-volume` to RuntimeBridgeControlAction union
- Add `volume` field to RuntimeBridgeControlMessage
- Handle `set-volume` in bridge.ts with [0,1] clamping
- Store bridgeVolume in RuntimeState, apply to media elements
- syncRuntimeMedia composes userVolume × clip author volume
Player side:
- Muted toggle now dispatches `volumechange` (HTML5 spec compliance)
- Volume slider auto-unmutes when scrubbed above 0 while muted
- Touch support on volume slider (touchstart/move/end)
Tests: 5 new (3 bridge, 2 media)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(player): add ARIA keyboard controls, fix icon collision and clipVolume parity
- Volume slider: role="slider", aria-label, aria-valuemin/max/now,
tabindex=0, arrow key support (5% steps, auto-unmutes)
- Volume=0 unmuted now shows low-volume icon instead of muted icon
- Fix clipVolume divergence: init.ts uses Number.isFinite() matching
media.ts semantics (preserves data-volume="0")
- 3 new tests: ARIA attributes, volumechange on mute, icon collision
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(player): replay from start when play is pressed after video ends
When a non-looping composition reaches its end, pressing play again
had no effect because the playhead stayed at the final frame. Now
play() detects the ended state and seeks to 0 before resuming.
* chore: fix pre-existing format issues in registry files
Two files on main fail `bun run format:check`:
- `registry/registry.json` (missing trailing newline)
- `registry/blocks/vfx-liquid-glass/vfx-liquid-glass.html` (whitespace
+ quote-style)
Local format-check on the auto-detect-browser-gpu branch only flagged
these once rebased onto current main (post-#647 / v0.5.2). Pure
whitespace fix; no semantic change.
Three follow-ups from Vai's staff-eng review:
1. Concurrent-probe race (real bug): the parallel coordinator runs N
workers via Promise.all, so `--workers 4` on a no-GPU host fired 4
simultaneous probe Chromes — each paying the same 240 ms launch cost.
Cache the *Promise* (not the resolved value): first caller assigns
the in-flight Promise, every other concurrent caller awaits the same
one. Verified with a new test asserting all concurrent callers get
the identical Promise reference.
2. Stale rendering.md (lines 23, 29): user-visible contract said
"browser GPU enabled by default", which was wrong post-auto. Now
describes the auto / hardware / software trichotomy explicitly.
3. Silent fallback: auto-mode produced no output, so a regression to
"always falls back to software even with GPU present" would have
been invisible in production logs. Added a single stderr line per
process when the probe resolves: `[hyperframes] browserGpuMode auto
→ <mode> (<reason>)`. Cache hits don't re-log.
Verification:
- Engine 536/536 (incl. new concurrent-dedup test asserting Promise
reference equality across simultaneous callers)
- CLI 256/256
- Format / lint / typecheck clean
Initial PR carried a backwards-compat shim where RenderOptions had both
`browserGpu?: boolean` (for docker) and `browserGpuMode?` (for local).
Since renderLocal/renderDocker have no external callers, simplify to a
single field. The boolean → docker-args conversion now happens inline at
the one site that needs it (`browserGpu: options.browserGpuMode === "hardware"`
when handing off to dockerRunArgs).
No behaviour change. 535/535 engine + 256/256 CLI still pass.
When the host doesn't have a usable GPU (CI containers, eval rigs without
GPU passthrough, dev VMs), Chrome's hardware-mode WebGL flags
(`--use-gl=egl/metal/d3d11`) silently leave WebGL unavailable —
`getContext("webgl")` returns null, three.js' WebGLRenderer dies, the
canvas stays black. Surfaced today by Abhay's c2v-eval failing on a
docker render of an hf bundle that uses three.js + a custom fragment
shader.
The fix that's been there: `--use-gl=angle --use-angle=swiftshader` (CPU
software WebGL, ~5-50× slower but pixel-identical). The engine already
exposed `browserGpuMode: "software"` for this. The gap was discovery —
users had to know to pass `--no-browser-gpu` on no-GPU hosts.
This change adds `browserGpuMode: "auto"` (now the CLI default for local
renders): on first launch in the process, probe Chrome with hardware
args, check `canvas.getContext("webgl") !== null`, cache the result.
~1-2 s on first render, free on every subsequent render in the same
worker. Hardware GPUs keep their fast path; no-GPU hosts get SwiftShader
without ceremony.
Behaviour matrix:
- No flag, no env, local → "auto" (NEW default)
- `--browser-gpu` → "hardware" (force; errors if no GPU)
- `--no-browser-gpu` → "software" (force SwiftShader)
- `PRODUCER_BROWSER_GPU_MODE` → "hardware" / "software" / "auto" / unset
- Docker mode → forced "software" (unchanged)
Engine-config default stays "software" (conservative for embedders); the
"auto" default lives in the CLI's `resolveBrowserGpuForCli` so producer
embedders aren't surprised by a probe-on-launch.
Also adds `--enable-unsafe-swiftshader` to the software flag set —
Chrome 120+ deprecated implicit SwiftShader fallback and emits a
deprecation warning unless the flag is set explicitly. Despite the
"unsafe" name this is exactly the pre-deprecation behaviour; the rename
is about Chrome's threat model on the open web, not about the rendering
itself.
Verification:
- Engine 535/535 + CLI 256/256 (incl. new probe tests + tri-state CLI test)
- Empirical: probe on this no-GPU devbox returns "software" in 240 ms,
cached 0 ms on subsequent calls
- Format / lint / typecheck clean across all packages
Refs the Abhay/Slack thread on c2v-eval rendering without a GPU node.
The add command's tag fallback (e.g. `hyperframes add html-in-canvas`)
uses the same 24h cached manifest as single-item lookups. When new items
are added to the registry, the stale cache returns an incomplete item
list, causing tag resolution to find zero matches.
Pass skipCache: true in the tag fallback path so it always fetches the
latest manifest from the registry.
* docs: group VFX blocks under HTML-in-Canvas, captions under Captions in sidebar
* docs: add HTML-in-Canvas guide, Chrome flag disclaimer, remove captions
- Add docs/guides/html-in-canvas.mdx — comprehensive guide covering the
API, feature detection, re-capture patterns, and catalog blocks
- Add Chrome flag Warning banner to every HTML-in-Canvas block page
- Remove captions blocks from registry (will ship separately)
- Add html-in-canvas guide to docs navigation (top of Guides section)
* feat: update liquid glass, portal, shatter with HyperFrames branding
Replace generic placeholder content with HyperFrames-themed text:
- Liquid Glass: 'Ship videos 10x faster' with stats and gradient text
- Portal: 'Write HTML / Render Video' with HyperFrames nav
- Shatter: 'HTML is Video' with render speed/file size metrics
Re-rendered and uploaded preview videos to S3.
Merges main's refactored capture (CaptureSceneOptions, forceVisible,
stabilizeTransformedBoxShadows, foreignObjectRendering fallback) with
our HTML-in-Canvas drawElementImage capture path. The native capture
tries first and falls back to html2canvas on failure.
hyperframes add now auto-detects whether the argument is a single
block name or a tag. No --tag flag needed:
hyperframes add html-in-canvas # installs all 7 html-in-canvas blocks
hyperframes add captions # installs all 5 caption blocks
hyperframes add vfx-shatter # installs one block
When the name doesn't match a registry item, the CLI falls back to
tag-based resolution and bulk-installs all matching blocks.
Also fixes tag assignments:
- VFX blocks tagged "html-in-canvas" (not "vfx")
- Caption blocks tagged "captions" only (no html-in-canvas)