diff --git a/packages/engine/src/services/frameCapture-videoMetadataHints.browser.test.ts b/packages/engine/src/services/frameCapture-videoMetadataHints.browser.test.ts new file mode 100644 index 0000000000..94c8090d91 --- /dev/null +++ b/packages/engine/src/services/frameCapture-videoMetadataHints.browser.test.ts @@ -0,0 +1,62 @@ +import { spawnSync } from "node:child_process"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import puppeteer, { type Browser } from "puppeteer-core"; +import { resolveHeadlessShellPath } from "./browserManager.js"; +import { applyVideoMetadataHints } from "./frameCapture.js"; + +const chromePath = resolveHeadlessShellPath(); +const hasBrowser = chromePath !== undefined && spawnSync(chromePath, ["--version"]).status === 0; + +// Boxes Chrome gives these styles once a 640x360 video's metadata has loaded. +const cases = { + noSize: { style: "", box: "640x360" }, + widthOnly: { style: "width:200px", box: "200x112.5" }, + heightOnly: { style: "height:90px", box: "160x90" }, + bothSizes: { style: "width:200px;height:300px;object-fit:cover", box: "200x300" }, + maxWidthOnly: { style: "max-width:100px", box: "100x56.25" }, + // A flex item may not shrink below its natural width, so its border overflows the 400px row. + borderedFlexItem: { style: "width:100%;border:8px solid", box: "416x241" }, +}; +const ids = Object.keys(cases); + +describe.skipIf(!hasBrowser)("applyVideoMetadataHints in Chrome", () => { + let browser: Browser; + + beforeAll(async () => { + browser = await puppeteer.launch({ + executablePath: chromePath, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + }); + afterAll(async () => { + await browser?.close(); + }); + + it("lays out a video without loaded metadata as it would with it", async () => { + const page = await browser.newPage(); + const videos = Object.entries(cases).map( + ([id, { style }]) => + `
`, + ); + await page.setContent(`${videos.join("")}`); + + await applyVideoMetadataHints( + page, + ids.map((id) => ({ id, width: 640, height: 360 })), + ); + + const boxes = await page.evaluate((videoIds) => { + return Object.fromEntries( + videoIds.map((id) => { + const rect = document.getElementById(id)!.getBoundingClientRect(); + return [id, `${rect.width}x${rect.height}`]; + }), + ); + }, ids); + expect(boxes).toEqual( + Object.fromEntries(Object.entries(cases).map(([id, { box }]) => [id, box])), + ); + await page.close(); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index c71a963b5f..8c24bfe849 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -2217,7 +2217,7 @@ async function decodeAllImages(page: Page): Promise { }); } -async function applyVideoMetadataHints( +export async function applyVideoMetadataHints( page: Page, hints: readonly CaptureVideoMetadataHint[] | undefined, ): Promise { @@ -2225,7 +2225,8 @@ async function applyVideoMetadataHints( // fallow-ignore-next-line complexity await page.evaluate( - (metadataHints: CaptureVideoMetadataHint[]) => { + async (metadataHints: CaptureVideoMetadataHint[]) => { + const posters = new Set(); for (const hint of metadataHints) { if ( !hint.id || @@ -2239,19 +2240,20 @@ async function applyVideoMetadataHints( const video = (window.__hfMediaEl?.(hint.id) ?? document.getElementById(hint.id)) as HTMLVideoElement | null; - if (!video) continue; + if (!video || video.hasAttribute("poster")) continue; - if (!video.hasAttribute("width")) video.setAttribute("width", String(hint.width)); - if (!video.hasAttribute("height")) video.setAttribute("height", String(hint.height)); - - const computed = window.getComputedStyle(video); - if ( - !video.style.aspectRatio && - (!computed.aspectRatio || computed.aspectRatio === "auto") - ) { - video.style.aspectRatio = `${hint.width} / ${hint.height}`; - } + const svg = ``; + const intrinsicSizePoster = `data:image/svg+xml,${encodeURIComponent(svg)}`; + video.poster = intrinsicSizePoster; + posters.add(intrinsicSizePoster); } + await Promise.all( + [...posters].map((src) => { + const image = new Image(); + image.src = src; + return image.decode(); + }), + ); }, [...hints], );