fix(engine): a video held past its media end keeps following its animation (#6424)

* fix(engine): a video held past its media end keeps following its animation

Fixes #4049.

* fix(engine): drop two comments the names already carry
This commit is contained in:
Miguel Ángel
2026-10-26 16:12:30 +00:00
committed by GitHub
parent 9ecb13ed69
commit d8398c9da7
5 changed files with 155 additions and 26 deletions
+2 -2
View File
@@ -4569,8 +4569,8 @@ async function captureAccumulatedFrame(
await seekPageTimeline(session.page, frameTime, undefined);
const totals = { seekMs: Date.now() - eventfulSeekStart, beforeCaptureMs: 0, screenshotMs: 0 };
if (session.onBeforeCapture) {
// The injector copies a video's own style only when its frame changes: inject at frameTime
// first so the held frame carries frameTime's style, not the first sample's.
// Sub-samples leave a video's style alone: inject at frameTime first so the held frame
// carries frameTime's style, not the first sample's.
const injectStart = Date.now();
session.captureStep = "injecting video frames";
await session.onBeforeCapture(session.page, frameTime, frameTime);
@@ -643,25 +643,35 @@ export async function ensureRenderFrameSiblings(page: Page): Promise<void> {
* cache entry for a frame that never reached the page, which would otherwise
* short-circuit the next inject at the same frameIndex and leave the host's
* first visible frame blank.
*
* `restyleIds` keep their current picture and only re-copy the video's style.
*/
export async function injectVideoFramesBatch(
page: Page,
updates: Array<{ videoId: string; dataUri: string }>,
restyleIds: readonly string[] = [],
): Promise<string[]> {
if (updates.length === 0) return [];
if (updates.length === 0 && restyleIds.length === 0) return [];
return withVideoFrameSources(page, updates, (resolved) =>
injectResolvedVideoFramesBatch(page, resolved),
injectResolvedVideoFramesBatch(page, resolved, restyleIds),
);
}
async function injectResolvedVideoFramesBatch(
page: Page,
updates: VideoFrameUpdate[],
restyleIds: readonly string[],
): Promise<string[]> {
// Build the sibling id with core's function rather than a template here,
// so the id the readers look up has exactly one definition.
const items: Array<Partial<VideoFrameUpdate> & { videoId: string; frameId: string }> = [
...updates,
...restyleIds.map((videoId) => ({ videoId })),
].map((item) => ({ ...item, frameId: renderFrameIdForRenderId(item.videoId) }));
return await page.evaluate(
// fallow-ignore-next-line complexity
async (
items: Array<VideoFrameUpdate & { frameId: string }>,
items: Array<Partial<VideoFrameUpdate> & { videoId: string; frameId: string }>,
visualProperties: string[],
colorGradingSourceHiddenAttr: string,
) => {
@@ -723,6 +733,8 @@ async function injectResolvedVideoFramesBatch(
let img = video.nextElementSibling as HTMLImageElement | null;
const hasImg = img !== null && img.classList.contains("__render_frame__");
const restyleOnly = item.dataUri === undefined;
if (restyleOnly && !hasImg) continue;
if (isVisualAncestorHidden(video)) {
// Don't paint a frame over a hidden host — if an existing replacement
@@ -812,7 +824,7 @@ async function injectResolvedVideoFramesBatch(
// 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.dataUri !== undefined && img.getAttribute("src") !== item.dataUri) {
if (item.crossOrigin) img.crossOrigin = item.crossOrigin;
else img.removeAttribute("crossorigin");
img.src = item.dataUri;
@@ -835,22 +847,20 @@ async function injectResolvedVideoFramesBatch(
// GSAP-controlled value.
video.style.setProperty("visibility", "hidden", "important");
video.style.setProperty("pointer-events", "none", "important");
injectedIds.push(item.videoId);
if (!restyleOnly) injectedIds.push(item.videoId);
}
if (pendingDecodes.length > 0) {
await Promise.all(pendingDecodes);
}
for (const img of imagesToPublish) img.style.visibility = "visible";
if (injectedIds.length > 0) {
if (imagesToPublish.length > 0) {
const redraw = (window as Window & { __hf?: { colorGrading?: { redraw?: () => void } } })
.__hf?.colorGrading?.redraw;
redraw?.();
}
return injectedIds;
},
// Build the sibling id with core's function rather than a template here,
// so the id the readers look up has exactly one definition.
updates.map((update) => ({ ...update, frameId: renderFrameIdForRenderId(update.videoId) })),
items,
[...MEDIA_VISUAL_STYLE_PROPERTIES],
COLOR_GRADING_SOURCE_HIDDEN_ATTR,
);
@@ -231,18 +231,22 @@ describe("createVideoFrameInjector cache hygiene against page-side skips", () =>
injectVideoFramesBatchMock.mockResolvedValueOnce([]);
await hook!(fakePage, 0);
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1);
expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(fakePage, [
{ videoId: "pip", dataUri: "data:image/png;base64,fake-/p" },
]);
expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(
fakePage,
[{ videoId: "pip", dataUri: "data:image/png;base64,fake-/p" }],
[],
);
// Second call: same frameIndex, but the previous call did not really
// paint. The cache must NOT short-circuit; the inject must run again.
injectVideoFramesBatchMock.mockResolvedValueOnce(["pip"]);
await hook!(fakePage, 0);
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(2);
expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(fakePage, [
{ videoId: "pip", dataUri: "data:image/png;base64,fake-/p" },
]);
expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(
fakePage,
[{ videoId: "pip", dataUri: "data:image/png;base64,fake-/p" }],
[],
);
});
it("does cache normally when the page reports the id as injected", async () => {
@@ -265,7 +269,21 @@ describe("createVideoFrameInjector cache hygiene against page-side skips", () =>
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1);
await hook!(fakePage, 0);
// Cache hit — no second inject for the same frameIndex.
// Cache hit: the picture is not sent again, only the video's style is re-copied.
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(2);
expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(fakePage, [], ["pip"]);
});
it("leaves a held video's style alone on a motion-blur sub-sample", async () => {
const fakePage = { evaluate: async () => undefined } as unknown as Page;
const hook = createVideoFrameInjector(
fakeTable({ videoId: "pip", framePath: "/p", frameIndex: 5 }),
{ frameSrcResolver: inlineResolver },
);
injectVideoFramesBatchMock.mockResolvedValueOnce(["pip"]);
await hook!(fakePage, 0.5, 0.5);
await hook!(fakePage, 0.48, 0.5);
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1);
});
@@ -283,13 +301,16 @@ describe("createVideoFrameInjector cache hygiene against page-side skips", () =>
await fresh!(truthPage, 0);
// The fresh hook paints its own page even though the first hook already painted frame 5.
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(2);
expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(truthPage, [
{ videoId: "pip", dataUri: "data:image/png;base64,fake-/p" },
]);
expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(
truthPage,
[{ videoId: "pip", dataUri: "data:image/png;base64,fake-/p" }],
[],
);
await hook!(renderPage, 0);
// The first hook still remembers frame 5 on its page.
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(2);
// The first hook still remembers frame 5 on its page: it only re-copies the style.
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(3);
expect(injectVideoFramesBatchMock).toHaveBeenLastCalledWith(renderPage, [], ["pip"]);
});
it("reinjects frame zero when a whole-chunk retry uses a fresh hook and page", async () => {
@@ -215,14 +215,19 @@ export function createVideoFrameInjector(
}
const updates: Array<{ videoId: string; dataUri: string; frameIndex: number }> = [];
const restyleIds: string[] = [];
const activeIds = new Set<string>();
const isBlurSubSample = heldVideoTime !== undefined && heldVideoTime !== time;
if (activePayloads.size > 0) {
const pendingReads: Array<Promise<{ videoId: string; dataUri: string; frameIndex: number }>> =
[];
for (const [videoId, payload] of activePayloads) {
activeIds.add(videoId);
const lastFrameIndex = lastInjectedFrameByVideo.get(videoId);
if (lastFrameIndex === payload.frameIndex) continue;
if (lastFrameIndex === payload.frameIndex) {
if (!isBlurSubSample) restyleIds.push(videoId);
continue;
}
pendingReads.push(
frameCache
.get(payload.framePath)
@@ -239,7 +244,7 @@ export function createVideoFrameInjector(
}
await syncVideoFrameVisibility(page, Array.from(activeIds));
if (updates.length > 0) {
if (updates.length > 0 || restyleIds.length > 0) {
// Only record cache entries for videos the page actually painted.
// injectVideoFramesBatch skips any video whose visual ancestor is
// hidden (sub-comp host out-of-window) and returns the subset of ids
@@ -250,6 +255,7 @@ export function createVideoFrameInjector(
await injectVideoFramesBatch(
page,
updates.map((u) => ({ videoId: u.videoId, dataUri: u.dataUri })),
restyleIds,
),
);
for (const update of updates) {
@@ -1,5 +1,6 @@
// Motion blur over a <video>, rendered for real (#5144): held video rows match blur-off, the
// bar smears, and a sub-composition cut keeps its video. Needs Chrome + ffmpeg: integration lane.
// bar smears, and a sub-composition cut keeps its video. A video held past its media end keeps
// following its style (#4049). Needs Chrome + ffmpeg: integration lane.
import { spawnSync } from "node:child_process";
import {
@@ -81,6 +82,40 @@ const CUT_FPS = 24;
const CUT_FRAME = 24;
const CUT_FRAME_COUNT = 48;
// A 0.2 s white clip in a 0.5 s slot, fading out over the whole slot on black (#4049).
const HELD_MEDIA_SECONDS = 0.2;
const HELD_COMPOSITION = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; }
#root { background: #000; }
#clip {
position: absolute; left: 0; top: 0; width: ${WIDTH}px; height: ${HEIGHT}px;
animation: fade ${DURATION_SECONDS}s linear both;
}
@keyframes fade { from { opacity: 1; } to { opacity: 0; } }
</style>
</head>
<body>
<div
id="root"
data-composition-id="held-video"
data-start="0"
data-duration="${DURATION_SECONDS}"
data-width="${WIDTH}"
data-height="${HEIGHT}"
data-fps="${FPS}"
data-no-timeline
>
<video id="clip" class="clip" src="assets/white.mp4" data-start="0" data-duration="${DURATION_SECONDS}" data-track-index="0" muted playsinline></video>
</div>
</body>
</html>
`;
let root: string;
async function renderFrames(
@@ -237,3 +272,60 @@ describe.skipIf(!HAS_FFMPEG)("motion blur over video — real render (#5144)", (
}
});
});
describe.skipIf(!HAS_FFMPEG)("a video held past its media end — real render (#4049)", () => {
let heldDir: string;
let plain: Buffer[];
let blurred: Buffer[];
beforeAll(async () => {
root = mkdtempSync(join(tmpdir(), "hf-held-video-"));
heldDir = join(root, "held");
mkdirSync(join(heldDir, "assets"), { recursive: true });
writeFileSync(join(heldDir, "index.html"), HELD_COMPOSITION);
const clip = spawnSync(
FFMPEG,
[
"-v",
"error",
"-f",
"lavfi",
"-i",
`color=c=white:size=${WIDTH}x${HEIGHT}:rate=${FPS}:duration=${HELD_MEDIA_SECONDS}`,
"-pix_fmt",
"yuv420p",
"-c:v",
"libx264",
"-y",
join(heldDir, "assets", "white.mp4"),
],
{ encoding: "utf-8" },
);
if (clip.status !== 0) throw new Error(`white clip fixture failed: ${clip.stderr}`);
plain = await renderFrames("held-plain", undefined, heldDir);
blurred = await renderFrames("held-blurred", { samplesPerFrame: 4 }, heldDir);
}, 600_000);
afterAll(() => {
if (root) rmSync(root, { recursive: true, force: true });
});
it("keeps following the fade after the media ends", () => {
expect(plain).toHaveLength(FRAME_COUNT);
const levels = plain.map(meanLevel);
const lastMediaFrame = Math.floor(HELD_MEDIA_SECONDS * FPS);
for (let i = lastMediaFrame; i < FRAME_COUNT - 1; i++) {
expect(levels[i + 1]!, `frame ${i + 1}`).toBeLessThan(levels[i]!);
}
expect(levels[FRAME_COUNT - 1]!).toBeLessThan(levels[0]! * 0.15);
});
it("gives every motion-blur sample the output frame's fade", () => {
expect(blurred).toHaveLength(FRAME_COUNT);
for (let i = 0; i < FRAME_COUNT; i++) {
expect(rows(blurred[i]!, 0, HEIGHT).equals(rows(plain[i]!, 0, HEIGHT)), `frame ${i}`).toBe(
true,
);
}
});
});