From d111e463bd235ec2167217ed5dd3c6e41d9df9c2 Mon Sep 17 00:00:00 2001 From: manupareekk Date: Sat, 13 Jun 2026 05:12:06 +0000 Subject: [PATCH 1/2] fix: Inherited CSS color invisible in rendered output (Page.captureScreenshot Fixes heygen-com/hyperframes#1290 --- .../src/services/screenshotService.test.ts | 97 ++++++++++++++++++- .../engine/src/services/screenshotService.ts | 36 +++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/services/screenshotService.test.ts b/packages/engine/src/services/screenshotService.test.ts index 4332c359d..48b80d9ad 100644 --- a/packages/engine/src/services/screenshotService.test.ts +++ b/packages/engine/src/services/screenshotService.test.ts @@ -7,6 +7,7 @@ import { cdpSessionCache, injectVideoFramesBatch, syncVideoFrameVisibility, + materializeInheritedTextColorsInDocument, } from "./screenshotService.js"; // Stub a Page + CDPSession just enough that pageScreenshotCapture can call @@ -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( + '
Invisible in render
Visible control
', + ); + 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( + '
Explicit red
', + ); + 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( + '
Nested text
', + ); + 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; + }; + 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, @@ -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, @@ -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, @@ -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, diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts index ffbe0be56..e89ff0117 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -116,6 +116,39 @@ 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("*")) { + const style = (node as HTMLElement).style; + if (!style || style.color) continue; + for (let i = 0; i < node.childNodes.length; i++) { + const child = node.childNodes[i]; + if (child && child.nodeType === 3 && child.textContent && /\S/.test(child.textContent)) { + const computed = window.getComputedStyle(node).color; + if (computed) { + node.style.color = computed; + } + break; + } + } + } +} + +export async function materializeInheritedTextColors(page: Page): Promise { + await page.evaluate(materializeInheritedTextColorsInDocument); +} + /** * Capture a screenshot using standard Page.captureScreenshot CDP call. * Fallback for environments where BeginFrame is unavailable (macOS, Windows). @@ -127,6 +160,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 { + await materializeInheritedTextColors(page); const client = await getCdpSession(page); const isPng = options.format === "png"; const dpr = options.deviceScaleFactor ?? 1; @@ -163,6 +197,7 @@ export async function captureScreenshotWithAlpha( width: number, height: number, ): Promise { + await materializeInheritedTextColors(page); const client = await getCdpSession(page); // Force transparent background so the screenshot has a real alpha channel await client.send("Emulation.setDefaultBackgroundColorOverride", { @@ -233,6 +268,7 @@ export async function initTransparentBackground(page: Page): Promise { * two-pass compositing loop. */ export async function captureAlphaPng(page: Page, width: number, height: number): Promise { + await materializeInheritedTextColors(page); const client = await getCdpSession(page); const result = await client.send("Page.captureScreenshot", { format: "png", From 6192652ce3dea362aea1791c4868e872f704aa2d Mon Sep 17 00:00:00 2001 From: manupareekk Date: Sat, 13 Jun 2026 05:41:34 +0000 Subject: [PATCH 2/2] fix(engine): materialize inherited text color before Page.captureScreenshot Fixes heygen-com/hyperframes#1290 - Use HTMLElement typing in page.evaluate helper - Keep materializeInheritedTextColors module-private (fallow) Co-authored-by: Cursor --- packages/engine/src/services/screenshotService.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/engine/src/services/screenshotService.ts b/packages/engine/src/services/screenshotService.ts index e89ff0117..b582e8d2a 100644 --- a/packages/engine/src/services/screenshotService.ts +++ b/packages/engine/src/services/screenshotService.ts @@ -129,23 +129,21 @@ export function materializeInheritedTextColorsInDocument(): void { const body = document.body; if (!body) return; - for (const node of body.querySelectorAll("*")) { - const style = (node as HTMLElement).style; - if (!style || style.color) continue; + for (const node of body.querySelectorAll("*")) { + const style = node.style; + if (style.color) continue; for (let i = 0; i < node.childNodes.length; i++) { const child = node.childNodes[i]; - if (child && child.nodeType === 3 && child.textContent && /\S/.test(child.textContent)) { + if (child?.nodeType === 3 && child.textContent && /\S/.test(child.textContent)) { const computed = window.getComputedStyle(node).color; - if (computed) { - node.style.color = computed; - } + if (computed) style.color = computed; break; } } } } -export async function materializeInheritedTextColors(page: Page): Promise { +async function materializeInheritedTextColors(page: Page): Promise { await page.evaluate(materializeInheritedTextColorsInDocument); }