mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
perf(core): a bundled film inlines each shared font once instead of once per composition (#4643)
* perf(core): a bundled film inlines each font once instead of once per composition * fix(core): font dedupe keeps the last copy, so the rule the browser uses never changes * fix(core): font dedupe merges only exact copies, from styles that always apply A style postcss cannot parse ships as authored instead of failing the bundle. The key keeps !important and whitespace inside quoted family names. Styles behind a media query, a non-CSS type, <noscript> or <svg> neither lose a copy nor keep one. * perf(core): font dedupe runs before inlining, so bundling is no slower Deduping after inlining made postcss parse every inlined font: a three-scene fixture went from about 40 ms to about 450 ms. The same url text inlines to the same bytes, so deduping first gives the same output. * fix(core): a scene-part bundle keeps each scene's own font copy, so a scene still swaps alone * chore(core): shorten the font dedupe comment * chore(core): trim the font dedupe comments to keep the file's comment share
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import {
|
||||
buildVariablesByCompScript,
|
||||
dedupeFontFaceRules,
|
||||
scopeCssToComposition,
|
||||
wrapInlineScriptWithErrorBoundary,
|
||||
scopedModulePrelude,
|
||||
@@ -1181,3 +1182,44 @@ describe("wrapInlineScriptWithErrorBoundary — <script> breakout", () => {
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dedupeFontFaceRules", () => {
|
||||
const face = (display: string) =>
|
||||
`@font-face { font-family: "Brand"; src: url(data:font/woff2;base64,AA); font-display: ${display}; }`;
|
||||
|
||||
it("keeps the last copy, so a different rule declared in between never takes over", () => {
|
||||
const [first, middle, last] = dedupeFontFaceRules([face("swap"), face("block"), face("swap")]);
|
||||
expect(first).not.toContain("@font-face");
|
||||
expect(middle).toContain("font-display: block");
|
||||
expect(last).toContain("font-display: swap");
|
||||
});
|
||||
|
||||
it("keeps two rules whose repeated src lines come in a different order", () => {
|
||||
const a = `@font-face { font-family: "Brand"; src: url(a.woff); src: url(b.woff2); }`;
|
||||
const b = `@font-face { font-family: "Brand"; src: url(b.woff2); src: url(a.woff); }`;
|
||||
expect(dedupeFontFaceRules([a, b])).toEqual([a, b]);
|
||||
});
|
||||
|
||||
it("leaves unparseable style text as authored and never keeps a copy from it", () => {
|
||||
const broken = `${face("swap")} a { color: red`;
|
||||
expect(dedupeFontFaceRules([face("swap"), broken])).toEqual([face("swap"), broken]);
|
||||
});
|
||||
|
||||
it("keeps an !important src apart from a plain one", () => {
|
||||
const a = `@font-face { font-family: "Brand"; src: url(a.woff2) !important; }`;
|
||||
const b = `@font-face { font-family: "Brand"; src: url(a.woff2); }`;
|
||||
expect(dedupeFontFaceRules([a, b])).toEqual([a, b]);
|
||||
});
|
||||
|
||||
it("keeps quoted family names that differ only in inner spaces", () => {
|
||||
const a = `@font-face { font-family: "Brand Sans"; src: url(a.woff2); }`;
|
||||
const b = `@font-face { font-family: "Brand Sans"; src: url(a.woff2); }`;
|
||||
expect(dedupeFontFaceRules([a, b])).toEqual([a, b]);
|
||||
});
|
||||
|
||||
it("treats a src list wrapped over lines as the same rule", () => {
|
||||
const a = `@font-face { font-family: "Brand"; src: url(a.woff2),\n url(b.woff); }`;
|
||||
const b = `@font-face { font-family: "Brand"; src: url(a.woff2), url(b.woff); }`;
|
||||
expect(dedupeFontFaceRules([a, b])[0]).not.toContain("@font-face");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,6 +256,55 @@ export function scopeCssToComposition(
|
||||
return root.toResult({ map: false }).css;
|
||||
}
|
||||
|
||||
function isFontFaceAtRule(node: { type: string; name?: string }): node is AtRule {
|
||||
return node.type === "atrule" && (node as AtRule).name.toLowerCase() === "font-face";
|
||||
}
|
||||
|
||||
function fontFaceKey(atRule: AtRule): string {
|
||||
const decls: string[] = [];
|
||||
atRule.walkDecls((decl) => {
|
||||
// Collapse whitespace outside quoted strings only: "A B" and "A B" name different families.
|
||||
const value = decl.value.replace(
|
||||
/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|\s+/g,
|
||||
(_m, str) => str ?? " ",
|
||||
);
|
||||
decls.push(
|
||||
`${decl.prop.trim().toLowerCase()}:${value.trim()}${decl.important ? "!important" : ""}`,
|
||||
);
|
||||
});
|
||||
return decls.join(";");
|
||||
}
|
||||
|
||||
/** Drops repeats of an identical `@font-face` across the given style texts, keeping the last copy:
|
||||
* the last matching rule is the one the browser uses, so a rule in between never gains precedence. */
|
||||
export function dedupeFontFaceRules(styleTexts: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
return [...styleTexts]
|
||||
.reverse()
|
||||
.map((css) => {
|
||||
if (!css || !/@font-face/i.test(css)) return css;
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
root = postcss.parse(css);
|
||||
} catch {
|
||||
return css; // unparseable text ships as authored and takes no part
|
||||
}
|
||||
let changed = false;
|
||||
for (const node of [...(root.nodes ?? [])].reverse()) {
|
||||
if (!isFontFaceAtRule(node)) continue;
|
||||
const key = fontFaceKey(node);
|
||||
if (seen.has(key)) {
|
||||
node.remove();
|
||||
changed = true;
|
||||
} else {
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
return changed ? root.toResult({ map: false }).css : css;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a value as a JS literal safe to emit inside a `<script>` element.
|
||||
*
|
||||
|
||||
@@ -1545,6 +1545,110 @@ describe("bundleToSingleHtml", () => {
|
||||
expect(bundled).not.toContain("data:font/woff2");
|
||||
});
|
||||
|
||||
it("inlines a font shared by two compositions once, keeping a rule that differs in weight", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head></head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div
|
||||
data-composition-id="hero"
|
||||
data-composition-src="compositions/hero.html"
|
||||
data-start="0"
|
||||
data-duration="2"></div>
|
||||
<div
|
||||
data-composition-id="outro"
|
||||
data-composition-src="compositions/outro.html"
|
||||
data-start="2"
|
||||
data-duration="2"></div>
|
||||
<div
|
||||
data-composition-id="bold"
|
||||
data-composition-src="compositions/bold.html"
|
||||
data-start="4"
|
||||
data-duration="2"></div>
|
||||
</div>
|
||||
<script>window.__timelines={};</script>
|
||||
</body></html>`,
|
||||
"compositions/hero.html": `<template id="hero-template">
|
||||
<div data-composition-id="hero" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Brand Sans";
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
src: url("../fonts/brand.woff2") format("woff2");
|
||||
}
|
||||
</style>
|
||||
<p>Hero</p>
|
||||
</div>
|
||||
</template>`,
|
||||
// A separate composition declaring the byte-identical @font-face rule:
|
||||
// its bytes must ship once, not once per composition that repeats it.
|
||||
"compositions/outro.html": `<template id="outro-template">
|
||||
<div data-composition-id="outro" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Brand Sans";
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
src: url("../fonts/brand.woff2") format("woff2");
|
||||
}
|
||||
</style>
|
||||
<p>Outro</p>
|
||||
</div>
|
||||
</template>`,
|
||||
// Same family and src, but a different font-weight: a genuinely
|
||||
// different rule that must survive dedupe untouched.
|
||||
"compositions/bold.html": `<template id="bold-template">
|
||||
<div data-composition-id="bold" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Brand Sans";
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
src: url("../fonts/brand.woff2") format("woff2");
|
||||
}
|
||||
</style>
|
||||
<p>Bold</p>
|
||||
</div>
|
||||
</template>`,
|
||||
"fonts/brand.woff2": "brand-font-bytes",
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
const fontFaceRules = bundled.match(/@font-face\s*{[^}]*}/g) ?? [];
|
||||
const rulesByWeight = (weight: string) =>
|
||||
fontFaceRules.filter((rule) => rule.includes(`font-weight: ${weight};`));
|
||||
|
||||
expect(fontFaceRules).toHaveLength(2);
|
||||
expect(rulesByWeight("400")).toHaveLength(1);
|
||||
expect(rulesByWeight("700")).toHaveLength(1);
|
||||
expect(bundled.split(inlinedAs("font/woff2", "brand-font-bytes")).length - 1).toBe(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a media query", `<style media="print">FACE</style>`],
|
||||
["a non-CSS type", `<style type="text/x-template">FACE</style>`],
|
||||
["<noscript>", `<noscript><style>FACE</style></noscript>`],
|
||||
["<svg>", `<svg><style>FACE</style></svg>`],
|
||||
])("keeps a font's always-applied copy when the later copy sits behind %s", async (_, later) => {
|
||||
const face = `@font-face { font-family: "Brand"; src: url("fonts/brand.woff2") format("woff2"); }`;
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head><style>${face}</style></head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
${later.replace("FACE", face)}
|
||||
<p>Hi</p>
|
||||
</div>
|
||||
<script>window.__timelines={};</script>
|
||||
</body></html>`,
|
||||
"fonts/brand.woff2": "brand-font-bytes",
|
||||
});
|
||||
|
||||
const { document } = parseHTML(await bundleToSingleHtml(dir));
|
||||
const headCss = [...document.querySelectorAll("head style")].map((s) => s.textContent).join("");
|
||||
expect(headCss).toContain("@font-face");
|
||||
});
|
||||
|
||||
it("leaves an oversized asset relative and warns rather than inlining it", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
@@ -2094,6 +2198,30 @@ describe("bundleToSingleHtml sceneParts", () => {
|
||||
const partsOf = (doc: Document, scene: string) =>
|
||||
[...doc.querySelectorAll(`[data-hf-scene="${scene}"]`)].map((el) => el.tagName.toLowerCase());
|
||||
|
||||
it("keeps each scene's own copy of a shared @font-face, so one scene still swaps alone", async () => {
|
||||
const face = `@font-face { font-family: "Brand"; src: url('assets/fonts/brand.woff2'); }`;
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head></head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080" data-duration="4">
|
||||
<div data-composition-id="a" data-composition-src="compositions/a.html" data-start="0" data-duration="2"></div>
|
||||
<div data-composition-id="b" data-composition-src="compositions/b.html" data-start="2" data-duration="2"></div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
"compositions/a.html": `<template id="a-template"><div data-composition-id="a"><style>${face}</style><p>A</p></div></template>`,
|
||||
"compositions/b.html": `<template id="b-template"><div data-composition-id="b"><style>${face}</style><p>B</p></div></template>`,
|
||||
});
|
||||
const doc = parseHTML(
|
||||
await bundleToSingleHtml(dir, { sceneParts: true, inlineAssets: false }),
|
||||
).document;
|
||||
for (const scene of ["a", "b"]) {
|
||||
const css = [...doc.querySelectorAll(`style[data-hf-scene="${scene}"]`)]
|
||||
.map((el) => el.textContent ?? "")
|
||||
.join("\n");
|
||||
expect(css).toContain("@font-face");
|
||||
}
|
||||
});
|
||||
|
||||
it("tags each top-level scene's host, styles and scripts, with nested scenes in their parent's parts", async () => {
|
||||
const doc = parseHTML(await bundleToSingleHtml(film(), { sceneParts: true })).document;
|
||||
// The nested scene is reached after b, so a's parts come in two runs around b's.
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
// rewriteSubCompPaths functions are used by inlineSubCompositions (shared module)
|
||||
import {
|
||||
buildVariablesByCompScript,
|
||||
dedupeFontFaceRules,
|
||||
scopeCssToComposition,
|
||||
wrapInlineScriptWithErrorBoundary,
|
||||
wrapScopedCompositionScript,
|
||||
@@ -702,6 +703,15 @@ function placeSceneStylesLikeRender(document: Document): void {
|
||||
first.before(holder);
|
||||
}
|
||||
|
||||
function isAlwaysAppliedStyle(el: Element): boolean {
|
||||
const type = el.getAttribute("type")?.trim().toLowerCase();
|
||||
return (
|
||||
!el.hasAttribute("media") &&
|
||||
(!type || type === "text/css") &&
|
||||
!el.closest("template, noscript, svg")
|
||||
);
|
||||
}
|
||||
|
||||
type PartRun<T> = { scene?: string; chunks: T[] };
|
||||
|
||||
function pushRun<T>(runs: PartRun<T>[], scene: string | undefined, chunk: T): void {
|
||||
@@ -1317,6 +1327,14 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi
|
||||
if (srcset)
|
||||
el.setAttribute("srcset", rewriteSrcsetWithInlinedAssets(srcset, projectDir, inlineAssets));
|
||||
}
|
||||
// Before inlining, so postcss reads paths not font bytes; scene parts keep copies to swap alone.
|
||||
if (!options?.sceneParts) {
|
||||
const liveStyles = [...document.querySelectorAll("style")].filter(isAlwaysAppliedStyle);
|
||||
const dedupedStyles = dedupeFontFaceRules(liveStyles.map((el) => el.textContent || ""));
|
||||
liveStyles.forEach((el, i) => {
|
||||
el.textContent = dedupedStyles[i] ?? "";
|
||||
});
|
||||
}
|
||||
for (const styleEl of document.querySelectorAll("style")) {
|
||||
styleEl.textContent = rewriteCssUrlsWithInlinedAssets(
|
||||
styleEl.textContent || "",
|
||||
|
||||
Reference in New Issue
Block a user