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

This commit is contained in:
James
2026-05-20 18:44:41 -04:00
committed by James Russo
parent 93728d7223
commit b7bd956583
4 changed files with 124 additions and 2 deletions
@@ -939,4 +939,30 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain("/* @import url('./old.css'); */"); expect(bundled).toContain("/* @import url('./old.css'); */");
expect(bundled).not.toContain(".old { display: none; }"); 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 <head>", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html>
<head><title>t</title></head>
<body>
<div data-composition-id="root" data-width="640" data-height="360">
<h1>Hello</h1>
</div>
</body></html>`,
});
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");
});
}); });
+22
View File
@@ -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: * Concatenate JS chunks safely. Goals:
* - Each chunk's last statement is terminated, so joining can't introduce ASI * - Each chunk's last statement is terminated, so joining can't introduce ASI
@@ -842,6 +863,7 @@ export async function bundleToSingleHtml(
enforceCompositionPixelSizing(document); enforceCompositionPixelSizing(document);
autoHealMissingCompositionIds(document); autoHealMissingCompositionIds(document);
coalesceHeadStylesAndBodyScripts(document); coalesceHeadStylesAndBodyScripts(document);
injectTextRenderingRule(document);
// Inline textual assets // Inline textual assets
for (const el of [...document.querySelectorAll("[src], [href], [poster], [xlink\\:href]")]) { for (const el of [...document.querySelectorAll("[src], [href], [poster], [xlink\\:href]")]) {
@@ -719,3 +719,50 @@ describe("template-wrapped sub-composition media offsets", () => {
expect(compiled.html).toContain('var __hfCompId = "scene";'); 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 <head> for a full-document composition", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-text-rendering-"));
writeFileSync(
join(projectDir, "index.html"),
`<!DOCTYPE html>
<html>
<head><title>t</title></head>
<body>
<div data-composition-id="root" data-width="640" data-height="360" data-duration="1">
<h1>Hello</h1>
</div>
</body>
</html>`,
);
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 <html>/<head>/<body>) — exercises ensureFullDocument.
writeFileSync(
join(projectDir, "index.html"),
`<div data-composition-id="root" data-width="640" data-height="360" data-duration="1"><h1>Hi</h1></div>`,
);
const compiled = await compileForRender(projectDir, join(projectDir, "index.html"), projectDir);
expect(compiled.html.replace(/\s+/g, "")).toContain("text-rendering:geometricPrecision");
});
});
+28 -1
View File
@@ -680,7 +680,32 @@ function ensureFullDocument(html: string): string {
// Wrap fragment with a proper document including margin/padding reset. // Wrap fragment with a proper document including margin/padding reset.
// Without this, Chrome applies default body { margin: 8px } which creates // Without this, Chrome applies default body { margin: 8px } which creates
// visible white lines at the edges of rendered video. // visible white lines at the edges of rendered video.
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>`; 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>`;
}
/**
* 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 hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
const coalescedHtml = await injectDeterministicFontFaces( const coalescedHtml = await injectDeterministicFontFaces(
injectTextRenderingRule(
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)), coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
),
{ failClosedFontFetch: options.failClosedFontFetch === true }, { failClosedFontFetch: options.failClosedFontFetch === true },
); );