From b7bd9565830874460c74815a2fc5e5215335f9c9 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 20 May 2026 20:26:03 +0000 Subject: [PATCH] fix(producer): force text-rendering:geometricPrecision so headless-shell matches Chrome --- .../core/src/compiler/htmlBundler.test.ts | 26 ++++++++++ packages/core/src/compiler/htmlBundler.ts | 22 +++++++++ .../src/services/htmlCompiler.test.ts | 47 +++++++++++++++++++ .../producer/src/services/htmlCompiler.ts | 31 +++++++++++- 4 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 2dbbd4e35..1bab57364 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -939,4 +939,30 @@ describe("bundleToSingleHtml", () => { expect(bundled).toContain("/* @import url('./old.css'); */"); expect(bundled).not.toContain(".old { display: none; }"); }); + + // Forces `text-rendering: geometricPrecision` so headless-shell BeginFrame + // renders match full Chrome (which is the snapshot/preview path). See + // `injectTextRenderingRule` in htmlBundler.ts. + it("injects a single text-rendering:geometricPrecision rule into ", async () => { + const dir = makeTempProject({ + "index.html": ` + +t + +
+

Hello

+
+`, + }); + + const bundled = await bundleToSingleHtml(dir); + const { document } = parseHTML(bundled); + const styleEls = document.querySelectorAll("style[data-hyperframes-text-rendering]"); + + expect(styleEls.length).toBe(1); + expect((styleEls[0]?.textContent || "").replace(/\s+/g, "")).toContain( + "html,body,*{text-rendering:geometricPrecision}", + ); + expect(styleEls[0]?.parentElement?.tagName.toLowerCase()).toBe("head"); + }); }); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 1bcbfbbf6..aeb2d9e99 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -509,6 +509,27 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void { } } +/** + * Force subpixel glyph positioning so headless rendering paths + * (chrome-headless-shell with BeginFrame) lay text out identically to full + * Chrome. `text-rendering: auto` resolves to `optimizeSpeed` (integer glyph + * advances) in headless-shell but `geometricPrecision` in full Chrome, which + * shifts line-wrap points and any animation that reads measured text width. + * Mirrors the producer's `injectTextRenderingRule` so bundled previews and + * compiled renders stay byte-aligned. `*` has zero specificity, so authored + * class/id rules still override. + */ +function injectTextRenderingRule(document: Document): void { + const head = document.head; + if (!head) return; + if (document.querySelector("style[data-hyperframes-text-rendering]")) return; + + const styleEl = document.createElement("style"); + styleEl.setAttribute("data-hyperframes-text-rendering", "true"); + styleEl.textContent = "html,body,*{text-rendering:geometricPrecision}"; + head.insertBefore(styleEl, head.firstChild); +} + /** * Concatenate JS chunks safely. Goals: * - Each chunk's last statement is terminated, so joining can't introduce ASI @@ -842,6 +863,7 @@ export async function bundleToSingleHtml( enforceCompositionPixelSizing(document); autoHealMissingCompositionIds(document); coalesceHeadStylesAndBodyScripts(document); + injectTextRenderingRule(document); // Inline textual assets for (const el of [...document.querySelectorAll("[src], [href], [poster], [xlink\\:href]")]) { diff --git a/packages/producer/src/services/htmlCompiler.test.ts b/packages/producer/src/services/htmlCompiler.test.ts index d7daa58c1..642c59ff3 100644 --- a/packages/producer/src/services/htmlCompiler.test.ts +++ b/packages/producer/src/services/htmlCompiler.test.ts @@ -719,3 +719,50 @@ describe("template-wrapped sub-composition media offsets", () => { expect(compiled.html).toContain('var __hfCompId = "scene";'); }); }); + +// ── injectTextRenderingRule (via compileForRender) ───────────────────────── +// +// Forces `text-rendering: geometricPrecision` so chrome-headless-shell +// (BeginFrame) and full Chrome lay text out identically. See +// `injectTextRenderingRule` in htmlCompiler.ts for full context. + +describe("text-rendering rule injection", () => { + it("injects a single geometricPrecision rule into for a full-document composition", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-text-rendering-")); + writeFileSync( + join(projectDir, "index.html"), + ` + +t + +
+

Hello

+
+ +`, + ); + + const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir); + + const { document } = parseHTML(compiled.html); + const styleEls = document.querySelectorAll("style[data-hyperframes-text-rendering]"); + expect(styleEls.length).toBe(1); + expect((styleEls[0]?.textContent || "").replace(/\s+/g, "")).toContain( + "html,body,*{text-rendering:geometricPrecision}", + ); + expect(styleEls[0]?.parentElement?.tagName.toLowerCase()).toBe("head"); + }); + + it("includes geometricPrecision in the fragment-wrap fallback stylesheet", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "hf-text-rendering-frag-")); + // Fragment (no //) — exercises ensureFullDocument. + writeFileSync( + join(projectDir, "index.html"), + `

Hi

`, + ); + + const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir); + + expect(compiled.html.replace(/\s+/g, "")).toContain("text-rendering:geometricPrecision"); + }); +}); diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 8108eab59..892bd8657 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -680,7 +680,32 @@ function ensureFullDocument(html: string): string { // Wrap fragment with a proper document including margin/padding reset. // Without this, Chrome applies default body { margin: 8px } which creates // visible white lines at the edges of rendered video. - return `\n\n\n \n \n\n\n${html}\n\n`; + return `\n\n\n \n \n\n\n${html}\n\n`; +} + +/** + * Force subpixel glyph positioning so chrome-headless-shell (BeginFrame) and + * full Chrome (screenshot fallback) lay text out identically. `text-rendering: + * auto` resolves to `optimizeSpeed` (integer advances) in headless-shell but + * `geometricPrecision` in full Chrome — that ~1% advance-width gap shifts + * line-wrap points and any animation that reads `offsetWidth`. The `*` + * selector has zero specificity, so authored class/id rules still override. + */ +function injectTextRenderingRule(html: string): string { + const { document } = parseHTML(html); + const head = document.querySelector("head"); + if (!head) return html; + + if (document.querySelector("style[data-hyperframes-text-rendering]")) { + return html; + } + + const styleEl = document.createElement("style"); + styleEl.setAttribute("data-hyperframes-text-rendering", "true"); + styleEl.textContent = "html,body,*{text-rendering:geometricPrecision}"; + head.insertBefore(styleEl, head.firstChild); + + return document.toString(); } /** @@ -894,7 +919,9 @@ export async function compileForRender( const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml); const coalescedHtml = await injectDeterministicFontFaces( - coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)), + injectTextRenderingRule( + coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)), + ), { failClosedFontFetch: options.failClosedFontFetch === true }, );