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:
Vance Ingalls
2026-04-19 14:10:38 -07:00
parent 11811dd4cc
commit 9924b53f55
@@ -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");