mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +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:
@@ -369,13 +369,22 @@ export async function removeDomLayerMask(page: Page, extraHideIds: string[]): Pr
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subset of `updates.videoId`s that were actually painted in
|
||||
* this call. Videos skipped because of a hidden visual ancestor are NOT
|
||||
* included — the caller relies on this to avoid recording a `lastInjected`
|
||||
* 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.
|
||||
*/
|
||||
export async function injectVideoFramesBatch(
|
||||
page: Page,
|
||||
updates: Array<{ videoId: string; dataUri: string }>,
|
||||
): Promise<void> {
|
||||
if (updates.length === 0) return;
|
||||
await page.evaluate(
|
||||
): Promise<string[]> {
|
||||
if (updates.length === 0) return [];
|
||||
return await page.evaluate(
|
||||
async (items: Array<{ videoId: string; dataUri: string }>, visualProperties: string[]) => {
|
||||
const injectedIds: string[] = [];
|
||||
const pendingDecodes: Array<Promise<void>> = [];
|
||||
const replacementLayoutProperties = new Set([
|
||||
"width",
|
||||
@@ -435,7 +444,13 @@ export async function injectVideoFramesBatch(
|
||||
// Don't paint a frame over a hidden host — if an existing replacement
|
||||
// <img> is still around from when the host was visible, hide it so it
|
||||
// doesn't bleed through a sibling host that *is* visible on this seek.
|
||||
if (hasImg && img) img.style.visibility = "hidden";
|
||||
//
|
||||
// Use `!important` so the inline hide survives `applyDomLayerMask`'s
|
||||
// stylesheet `#${showId} *{visibility:visible !important}` when the
|
||||
// sub-comp host happens to land in the active layer's `show` set —
|
||||
// important stylesheet beats non-important inline, but important
|
||||
// inline beats important stylesheet.
|
||||
if (hasImg && img) img.style.setProperty("visibility", "hidden", "important");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -522,10 +537,12 @@ export async function injectVideoFramesBatch(
|
||||
// GSAP-controlled value.
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
injectedIds.push(item.videoId);
|
||||
}
|
||||
if (pendingDecodes.length > 0) {
|
||||
await Promise.all(pendingDecodes);
|
||||
}
|
||||
return injectedIds;
|
||||
},
|
||||
updates,
|
||||
[...MEDIA_VISUAL_STYLE_PROPERTIES],
|
||||
@@ -577,11 +594,16 @@ export async function syncVideoFrameVisibility(
|
||||
} else {
|
||||
// Inactive (or ancestor-hidden) video: hide both. Use visibility only
|
||||
// (never opacity) so we never clobber GSAP-controlled inline opacity.
|
||||
// Use `!important` on the <img> hide so `applyDomLayerMask`'s
|
||||
// important stylesheet rule (`#${showId} *{visibility:visible !important}`)
|
||||
// cannot revive a stale frame when the sub-comp host lands in the
|
||||
// active layer's `show` set — same mask-defense reasoning as the
|
||||
// `isVisualAncestorHidden` branch in `injectVideoFramesBatch`.
|
||||
video.style.removeProperty("display");
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
if (hasImg) {
|
||||
img.style.visibility = "hidden";
|
||||
img.style.setProperty("visibility", "hidden", "important");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user