Skip to content

fix(producer): force text-rendering:geometricPrecision so headless-shell matches Chrome#985

Merged
jrusso1020 merged 2 commits into
mainfrom
05-20-fix_producer_force_text-rendering_geometricprecision_so_headless-shell_matches_chrome
May 20, 2026
Merged

fix(producer): force text-rendering:geometricPrecision so headless-shell matches Chrome#985
jrusso1020 merged 2 commits into
mainfrom
05-20-fix_producer_force_text-rendering_geometricprecision_so_headless-shell_matches_chrome

Conversation

@jrusso1020
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 commented May 20, 2026

What

Inject html, body, * { text-rendering: geometricPrecision; } into every compiled composition so chrome-headless-shell (the binary BeginFrame requires) lays out text identically to full Chrome.

Why

The two Chrome binaries silently disagree on the default value of text-rendering: auto:

binary text-rendering: auto resolves to measured advance for the same string
chrome-headless-shell optimizeSpeed (integer-rounded advances) 942.00 px
full Chrome geometricPrecision (subpixel advances) 951.09 px

That ~1% advance-width gap cascades:

  1. Text wraps at different word boundaries in fixed-width containers.
  2. Animations that read element.offsetWidth to compute a transform scale (e.g. cardScale = targetWidth / naturalCardWidth) get a different scale per binary, which then shifts every pixel of the card.
  3. PSNR collapses on text-heavy compositions.

It's an intrinsic per-binary CSS default — not a Chromium-version drift — so no version pin closes it. Forcing geometricPrecision puts both binaries on the same subpixel glyph path that full Chrome was already using.

Ported from the experiment-framework GSAP renderer fix in heygen-com/experiment-framework#37369. There it lifted worst-case PSNR from 12.56 → 24.66 dB and cut SSIM standard deviation ~3.2× across 200 production templates.

Same browser dichotomy applies in hyperframes:

  • packages/engine/src/services/browserManager.ts:43 — BeginFrame mode launches chrome-headless-shell.
  • Screenshot mode falls back to whatever Chrome is installed on the host.
  • hyperframes snapshot and friends also drive chrome-headless-shell via bundleToSingleHtml, so the bundler path needs the same rule for studio/snapshot ↔ producer parity.

How

Two small head-style injections, mirroring the existing injectDeterministicFontFaces pattern.

  • packages/producer/src/services/htmlCompiler.ts
    • New injectTextRenderingRule(html) — inserts <style data-hyperframes-text-rendering="true">html,body,*{text-rendering:geometricPrecision}</style> at the start of <head>. Idempotent.
    • Wired into compileForRender between coalesceHeadStylesAndBodyScripts(...) and injectDeterministicFontFaces(...), so the rule lands in the compiled HTML (and the content-addressed planDir hash) before user CSS is merged.
    • ensureFullDocument's fallback <style> block also gets text-rendering:geometricPrecision appended to the universal selector, so fragment compositions don't bypass the fix.
  • packages/core/src/compiler/htmlBundler.ts
    • Mirror helper called from bundleToSingleHtml after coalesceHeadStylesAndBodyScripts. Studio runs in full Chrome so this is a visual no-op there, but it keeps the bundled HTML byte-aligned with the producer compile output, which is what snapshot/validate/layout (all of which feed bundled HTML into headless renders) need.

Specificity is preserved: * is (0,0,0), so any composition's class/id/element-targeted text-rendering rule still overrides.

Out of scope: residual non-text-layout divergence (SVG filter/shadow/gradient rasterization between the binaries) — same caveat called out in heygen-com/experiment-framework#37369.

Test plan

  • Unit tests added — packages/producer/src/services/htmlCompiler.test.ts (full-doc + fragment paths) and packages/core/src/compiler/htmlBundler.test.ts.
  • bun test packages/producer/src/services/htmlCompiler.test.ts — 38 pass.
  • bunx vitest run packages/core/src/compiler/htmlBundler.test.ts (from packages/core) — 31 pass.
  • bunx oxlint + bunx oxfmt --check on all 4 changed files — clean.
  • bun run --cwd packages/producer build + bun run --cwd packages/core build — green.
  • Producer golden baselines will likely shift (text wraps and scale-from-width effects change). Regenerate via bun run --cwd packages/producer docker:test:update <test-name> per Dockerfile.test (not from host).
  • Optional: re-run a text-heavy regression composition through hyperframes render to confirm PSNR vs. a full-Chrome render.

🤖 Generated with Claude Code

Copy link
Copy Markdown
Collaborator Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

…o-preview-assets

The text-rendering:geometricPrecision rule injected by the previous commit
shifts glyph advances by ~1% on chrome-headless-shell (was optimizeSpeed
under text-rendering:auto). Two fixtures with strict gates tripped:

- distributed/png-sequence: maxFrameFailures=0 byte-identity gate, all 60
  frames now differ. The fixture's own meta.json already documents this
  as the expected response to renderer-pixel changes.
- heygen-promo-preview-assets: minPsnr=30, maxFrameFailures=0; one frame
  dropped to 27.67 dB after the layout shift.

Full local regression run (47 fixtures): 45 passed, only these 2 needed
regeneration — the text-rendering change passes through the rest without
PSNR impact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jrusso1020 jrusso1020 merged commit 129a7e3 into main May 20, 2026
46 checks passed
@jrusso1020 jrusso1020 deleted the 05-20-fix_producer_force_text-rendering_geometricprecision_so_headless-shell_matches_chrome branch May 20, 2026 22:44
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.

2 participants