diff --git a/packages/cli/src/capture/assetDownloader.test.ts b/packages/cli/src/capture/assetDownloader.test.ts
index 715965485d..b29e7d00ab 100644
--- a/packages/cli/src/capture/assetDownloader.test.ts
+++ b/packages/cli/src/capture/assetDownloader.test.ts
@@ -351,6 +351,7 @@ describe("drop counts — why a referenced asset is not in the capture", () => {
expect(css).toContain("assets/fonts/font-0.woff2");
expect(drops).toEqual({
"size-floor": 0,
+ "size-ceiling": 0,
"budget-exhausted": 0,
"cap-reached": 0,
unavailable: 0,
@@ -421,6 +422,32 @@ describe("drop counts — why a referenced asset is not in the capture", () => {
expect(drops["cap-reached"]).toBe(4);
});
});
+
+ it("writes an inline SVG logo over 10 KB whole, and skips one over the size cap uncut", async () => {
+ await withTempDir(async (dir) => {
+ const shapes = (n: number) =>
+ Array.from({ length: n }, (_, i) => ``).join("");
+ const logo = ``;
+ const huge = ``;
+ expect(logo.length).toBeGreaterThan(10_000);
+ const { assets, drops } = await downloadAssets(
+ {
+ svgs: [
+ { outerHTML: logo, isLogo: true },
+ { outerHTML: huge, isLogo: false },
+ ],
+ sections: [],
+ ogImage: "",
+ } as unknown as DesignTokens,
+ dir,
+ );
+ expect(assets).toHaveLength(1);
+ const written = readFileSync(join(dir, assets[0]!.localPath), "utf-8");
+ expect(written).toBe(toStandaloneSvg(logo));
+ expect(written.endsWith("")).toBe(true);
+ expect(drops["size-ceiling"]).toBe(1);
+ });
+ });
});
describe("asset fetches present the same identity as the page navigation", () => {
diff --git a/packages/cli/src/capture/assetDownloader.ts b/packages/cli/src/capture/assetDownloader.ts
index 3dbf6188a1..0d1cea7391 100644
--- a/packages/cli/src/capture/assetDownloader.ts
+++ b/packages/cli/src/capture/assetDownloader.ts
@@ -40,6 +40,8 @@ interface DownloadBudgetOptions {
export type AssetDropReason =
/** Fetched, then judged too small to be a real asset rather than a spacer or tracking pixel. */
| "size-floor"
+ /** Larger than any one asset may be; skipped whole, never cut. */
+ | "size-ceiling"
/** The post-navigation clock ran out before this one was reached. */
| "budget-exhausted"
/** A per-run or per-family limit was already met. */
@@ -51,7 +53,13 @@ export type AssetDropCounts = Record;
/** A tally with every reason at zero — the shape a caller merges into. */
export function noDrops(): AssetDropCounts {
- return { "size-floor": 0, "budget-exhausted": 0, "cap-reached": 0, unavailable: 0 };
+ return {
+ "size-floor": 0,
+ "size-ceiling": 0,
+ "budget-exhausted": 0,
+ "cap-reached": 0,
+ unavailable: 0,
+ };
}
/** Sum two tallies. Used to fold the font pass and the asset pass into one capture-wide count. */
@@ -326,6 +334,7 @@ export async function downloadAssets(
ensureCaptureDirSync(outputDir, join(outputDir, "assets", "svgs"));
const usedSvgNames = new Set();
const MAX_INLINE_SVGS = 30;
+ const MAX_INLINE_SVG_BYTES = 2 * 1024 * 1024;
drops["cap-reached"] += Math.max(0, tokens.svgs.length - MAX_INLINE_SVGS);
for (let i = 0; i < tokens.svgs.length && i < MAX_INLINE_SVGS; i++) {
const svg = tokens.svgs[i]!;
@@ -335,6 +344,10 @@ export async function downloadAssets(
}
// Hash the bytes that actually land on disk, so the filename still can't drift from content.
const svgFile = toStandaloneSvg(svg.outerHTML);
+ if (Buffer.byteLength(svgFile, "utf-8") > MAX_INLINE_SVG_BYTES) {
+ drops["size-ceiling"]++;
+ continue;
+ }
const slug = svgContentHashSlug(svgFile, !!svg.isLogo);
let finalSlug = slug;
let suffix = 2;
diff --git a/packages/cli/src/capture/tokenExtractor.test.ts b/packages/cli/src/capture/tokenExtractor.test.ts
new file mode 100644
index 0000000000..8b18c6981e
--- /dev/null
+++ b/packages/cli/src/capture/tokenExtractor.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+import { runInNewContext } from "node:vm";
+import { Window } from "happy-dom";
+import type { Page } from "puppeteer-core";
+import { extractTokens } from "./tokenExtractor.js";
+
+async function tokensOf(body: string) {
+ const window = new Window();
+ window.document.body.innerHTML = body;
+ const evaluate: Page["evaluate"] = async (script, ..._args) => {
+ if (typeof script !== "string") throw new Error("Expected a page expression");
+ return runInNewContext(script, {
+ window,
+ document: window.document,
+ location: window.location,
+ getComputedStyle: window.getComputedStyle.bind(window),
+ });
+ };
+ try {
+ return await extractTokens({ evaluate } as Page);
+ } finally {
+ await window.happyDOM.close();
+ }
+}
+
+describe("capture tokens", () => {
+ it("keeps an inline SVG logo over 10 KB whole", async () => {
+ const shapes = Array.from({ length: 3000 }, (_, i) => ``);
+ const logo = ``;
+ expect(logo.length).toBeGreaterThan(10_000);
+ const tokens = await tokensOf(``);
+ expect(tokens.svgs).toHaveLength(1);
+ expect(tokens.svgs[0]!.outerHTML.length).toBe(logo.length);
+ expect(tokens.svgs[0]!.outerHTML.endsWith("")).toBe(true);
+ expect(tokens.svgs[0]!.isLogo).toBe(true);
+ });
+});
diff --git a/packages/cli/src/capture/tokenExtractor.ts b/packages/cli/src/capture/tokenExtractor.ts
index 7c42f28ef9..acb48a6b5c 100644
--- a/packages/cli/src/capture/tokenExtractor.ts
+++ b/packages/cli/src/capture/tokenExtractor.ts
@@ -386,7 +386,7 @@ const EXTRACT_SCRIPT = `(() => {
viewBox: svg.getAttribute("viewBox") || undefined,
width: Math.round(rect.width),
height: Math.round(rect.height),
- outerHTML: svg.outerHTML.slice(0, 10000),
+ outerHTML: svg.outerHTML,
isLogo: isLogo
};
}).filter(Boolean).slice(0, 50);
diff --git a/packages/cli/src/whisper/sherpa.test.ts b/packages/cli/src/whisper/sherpa.test.ts
index 25f0377020..32342c35c9 100644
--- a/packages/cli/src/whisper/sherpa.test.ts
+++ b/packages/cli/src/whisper/sherpa.test.ts
@@ -14,7 +14,9 @@ import { basename, dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DownloadOptions } from "../cloud/download.js";
+import { execFileSync } from "node:child_process";
import {
+ decodeTimeoutMs,
ensureParakeetModel,
installSherpaRuntime,
SHERPA_RUNTIME_DIR,
@@ -470,3 +472,16 @@ describe("sherpaUnsupportedReason", () => {
);
});
});
+
+describe("decodeTimeoutMs", () => {
+ // 1851.245125 s of audio once gave 3702490.25, which execFile refused as out of range.
+ it.each([1851.245125, 10_000_000])(
+ "gives execFile a timeout it accepts for %s s of audio",
+ (seconds) => {
+ const timeout = decodeTimeoutMs(seconds);
+ expect(Number.isInteger(timeout)).toBe(true);
+ expect(timeout).toBeGreaterThanOrEqual(Math.min(seconds * 2000, 2 ** 31 - 1));
+ expect(() => execFileSync(process.execPath, ["-e", ""], { timeout })).not.toThrow();
+ },
+ );
+});
diff --git a/packages/cli/src/whisper/sherpa.ts b/packages/cli/src/whisper/sherpa.ts
index 9615059186..61fe2a76d5 100644
--- a/packages/cli/src/whisper/sherpa.ts
+++ b/packages/cli/src/whisper/sherpa.ts
@@ -82,8 +82,9 @@ const PARAKEET_MODEL_FILES: readonly ModelFile[] = [
},
];
-/** At least 30 minutes, and twice the audio length for slow CPUs. */
-const decodeTimeoutMs = (audioSeconds: number) => Math.max(1_800_000, audioSeconds * 2000);
+/** At least 30 minutes, twice the audio length for slow CPUs; whole ms, within Node's timer limit. */
+export const decodeTimeoutMs = (audioSeconds: number) =>
+ Math.min(2 ** 31 - 1, Math.max(1_800_000, Math.ceil(audioSeconds * 2000)));
/** The platforms sherpa-onnx-node publishes a native package for. */
const SUPPORTED_TARGETS = [