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 },
);