Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 93 additions & 4 deletions packages/engine/src/services/screenshotService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
cdpSessionCache,
injectVideoFramesBatch,
syncVideoFrameVisibility,
materializeInheritedTextColorsInDocument,
} from "./screenshotService.js";

// Stub a Page + CDPSession just enough that pageScreenshotCapture can call
Expand All @@ -20,15 +21,103 @@ function makeFakePageWithCdp(send: (method: string, params: object) => Promise<{
return fakePage;
}

describe("materializeInheritedTextColorsInDocument", () => {
function withLinkedomDocument(html: string) {
const { window, document } = parseHTML(html);
Object.defineProperty(window, "getComputedStyle", {
configurable: true,
value: (el: Element) => {
let cur: Element | null = el;
while (cur) {
const inlineColor = (cur as HTMLElement).style?.color;
if (inlineColor) {
return { color: inlineColor };
}
cur = cur.parentElement;
}
return { color: "" };
},
});

const globals = globalThis as unknown as { window?: typeof window; document?: Document };
const previousWindow = globals.window;
const previousDocument = globals.document;
globals.window = window;
globals.document = document;
return {
window,
document,
teardown: () => {
globals.window = previousWindow;
globals.document = previousDocument;
},
};
}

it("sets inline color from computed style when text relies on CSS inheritance", () => {
const { document, teardown } = withLinkedomDocument(
'<html><body><div id="root" style="color:#e8e8e8"><div id="title" style="font-size:76px">Invisible in render</div><div id="subtitle" style="color:#67e8f9">Visible control</div></body></html>',
);
try {
materializeInheritedTextColorsInDocument();

const title = document.getElementById("title") as HTMLElement;
const subtitle = document.getElementById("subtitle") as HTMLElement;
expect(title.style.color).toBe("#e8e8e8");
expect(subtitle.style.color).toBe("#67e8f9");
} finally {
teardown();
}
});

it("does not override an existing inline color", () => {
const { document, teardown } = withLinkedomDocument(
'<html><body><div style="color:#e8e8e8"><div id="title" style="color:#ff0000">Explicit red</div></body></html>',
);
try {
materializeInheritedTextColorsInDocument();

const title = document.getElementById("title") as HTMLElement;
expect(title.style.color).toBe("#ff0000");
} finally {
teardown();
}
});

it("skips container elements whose text lives only in descendants", () => {
const { document, teardown } = withLinkedomDocument(
'<html><body><div style="color:#e8e8e8"><div id="wrap"><span id="label">Nested text</span></div></div></body></html>',
);
try {
materializeInheritedTextColorsInDocument();

const wrap = document.getElementById("wrap") as HTMLElement;
const label = document.getElementById("label") as HTMLElement;
expect(wrap.style.color ?? "").toBe("");
expect(label.style.color).toBe("#e8e8e8");
} finally {
teardown();
}
});
});

describe("pageScreenshotCapture supersample plumbing", () => {
// Minimal 1×1 transparent PNG, base64. The function returns Buffer.from(data, "base64")
// and we never inspect the bytes — only the params we pass to client.send.
const ONE_PIXEL_PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";

function makeScreenshotPage(send: (method: string, params: object) => Promise<{ data: string }>) {
const page = makeFakePageWithCdp(send) as Page & {
evaluate: (fn: () => void) => Promise<void>;
};
page.evaluate = async () => {};
return page;
}

it("passes `clip` with scale 1 when deviceScaleFactor is undefined (default 1)", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
const page = makeFakePageWithCdp(send);
const page = makeScreenshotPage(send);

await pageScreenshotCapture(page, {
width: 1920,
Expand All @@ -48,7 +137,7 @@ describe("pageScreenshotCapture supersample plumbing", () => {

it("passes `clip` with scale 1 when deviceScaleFactor is exactly 1", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
const page = makeFakePageWithCdp(send);
const page = makeScreenshotPage(send);

await pageScreenshotCapture(page, {
width: 1920,
Expand All @@ -64,7 +153,7 @@ describe("pageScreenshotCapture supersample plumbing", () => {

it("passes `clip` with `scale = dpr` when deviceScaleFactor > 1 (the supersample contract)", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
const page = makeFakePageWithCdp(send);
const page = makeScreenshotPage(send);

await pageScreenshotCapture(page, {
width: 1920,
Expand All @@ -84,7 +173,7 @@ describe("pageScreenshotCapture supersample plumbing", () => {

it("propagates a non-2 supersample factor (e.g. 720p → 4K = 3×)", async () => {
const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
const page = makeFakePageWithCdp(send);
const page = makeScreenshotPage(send);

await pageScreenshotCapture(page, {
width: 1280,
Expand Down
34 changes: 34 additions & 0 deletions packages/engine/src/services/screenshotService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,37 @@ export async function beginFrameCapture(
};
}

/**
* Materialize computed `color` as an inline style on text elements that rely
* on CSS inheritance. Chrome's `Page.captureScreenshot` compositor can fail to
* resolve inherited text color after GSAP clears its inline styles, leaving
* inherited-color text invisible in the captured frame.
*
* Runs in the page context via `page.evaluate` — must only reference browser
* globals so Puppeteer can serialize the function body.
*/
export function materializeInheritedTextColorsInDocument(): void {
const body = document.body;
if (!body) return;

for (const node of body.querySelectorAll<HTMLElement>("*")) {
const style = node.style;
if (style.color) continue;
for (let i = 0; i < node.childNodes.length; i++) {
const child = node.childNodes[i];
if (child?.nodeType === 3 && child.textContent && /\S/.test(child.textContent)) {
const computed = window.getComputedStyle(node).color;
if (computed) style.color = computed;
break;
}
}
}
}

async function materializeInheritedTextColors(page: Page): Promise<void> {
await page.evaluate(materializeInheritedTextColorsInDocument);
}

/**
* Capture a screenshot using standard Page.captureScreenshot CDP call.
* Fallback for environments where BeginFrame is unavailable (macOS, Windows).
Expand All @@ -127,6 +158,7 @@ export async function beginFrameCapture(
* `captureAlphaPng`. Keeping the fast path for opaque jpeg captures is fine.
*/
export async function pageScreenshotCapture(page: Page, options: CaptureOptions): Promise<Buffer> {
await materializeInheritedTextColors(page);
const client = await getCdpSession(page);
const isPng = options.format === "png";
const dpr = options.deviceScaleFactor ?? 1;
Expand Down Expand Up @@ -163,6 +195,7 @@ export async function captureScreenshotWithAlpha(
width: number,
height: number,
): Promise<Buffer> {
await materializeInheritedTextColors(page);
const client = await getCdpSession(page);
// Force transparent background so the screenshot has a real alpha channel
await client.send("Emulation.setDefaultBackgroundColorOverride", {
Expand Down Expand Up @@ -233,6 +266,7 @@ export async function initTransparentBackground(page: Page): Promise<void> {
* two-pass compositing loop.
*/
export async function captureAlphaPng(page: Page, width: number, height: number): Promise<Buffer> {
await materializeInheritedTextColors(page);
const client = await getCdpSession(page);
const result = await client.send("Page.captureScreenshot", {
format: "png",
Expand Down
Loading