mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(engine): preserve GSAP-applied opacity across DOM-layer captures
SDR clips inside an HDR composition were rendering at full opacity even
when the user had animated their wrapper opacity (e.g. fade-in or
yoyo). Two bugs in the per-layer screenshot path conspired to drop the
GSAP-applied opacity on the floor:
1. removeDomLayerMask was unconditionally calling
`el.style.removeProperty("opacity")` on every wrapper after each
layer capture. applyDomLayerMask only ever sets `visibility`, so the
only inline opacity present is the value GSAP wrote. Stripping it
between layer captures means that on the next capture (at the same
timestamp), GSAP's `totalTime(t, false)` no-ops because the timeline
is already at that time — the opacity is never restored, and the
wrapper renders fully opaque.
2. injectVideoFramesBatch was reading the source <video>'s computed
opacity via `parseFloat(computedStyle.opacity) || 1` and copying it
onto the injected <img>. Because syncVideoFrameVisibility forces the
<video> to `opacity: 0 !important` to hide it during capture, the
computed value is always 0, which `|| 1` then silently flips to
full opacity. The <img> is a sibling of the <video> inside the same
wrapper, so it should inherit opacity from the wrapper directly
instead of having a value hard-set on it.
Fix both: drop the opacity removal in removeDomLayerMask, skip opacity
when copying visual properties from <video> to <img>, and explicitly
clear any stale inline opacity on the <img> so it inherits from the
wrapper that GSAP is animating.
Made-with: Cursor
This commit is contained in:
@@ -331,12 +331,16 @@ export async function applyDomLayerMask(
|
||||
/**
|
||||
* Tear down the mask installed by applyDomLayerMask.
|
||||
*
|
||||
* Removes the mask stylesheet and clears the inline `visibility`/`opacity`
|
||||
* properties set on `extraHideIds` (and their `__render_frame_*` siblings).
|
||||
* Inline values are removed rather than restored — `injectVideoFramesBatch`
|
||||
* and `syncVideoFrameVisibility` re-apply the correct inline values for
|
||||
* native videos and injected imgs at the start of every frame, so subsequent
|
||||
* captures get a clean slate.
|
||||
* Removes the mask stylesheet and clears the inline `visibility` properties
|
||||
* set on `extraHideIds` (and their `__render_frame_*` siblings).
|
||||
*
|
||||
* IMPORTANT: We do NOT strip inline `opacity` here. applyDomLayerMask only
|
||||
* ever sets `visibility` (never `opacity`), so any inline opacity present on
|
||||
* a wrapper was put there by user animation code (typically GSAP) and must
|
||||
* survive across per-layer captures. GSAP's seek with suppress-events does
|
||||
* not re-apply tweens when the timeline is already at the target time, so if
|
||||
* we strip opacity here and then seek to the same time for the next layer,
|
||||
* GSAP won't put it back and the wrapper will render fully opaque.
|
||||
*/
|
||||
export async function removeDomLayerMask(page: Page, extraHideIds: string[]): Promise<void> {
|
||||
await page.evaluate(
|
||||
@@ -347,7 +351,6 @@ export async function removeDomLayerMask(page: Page, extraHideIds: string[]): Pr
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.style.removeProperty("visibility");
|
||||
el.style.removeProperty("opacity");
|
||||
}
|
||||
const img = document.getElementById(`__render_frame_${id}__`);
|
||||
if (img) img.style.removeProperty("visibility");
|
||||
@@ -372,7 +375,6 @@ export async function injectVideoFramesBatch(
|
||||
let img = video.nextElementSibling as HTMLImageElement | null;
|
||||
const isNewImage = !img || !img.classList.contains("__render_frame__");
|
||||
const computedStyle = window.getComputedStyle(video);
|
||||
const computedOpacity = parseFloat(computedStyle.opacity) || 1;
|
||||
const sourceIsStatic = !computedStyle.position || computedStyle.position === "static";
|
||||
|
||||
if (isNewImage) {
|
||||
@@ -408,6 +410,13 @@ export async function injectVideoFramesBatch(
|
||||
img.style.zIndex = computedStyle.zIndex;
|
||||
|
||||
for (const property of visualProperties) {
|
||||
// Skip opacity — the <video> is forced to opacity:0 !important below
|
||||
// (and by syncVideoFrameVisibility) to hide it during capture, so its
|
||||
// computed opacity is not the user's intent. The injected <img> is a
|
||||
// sibling of the <video> inside the same wrapper, so it inherits the
|
||||
// wrapper's opacity (where GSAP applies animated values) just like
|
||||
// the <video> would have.
|
||||
if (property === "opacity") continue;
|
||||
if (
|
||||
sourceIsStatic &&
|
||||
(property === "top" ||
|
||||
@@ -431,8 +440,8 @@ export async function injectVideoFramesBatch(
|
||||
.catch(() => undefined)
|
||||
.then(() => undefined),
|
||||
);
|
||||
img.style.opacity = String(computedOpacity);
|
||||
img.style.visibility = "visible";
|
||||
img.style.removeProperty("opacity");
|
||||
video.style.setProperty("visibility", "hidden", "important");
|
||||
video.style.setProperty("opacity", "0", "important");
|
||||
video.style.setProperty("pointer-events", "none", "important");
|
||||
|
||||
Reference in New Issue
Block a user