mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(producer): force text-rendering:geometricPrecision so headless-shell matches Chrome
This commit is contained in:
@@ -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 <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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]")]) {
|
||||
|
||||
@@ -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 <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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 `<!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 coalescedHtml = await injectDeterministicFontFaces(
|
||||
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
|
||||
injectTextRenderingRule(
|
||||
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
|
||||
),
|
||||
{ failClosedFontFetch: options.failClosedFontFetch === true },
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user