Skip to content

feat(hdr): GSAP transforms and border-radius masks on HDR video#290

Merged
vanceingalls merged 4 commits intomainfrom
feat/hdr-phase-3
Apr 20, 2026
Merged

feat(hdr): GSAP transforms and border-radius masks on HDR video#290
vanceingalls merged 4 commits intomainfrom
feat/hdr-phase-3

Conversation

@vanceingalls
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls commented Apr 16, 2026

Summary

HDR video elements with GSAP animations (position, scale, rotation, opacity) and CSS border-radius rendered without any transforms applied — the video just sat at (0,0) full-size. This PR adds affine transform support and rounded-corner masking for natively-composited HDR video.

What it does

Affine blit with bilinear interpolation:

  • blitRgb48leAffine() — Takes a 4x4 DOMMatrix and maps each destination pixel back to source coordinates via the inverse transform. Bilinear interpolation between the 4 nearest source pixels produces smooth edges under rotation and non-integer scaling. Optional opacity and border-radius parameters.
  • parseTransformMatrix() — Parses CSS matrix(a,b,c,d,e,f) strings into [a,b,c,d,e,f] tuples.

Accumulated viewport matrix:

  • getViewportMatrix() — Walks the offsetParent chain from element to viewport, accumulating position offsets and CSS transforms at each level. Correctly handles transform-origin using the CSS sandwich: translate(origin) × M × translate(-origin). This is critical because GSAP animates transforms on wrapper divs, not directly on the video element.

Effective opacity:

  • getEffectiveOpacity() — Multiplies opacity values walking up the ancestor chain. Uses Number.isNaN() (not || 1) so opacity:0 isn't incorrectly treated as 1.

Border-radius masks:

  • roundedRectAlpha() — Per-pixel anti-aliased rounded-rectangle mask with support for independent corner radii.
  • getEffectiveBorderRadius() — Walks ancestors for overflow:hidden + border-radius. Resolves percentage values (e.g., 50% for circles) via offsetWidth/offsetHeight.

Layout dimensions for extraction:

  • Uses offsetWidth/offsetHeight (unaffected by CSS transforms) instead of getBoundingClientRect() (which returns the transformed bounding box and wobbles under rotation).

Files changed

File What changed
packages/engine/src/utils/alphaBlit.ts blitRgb48leAffine(), parseTransformMatrix(), roundedRectAlpha(), cornerAlpha()
packages/engine/src/services/videoFrameInjector.ts getViewportMatrix(), getEffectiveOpacity(), getEffectiveBorderRadius(), layoutWidth/layoutHeight on ElementStackingInfo
packages/producer/src/services/renderOrchestrator.ts Affine blit path, extraction at layout dimensions, border-radius parameter passing

How to test

Render a composition with an HDR video that has GSAP scale + rotation animation and a border-radius: 50% wrapper (circle mask). The video should rotate smoothly with round edges — no wobble, no sharp corners.

Stack position

5 of 6 — Stacked on #289 (z-ordered layers). Adds transform and masking support to the HDR blit that the layer compositor uses.

🤖 Generated with Claude Code

@vanceingalls vanceingalls force-pushed the feat/hdr-phase-2 branch 2 times, most recently from 1ea1b2b to 528ea17 Compare April 16, 2026 00:51
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-3 branch 2 times, most recently from 3e571ca to c8cb0d0 Compare April 16, 2026 00:54
@vanceingalls vanceingalls marked this pull request as ready for review April 16, 2026 00:54
@vanceingalls vanceingalls changed the title fix(hdr): extract at element display dimensions, not composition dimensions feat(hdr): GSAP transforms and border-radius masks on HDR video Apr 17, 2026
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-3 branch 2 times, most recently from 05c2b21 to e523278 Compare April 18, 2026 00:47
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-2 branch 2 times, most recently from 5e4ed3d to cdbe189 Compare April 18, 2026 06:16
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-3 branch 2 times, most recently from 947cb3d to 83dd040 Compare April 18, 2026 07:54
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review (Claude Code)

Reviewed against CLAUDE.md. Stack-level meta on #268.

Critical

  • Main HDR composite path ignores transform and borderRadiuspackages/producer/src/services/renderOrchestrator.ts:~1012-1064 (verified against PR diff). The loop builds [scaleX, 0, 0, scaleY, el.x, el.y] from el.width/srcW, el.height/srcH, el.x, el.y only. el.transform and el.borderRadius — the fields this PR adds to ElementStackingInfo — are never read here. GSAP rotations and border-radius masking (the stated purpose of this PR) do not apply on this path. The bounding-rect comment says "getBoundingClientRect() already reflects all ancestor transforms" — but that gives the axis-aligned bounding box of a transformed element, destroying rotation information. Thread parseTransformMatrix(el.transform) and el.borderRadius through, or unify with the path at ~line 386 via a single helper.

Important

  • Zero tests for DOM-walking mathgetViewportMatrix, getEffectiveOpacity, getEffectiveBorderRadius (videoFrameInjector.ts:282-375). No unit coverage; not exercised by layerCompositor.test.ts. These run inside page.evaluate, so they need either jsdom/puppeteer tests or extraction into pure helpers taking DOM-like inputs. Transform-origin sandwich, percentage border-radius resolution, HDR-specific opacity-start-from-parent — all untested.
  • transform-origin percentage resolution is suspectvideoFrameInjector.ts:370-373. parseFloat(origin[0] || "0") on "50% 50%" returns 50 (treated as px, not 50% of width). Chrome normalizes transformOrigin to px for rendered elements, but keyword origins (center) and unresolved percentages can leak through. Assert px or resolve % against offsetWidth/offsetHeight.
  • offsetParent null for position:fixed silently truncates matrix walkvideoFrameInjector.ts:355-359. Fixed wrappers with GSAP transforms → dropped. Fall back to parentElement when offsetParent is null, or document the limitation.
  • No NaN/singular-matrix guard on new DOMMatrix(cs.transform)videoFrameInjector.ts:373. Malformed or 3D-matrix input throws; the whole queryElementStacking page.evaluate rejects → frame dropped. Wrap in try/catch, fall back to identity with a warning.
  • offsetLeft/Top composition ordervideoFrameInjector.ts:364-374. Translating by offsetLeft/Top before the transform-origin sandwich is fine for a single transform, but offsetParent semantics shift when an ancestor has a CSS transform (it becomes a containing block). Add a test with two stacked transformed wrappers.

Suggestions

  • Percentage border-radius uses clipping-ancestor dimsvideoFrameInjector.ts:289-295. Correct per CSS spec, but the resulting radii are then applied to the video at its own dimensions in the consumer. If the clipping ancestor is larger than the video → wrong mask size. Verify the consumer converts to video coordinate system (or uses layoutWidth/Height consistently).
  • layoutWidth: htmlEl.offsetWidth || Math.round(rect.width)videoFrameInjector.ts:404-405. offsetWidth=0 triggers the fallback to rect.width, which is the very transformed-bounding-box value the field exists to avoid. Prefer htmlEl instanceof HTMLElement ? htmlEl.offsetWidth : Math.round(rect.width).
  • hdrExtractionDims gates on el.width > 0renderOrchestrator.ts extraction-dim block. HDR elements that start invisible (GSAP autoAlpha: 0 at t=0, common pattern) have el.width = 0 → fall back to composition dims → original bug reintroduced. Use layoutWidth/layoutHeight.

Strengths

  • Number.isNaN(val) ? 1 : val correctly implemented (videoFrameInjector.ts:325); opacity:0 trap explicitly noted in-comment — matches PR description.
  • Freeze-on-last-frame fallback (renderOrchestrator.ts:1024-1033) is good UX parity with Chrome video.
  • Extraction at display dimensions → eliminates stride/scale mismatch.
  • DOMMatrix.toString() + parseTransformMatrix as the IPC boundary between browser and Node is a clean design.
  • Debug stderr from #289 was correctly removed here.

🤖 Automated review. See stack meta-comment on #268.

@vanceingalls
Copy link
Copy Markdown
Collaborator Author

vanceingalls commented Apr 18, 2026

Thanks. Fixes landed.

Addressed:

  • Main HDR composite path ignores transform / borderRadius — the feat(hdr): layered HDR compositing, shader transitions, and HDR image support #268 refactor collapsed both composite paths into a single blitHdrVideoLayer helper that reads el.transform via parseTransformMatrix and el.borderRadius directly, so this is already resolved by the later PR landing together.
  • offsetParent null for position:fixedgetViewportMatrix now falls back to parentElement when offsetParent is null. Fixed wrappers with GSAP transforms still contribute to the accumulated matrix.
  • transform-origin percentage resolution — extracted a resolveLength() helper that handles both px and % (resolving % against offsetWidth/offsetHeight).
  • NaN/singular-matrix guardnew DOMMatrix(cs.transform) is now wrapped in try/catch, and all 6 matrix fields are validated with Number.isFinite before being applied. Malformed ancestor transforms fall back to identity without rejecting the whole frame.
  • hdrExtractionDims gates on el.width > 0 (autoAlpha:0 trap) — already using layoutWidth/layoutHeight (from offsetWidth/offsetHeight), which are non-zero even when a GSAP autoAlpha: 0 wrapper sets display:none isn't involved.
  • layoutWidth fallback style — replaced el as HTMLElement cast with el instanceof HTMLElement ? el : null type guard. offsetWidth/offsetHeight are now only accessed on actual HTMLElement instances; SVGElement/MathMLElement/etc. fall through to the bounding-rect fallback cleanly instead of relying on the cast silently producing 0.

Not addressed:

  • Zero tests for DOM-walking math (getViewportMatrix, getEffectiveOpacity, getEffectiveBorderRadius) — these run inside page.evaluate so they need either a jsdom/puppeteer setup or extraction into pure helpers. Agreed it's a gap; worth a dedicated test-infra PR so we don't half-test them.
  • Two stacked transformed wrappers test — same reasoning; belongs with the DOM-walk tests above.
  • Percentage border-radius clipping-ancestor dims vs. consumer coordinate system — the consumer (blitRgb48le*) applies radii to the element at its own dimensions, so clipping-ancestor resolution is what we want for the mask size. Worth explicit verification in a dedicated test.

@vanceingalls vanceingalls force-pushed the feat/hdr-phase-3 branch 2 times, most recently from 8bfdcbc to dca5d69 Compare April 18, 2026 23:45
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-2 branch 2 times, most recently from 7ad09c5 to fa0afc1 Compare April 19, 2026 00:19
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-2 branch 2 times, most recently from b76fe30 to aa33ab9 Compare April 19, 2026 21:32
vanceingalls added a commit that referenced this pull request Apr 19, 2026
offsetWidth/offsetHeight are only defined on HTMLElement (not on
SVGElement, MathMLElement, etc.). Casting unconditionally can yield
undefined-by-runtime even though TypeScript thinks otherwise. Use an
instanceof check and fall back to the bounding rect dimensions when the
element is not an HTMLElement.

Addresses deferred review feedback from PR #290.
@vanceingalls vanceingalls force-pushed the feat/hdr-phase-2 branch 2 times, most recently from 9c4ef2f to 7493f87 Compare April 19, 2026 23:27
vanceingalls added a commit that referenced this pull request Apr 19, 2026
offsetWidth/offsetHeight are only defined on HTMLElement (not on
SVGElement, MathMLElement, etc.). Casting unconditionally can yield
undefined-by-runtime even though TypeScript thinks otherwise. Use an
instanceof check and fall back to the bounding rect dimensions when the
element is not an HTMLElement.

Addresses deferred review feedback from PR #290.
@graphite-app graphite-app Bot changed the base branch from feat/hdr-phase-2 to main April 19, 2026 23:30
vanceingalls and others added 4 commits April 19, 2026 23:31
Affine blit with bilinear interpolation for GSAP-animated HDR video.
Accumulated viewport matrix via DOMMatrix chain with transform-origin
handling. Effective opacity ancestor walk. Rounded-rectangle mask with
anti-aliased corners and percentage resolution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fall back to parentElement when offsetParent is null (position:fixed).
- Resolve transformOrigin percentages against offsetWidth/Height.
- Guard DOMMatrix with Number.isFinite + try/catch for malformed input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
offsetWidth/offsetHeight are only defined on HTMLElement (not on
SVGElement, MathMLElement, etc.). Casting unconditionally can yield
undefined-by-runtime even though TypeScript thinks otherwise. Use an
instanceof check and fall back to the bounding rect dimensions when the
element is not an HTMLElement.

Addresses deferred review feedback from PR #290.
When the page is loaded by the Hyperframes engine, `init()` would happily set
up GL contexts and html2canvas captures even though the engine composites
every transition itself from `window.__hf.transitions` metadata. That work was
both wasted and harmful: scenes were left at `opacity: 0` (waiting for the
async capture to resolve) when the engine sampled the DOM, so
`queryElementStacking()` reported them invisible and the layered HDR
compositor produced black frames between transitions.

Engine mode is detected via `window.__HF_VIRTUAL_TIME__` (the shim the engine
injects). When present, `initEngineMode()` runs instead of the GL pipeline and
schedules deterministic opacity flips:

- `tl.set('#toId',   { opacity: 1 }, T)`     // crossfade start
- `tl.set('#fromId', { opacity: 0 }, T + d)` // crossfade end

`tl.set` (zero-duration tweens) is required instead of `tl.call`, because
`tl.call` only fires in the direction of playback. The engine's warmup loop
seeks forward through every transition start and then the main render loop
seeks back to t=0 — `tl.call`-set state would get stuck. `tl.set` tweens
revert cleanly on backward seeks.

The new path still calls the shared `registerTimeline()` helper so the timeline
shows up on `window.__timelines[compId]` for the engine to drive.

Made-with: Cursor
@graphite-app
Copy link
Copy Markdown

graphite-app Bot commented Apr 19, 2026

Merge activity

  • Apr 19, 11:32 PM UTC: Graphite rebased this pull request, because this pull request is set to merge when ready.
  • Apr 20, 12:03 AM UTC: @vanceingalls merged this pull request with Graphite.

@vanceingalls vanceingalls merged commit 8548a17 into main Apr 20, 2026
22 checks passed
vanceingalls added a commit that referenced this pull request Apr 20, 2026
The --hdr flag was silently dropped when combined with --docker because
renderDocker did not include it in the args passed to the in-container
hyperframes render invocation. Forwarding it produces the same HDR10 MP4
(HEVC, yuv420p10le, BT.2020 PQ, mastering display + max-cll) as a local
render — verified end-to-end with the new hdr-pq regression fixture.

This PR also fills in the documentation gaps from the HDR feature stack
(#288, #289, #290, #265, #268), which each added a slice of HDR support
without pulling it together into a single guide.

Code change:
- packages/cli/src/commands/render.ts: refactor renderDocker to use a new
  pure helper buildDockerRunArgs (packages/cli/src/utils/dockerRunArgs.ts)
  that explicitly threads every render flag — including --hdr, --gpu,
  --quiet — into the docker run argv. Snapshot-tested in dockerRunArgs.test.ts
  so any future flag drop fails loudly.

Docs:
- New docs/guides/hdr.mdx — top-level HDR guide: quickstart, how detection
  works, source media requirements (HDR video + 16-bit HDR PNG), output
  format constraints, ffprobe verification, Docker rendering, limitations,
  common pitfalls
- docs/guides/common-mistakes.mdx — "Expected HDR but got SDR" entry; --hdr
  is detection, not force; notes Docker now supports it
- docs/guides/rendering.mdx — --hdr row in flags table; HDR card under
  Next Steps
- docs/packages/cli.mdx — expanded --hdr description with link to the guide
- docs/packages/producer.mdx — HDR Output section under RenderConfig
- docs/packages/engine.mdx — HDR APIs section (color-space utilities +
  WebGPU readback runtime); now compiles because DEFAULT_HDR10_MASTERING
  and HdrMasteringMetadata are re-exported from packages/engine/src/index.ts
- docs/docs.json — register the new HDR guide in nav

Review feedback from PR #346:
- packages/engine/src/index.ts — export DEFAULT_HDR10_MASTERING and
  HdrMasteringMetadata so the engine docs example compiles
- docs/guides/hdr.mdx — drop fabricated "animated HDR images log a warning
  and use first frame" claim (not implemented); clarify that HDR <img> is
  16-bit PNG only and other formats fall back to SDR; broaden the yt-dlp
  example to bestvideo[dynamic_range=HDR]+bestaudio so AV1-HDR streams
  are also picked up; qualify the Docker render benchmark claim
- packages/producer/src/services/renderOrchestrator.ts — bump the HDR + non-mp4
  fallback from log.info to log.warn and make the message actionable

Regression test (hdr-pq):
- packages/producer/tests/hdr-regression — delete the broken sub-tree.
  The old hdr-pq composition relied on a CDN shader-transitions library
  and window.__hf injection with scenes defaulting to opacity:0, so when
  the library failed to load most of the render was invisible. The fixture
  was also nested two directories deep, so discoverTestSuites (which only
  scans direct children of tests/) never picked it up.
- packages/producer/tests/hdr-pq — new top-level fixture: 5s of an HDR10
  HEVC Main10 BT.2020 PQ source clip with a static SDR DOM overlay. Source
  is a 5s excerpt (1:18–1:23) from https://youtu.be/56hEFqjKG0s, downloaded
  via yt-dlp and re-encoded to HEVC Main10 / 1920x1080 / 30fps with HDR10
  mastering metadata. Self-contained: no external CDNs, no custom fonts,
  no animation libraries. NOTICE.md captures attribution.
- packages/producer/src/regression-harness.ts — add optional hdr boolean
  to TestMetadata.renderConfig, validate it, and forward it to
  createRenderJob so HDR fixtures actually run through the HDR pipeline.
- .github/workflows/regression.yml — exclude hdr from the fast shard and
  add a dedicated hdr shard so the new fixture runs on CI.
- .gitattributes — LFS-track packages/producer/tests/*/src/*.mp4 so source
  clips don't bloat the repo.
- packages/engine/src/services/frameCapture.ts — inject a no-op __name
  shim into every page via evaluateOnNewDocument. tsx transforms the
  engine source on the fly with esbuild's keepNames, which wraps every
  named function in __name(fn, "name"). When page.evaluate() serializes
  a callback those calls would crash with "__name is not defined" because
  the helper only exists in Node. The shim makes the engine work whether
  it's imported from compiled dist or from source via tsx.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants