fix(engine): publish video frames only after decoding (#6092)

This commit is contained in:
Miguel Ángel
2026-10-22 22:35:17 +00:00
committed by GitHub
parent 4a788583f6
commit aa4b535334
2 changed files with 41 additions and 1 deletions
@@ -662,6 +662,7 @@ async function injectResolvedVideoFramesBatch(
) => {
const injectedIds: string[] = [];
const pendingDecodes: Array<Promise<void>> = [];
const imagesToPublish: HTMLImageElement[] = [];
const replacementLayoutProperties = new Set([
"width",
"height",
@@ -803,6 +804,8 @@ async function injectResolvedVideoFramesBatch(
img.style.objectPosition = computedStyle.objectPosition;
img.style.zIndex = computedStyle.zIndex;
// Publish after full decode so raster cannot race it with a reduced JPEG decode.
img.style.setProperty("visibility", "hidden", "important");
img.decoding = "sync";
if (img.getAttribute("src") !== item.dataUri) {
if (item.crossOrigin) img.crossOrigin = item.crossOrigin;
@@ -821,7 +824,7 @@ async function injectResolvedVideoFramesBatch(
);
}
img.style.opacity = String(computedOpacity);
img.style.visibility = "visible";
imagesToPublish.push(img);
// Hide the native <video> with visibility only — never clobber inline
// opacity, so subsequent reads (and queryElementStacking) see the real
// GSAP-controlled value.
@@ -832,6 +835,7 @@ async function injectResolvedVideoFramesBatch(
if (pendingDecodes.length > 0) {
await Promise.all(pendingDecodes);
}
for (const img of imagesToPublish) img.style.visibility = "visible";
if (injectedIds.length > 0) {
const redraw = (window as Window & { __hf?: { colorGrading?: { redraw?: () => void } } })
.__hf?.colorGrading?.redraw;
@@ -105,6 +105,42 @@ describe.skipIf(!hasBrowser)("video frame transport in Chrome", () => {
expect(unexpectedFrameRequests).toBe(0);
});
it("publishes a replacement only after its full-resolution decode completes", async () => {
await injectVideoFramesBatch(page, [{ videoId: "a", dataUri: blueJpeg }]);
await page.addStyleTag({ content: ".__render_frame__ { visibility: visible !important }" });
await page.evaluate(() => {
const decode = HTMLImageElement.prototype.decode;
HTMLImageElement.prototype.decode = async function () {
document.documentElement.setAttribute("data-decode-pending", "1");
await new Promise<void>((resolve) =>
window.addEventListener("release-frame-decode", () => resolve(), { once: true }),
);
await decode.call(this);
};
});
const injection = injectVideoFramesBatch(page, [{ videoId: "a", dataUri: redPng }]);
try {
await page.waitForSelector('[data-decode-pending="1"]');
const visibility = await page.evaluate(() => {
const img = document.querySelector<HTMLImageElement>("img.__render_frame__");
if (!img) throw new Error("No replacement frame");
return getComputedStyle(img).visibility;
});
expect(visibility).toBe("hidden");
} finally {
await page.evaluate(() => window.dispatchEvent(new Event("release-frame-decode")));
await injection;
}
expect((await pixels())[0]?.rgba).toEqual([255, 0, 0, 255]);
expect(
await page.evaluate(() => {
const img = document.querySelector("img");
if (!img) throw new Error("No replacement frame");
return getComputedStyle(img).visibility;
}),
).toBe("visible");
});
it("loads frames even while all same-origin download connections are busy", async () => {
await page.evaluate(() => {
for (let i = 0; i < 6; i++) void fetch(`/slow${i}.mp4`);