mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 07:09:59 +00:00
fix(engine): harden ancestor-hidden video skip against mask + caller cache
Two follow-ups to the ancestor-visibility skip in `injectVideoFramesBatch`
and `syncVideoFrameVisibility`.
1. **Mask defence.** Both ancestor-hidden branches previously wrote a plain
`img.style.visibility = "hidden"`. `applyDomLayerMask` writes the
stylesheet rule `#${showId} *{visibility:visible !important}`, and CSS
cascade puts important stylesheet author above non-important inline
author — so a sub-comp host landing in the active layer's `show` set
would revive a stale `__render_frame__` and let it bleed onto the
layer composite. Write the hide via
`style.setProperty("visibility", "hidden", "important")` instead;
important inline beats important stylesheet.
2. **Caller cache hygiene.** `createVideoFrameInjector` unconditionally
wrote `lastInjectedFrameByVideo.set(id, frameIndex)` after calling
`injectVideoFramesBatch`, even for videos the page silently skipped due
to a hidden visual ancestor. On the next call at the same frameIndex —
common with source-fps < output-fps, paused source frames, or
non-frame-aligned host starts — the cache short-circuited the second
inject and the host's first visible frame painted blank because the
replacement `<img>` was never created.
Make `injectVideoFramesBatch` return `string[]` (the subset of ids it
actually painted) and have the caller cache only those. The cli-side
`snapshot.ts` consumer is unaffected: its local `InjectFn` types the
return as `Promise<void>`, which is structurally compatible with
`Promise<string[]>` under TS void-return assignment rules.
Tests: linkedom doesn't preserve `!important` in cssText, so the two new
mask-defence cases spy on the live `<img>`'s `style.setProperty` and assert
the 3-arg call shape. The cache-hygiene case stubs the page-side primitives
via `vi.mock`, drives the hook twice at the same frameIndex with a stubbed
"injected nothing" first response, and verifies the second call still
issues an inject. A counter-test pins the happy-path cache hit so a future
refactor can't trade the skip bug for a never-cache regression.
This commit is contained in:
@@ -1,9 +1,30 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { __testing } from "./videoFrameInjector.js";
|
||||
import { type Page } from "puppeteer-core";
|
||||
|
||||
// Hoist mocks before importing the module under test so the mock factory wins.
|
||||
// The cache-hygiene block exercises createVideoFrameInjector against stubbed
|
||||
// page-side primitives so we can assert on Node-side state (cache poisoning)
|
||||
// without standing up a real browser.
|
||||
const { injectVideoFramesBatchMock, syncVideoFrameVisibilityMock } = vi.hoisted(() => ({
|
||||
injectVideoFramesBatchMock: vi.fn<
|
||||
(page: Page, updates: Array<{ videoId: string; dataUri: string }>) => Promise<string[]>
|
||||
>(async (_page, updates) => updates.map((u) => u.videoId)),
|
||||
syncVideoFrameVisibilityMock: vi.fn<(page: Page, ids: string[]) => Promise<void>>(
|
||||
async () => undefined,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./screenshotService.js", () => ({
|
||||
injectVideoFramesBatch: injectVideoFramesBatchMock,
|
||||
syncVideoFrameVisibility: syncVideoFrameVisibilityMock,
|
||||
}));
|
||||
|
||||
import { __testing, createVideoFrameInjector } from "./videoFrameInjector.js";
|
||||
import { type FrameLookupTable } from "./videoFrameExtractor.js";
|
||||
import { DEFAULT_CONFIG } from "../config.js";
|
||||
|
||||
const { createFrameSourceCache } = __testing;
|
||||
@@ -143,3 +164,91 @@ describe("frame source cache eviction", () => {
|
||||
expect(cache.stats()).toMatchObject({ ...SHARED_STATS, entries: 0, bytes: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVideoFrameInjector cache hygiene against page-side skips", () => {
|
||||
// Build a minimal FrameLookupTable stand-in that returns one fixed payload
|
||||
// for every time so we can drive the hook deterministically. The real
|
||||
// table is exercised exhaustively in videoFrameExtractor.test.ts.
|
||||
function fakeTable(payload: { videoId: string; framePath: string; frameIndex: number }) {
|
||||
return {
|
||||
getActiveFramePayloads: () =>
|
||||
new Map([
|
||||
[payload.videoId, { framePath: payload.framePath, frameIndex: payload.frameIndex }],
|
||||
]),
|
||||
} as unknown as FrameLookupTable;
|
||||
}
|
||||
|
||||
// Bypass the on-disk frame cache by handing back a synthetic data URI.
|
||||
function inlineResolver(framePath: string): string {
|
||||
return `data:image/png;base64,fake-${framePath}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
injectVideoFramesBatchMock.mockReset();
|
||||
syncVideoFrameVisibilityMock.mockReset();
|
||||
syncVideoFrameVisibilityMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("does not poison the lastInjected cache when the page reports zero ids injected", async () => {
|
||||
// Regression for the agentic-finecut scenario after PR #1028's ancestor
|
||||
// skip: when injectVideoFramesBatch silently drops a video (its sub-comp
|
||||
// host is hidden), the caller used to record `lastInjectedFrame[v] = N`
|
||||
// anyway. On the next frame, if the source frameIndex is unchanged
|
||||
// (low-fps source, multiple output frames per source frame, or
|
||||
// non-frame-aligned host start), the cache short-circuits the second
|
||||
// call and the host's first visible frame paints blank because the
|
||||
// replacement <img> was never created.
|
||||
//
|
||||
// Pin the contract: when the page returns `[]` (no ids actually
|
||||
// injected), the cache must not record those frameIndexes, so a follow-
|
||||
// up call at the same frameIndex still issues an inject.
|
||||
const fakePage = {} as Page;
|
||||
const hook = createVideoFrameInjector(
|
||||
fakeTable({ videoId: "pip", framePath: "/p", frameIndex: 5 }),
|
||||
{
|
||||
frameSrcResolver: inlineResolver,
|
||||
},
|
||||
);
|
||||
expect(hook).not.toBeNull();
|
||||
|
||||
// First call: simulate the ancestor-hidden skip — page-side reports it
|
||||
// injected nothing.
|
||||
injectVideoFramesBatchMock.mockResolvedValueOnce([]);
|
||||
await hook!(fakePage, 0);
|
||||
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1);
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does cache normally when the page reports the id as injected", async () => {
|
||||
// Counter-test: when injection succeeds for a videoId, the cache must
|
||||
// record it and a second call at the same frameIndex must short-circuit.
|
||||
// This pins the happy path so a future refactor can't trade the skip
|
||||
// bug for a never-cache regression.
|
||||
const fakePage = {} as Page;
|
||||
const hook = createVideoFrameInjector(
|
||||
fakeTable({ videoId: "pip", framePath: "/p", frameIndex: 5 }),
|
||||
{
|
||||
frameSrcResolver: inlineResolver,
|
||||
},
|
||||
);
|
||||
|
||||
injectVideoFramesBatchMock.mockResolvedValueOnce(["pip"]);
|
||||
await hook!(fakePage, 0);
|
||||
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await hook!(fakePage, 0);
|
||||
// Cache hit — no second inject for the same frameIndex.
|
||||
expect(injectVideoFramesBatchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user