Skip to content

Commit b7bd956

Browse files
committed
fix(producer): force text-rendering:geometricPrecision so headless-shell matches Chrome
1 parent 93728d7 commit b7bd956

4 files changed

Lines changed: 124 additions & 2 deletions

File tree

packages/core/src/compiler/htmlBundler.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -939,4 +939,30 @@ describe("bundleToSingleHtml", () => {
939939
expect(bundled).toContain("/* @import url('./old.css'); */");
940940
expect(bundled).not.toContain(".old { display: none; }");
941941
});
942+
943+
// Forces `text-rendering: geometricPrecision` so headless-shell BeginFrame
944+
// renders match full Chrome (which is the snapshot/preview path). See
945+
// `injectTextRenderingRule` in htmlBundler.ts.
946+
it("injects a single text-rendering:geometricPrecision rule into <head>", async () => {
947+
const dir = makeTempProject({
948+
"index.html": `<!doctype html>
949+
<html>
950+
<head><title>t</title></head>
951+
<body>
952+
<div data-composition-id="root" data-width="640" data-height="360">
953+
<h1>Hello</h1>
954+
</div>
955+
</body></html>`,
956+
});
957+
958+
const bundled = await bundleToSingleHtml(dir);
959+
const { document } = parseHTML(bundled);
960+
const styleEls = document.querySelectorAll("style[data-hyperframes-text-rendering]");
961+
962+
expect(styleEls.length).toBe(1);
963+
expect((styleEls[0]?.textContent || "").replace(/\s+/g, "")).toContain(
964+
"html,body,*{text-rendering:geometricPrecision}",
965+
);
966+
expect(styleEls[0]?.parentElement?.tagName.toLowerCase()).toBe("head");
967+
});
942968
});

packages/core/src/compiler/htmlBundler.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,27 @@ function coalesceHeadStylesAndBodyScripts(document: Document): void {
509509
}
510510
}
511511

512+
/**
513+
* Force subpixel glyph positioning so headless rendering paths
514+
* (chrome-headless-shell with BeginFrame) lay text out identically to full
515+
* Chrome. `text-rendering: auto` resolves to `optimizeSpeed` (integer glyph
516+
* advances) in headless-shell but `geometricPrecision` in full Chrome, which
517+
* shifts line-wrap points and any animation that reads measured text width.
518+
* Mirrors the producer's `injectTextRenderingRule` so bundled previews and
519+
* compiled renders stay byte-aligned. `*` has zero specificity, so authored
520+
* class/id rules still override.
521+
*/
522+
function injectTextRenderingRule(document: Document): void {
523+
const head = document.head;
524+
if (!head) return;
525+
if (document.querySelector("style[data-hyperframes-text-rendering]")) return;
526+
527+
const styleEl = document.createElement("style");
528+
styleEl.setAttribute("data-hyperframes-text-rendering", "true");
529+
styleEl.textContent = "html,body,*{text-rendering:geometricPrecision}";
530+
head.insertBefore(styleEl, head.firstChild);
531+
}
532+
512533
/**
513534
* Concatenate JS chunks safely. Goals:
514535
* - Each chunk's last statement is terminated, so joining can't introduce ASI
@@ -842,6 +863,7 @@ export async function bundleToSingleHtml(
842863
enforceCompositionPixelSizing(document);
843864
autoHealMissingCompositionIds(document);
844865
coalesceHeadStylesAndBodyScripts(document);
866+
injectTextRenderingRule(document);
845867

846868
// Inline textual assets
847869
for (const el of [...document.querySelectorAll("[src], [href], [poster], [xlink\\:href]")]) {

packages/producer/src/services/htmlCompiler.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -719,3 +719,50 @@ describe("template-wrapped sub-composition media offsets", () => {
719719
expect(compiled.html).toContain('var __hfCompId = "scene";');
720720
});
721721
});
722+
723+
// ── injectTextRenderingRule (via compileForRender) ─────────────────────────
724+
//
725+
// Forces `text-rendering: geometricPrecision` so chrome-headless-shell
726+
// (BeginFrame) and full Chrome lay text out identically. See
727+
// `injectTextRenderingRule` in htmlCompiler.ts for full context.
728+
729+
describe("text-rendering rule injection", () => {
730+
it("injects a single geometricPrecision rule into <head> for a full-document composition", async () => {
731+
const projectDir = mkdtempSync(join(tmpdir(), "hf-text-rendering-"));
732+
writeFileSync(
733+
join(projectDir, "index.html"),
734+
`<!DOCTYPE html>
735+
<html>
736+
<head><title>t</title></head>
737+
<body>
738+
<div data-composition-id="root" data-width="640" data-height="360" data-duration="1">
739+
<h1>Hello</h1>
740+
</div>
741+
</body>
742+
</html>`,
743+
);
744+
745+
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
746+
747+
const { document } = parseHTML(compiled.html);
748+
const styleEls = document.querySelectorAll("style[data-hyperframes-text-rendering]");
749+
expect(styleEls.length).toBe(1);
750+
expect((styleEls[0]?.textContent || "").replace(/\s+/g, "")).toContain(
751+
"html,body,*{text-rendering:geometricPrecision}",
752+
);
753+
expect(styleEls[0]?.parentElement?.tagName.toLowerCase()).toBe("head");
754+
});
755+
756+
it("includes geometricPrecision in the fragment-wrap fallback stylesheet", async () => {
757+
const projectDir = mkdtempSync(join(tmpdir(), "hf-text-rendering-frag-"));
758+
// Fragment (no <html>/<head>/<body>) — exercises ensureFullDocument.
759+
writeFileSync(
760+
join(projectDir, "index.html"),
761+
`<div data-composition-id="root" data-width="640" data-height="360" data-duration="1"><h1>Hi</h1></div>`,
762+
);
763+
764+
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
765+
766+
expect(compiled.html.replace(/\s+/g, "")).toContain("text-rendering:geometricPrecision");
767+
});
768+
});

packages/producer/src/services/htmlCompiler.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -680,7 +680,32 @@ function ensureFullDocument(html: string): string {
680680
// Wrap fragment with a proper document including margin/padding reset.
681681
// Without this, Chrome applies default body { margin: 8px } which creates
682682
// visible white lines at the edges of rendered video.
683-
return `<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n <style>*{margin:0;padding:0;box-sizing:border-box}body{overflow:hidden;background:#000}</style>\n</head>\n<body style="margin:0;overflow:hidden">\n${html}\n</body>\n</html>`;
683+
return `<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n <style>*{margin:0;padding:0;box-sizing:border-box;text-rendering:geometricPrecision}body{overflow:hidden;background:#000}</style>\n</head>\n<body style="margin:0;overflow:hidden">\n${html}\n</body>\n</html>`;
684+
}
685+
686+
/**
687+
* Force subpixel glyph positioning so chrome-headless-shell (BeginFrame) and
688+
* full Chrome (screenshot fallback) lay text out identically. `text-rendering:
689+
* auto` resolves to `optimizeSpeed` (integer advances) in headless-shell but
690+
* `geometricPrecision` in full Chrome — that ~1% advance-width gap shifts
691+
* line-wrap points and any animation that reads `offsetWidth`. The `*`
692+
* selector has zero specificity, so authored class/id rules still override.
693+
*/
694+
function injectTextRenderingRule(html: string): string {
695+
const { document } = parseHTML(html);
696+
const head = document.querySelector("head");
697+
if (!head) return html;
698+
699+
if (document.querySelector("style[data-hyperframes-text-rendering]")) {
700+
return html;
701+
}
702+
703+
const styleEl = document.createElement("style");
704+
styleEl.setAttribute("data-hyperframes-text-rendering", "true");
705+
styleEl.textContent = "html,body,*{text-rendering:geometricPrecision}";
706+
head.insertBefore(styleEl, head.firstChild);
707+
708+
return document.toString();
684709
}
685710

686711
/**
@@ -894,7 +919,9 @@ export async function compileForRender(
894919
const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
895920

896921
const coalescedHtml = await injectDeterministicFontFaces(
897-
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
922+
injectTextRenderingRule(
923+
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
924+
),
898925
{ failClosedFontFetch: options.failClosedFontFetch === true },
899926
);
900927

0 commit comments

Comments
 (0)