feat(hdr): GSAP transforms and border-radius masks on HDR video#290
Merged
vanceingalls merged 4 commits intomainfrom Apr 20, 2026
Merged
feat(hdr): GSAP transforms and border-radius masks on HDR video#290vanceingalls merged 4 commits intomainfrom
vanceingalls merged 4 commits intomainfrom
Conversation
This was referenced Apr 16, 2026
Collaborator
Author
a502eb3 to
8b9096f
Compare
1ea1b2b to
528ea17
Compare
3e571ca to
c8cb0d0
Compare
2837b2c to
eb10879
Compare
20ca4e3 to
dc89f54
Compare
eb10879 to
94d5cab
Compare
f3bbf79 to
8396136
Compare
94d5cab to
0d98d3f
Compare
05c2b21 to
e523278
Compare
0d98d3f to
ad300ba
Compare
e523278 to
b9670cc
Compare
5e4ed3d to
cdbe189
Compare
947cb3d to
83dd040
Compare
cdbe189 to
ae9bd45
Compare
Collaborator
miguel-heygen
left a comment
There was a problem hiding this comment.
Automated review (Claude Code)
Reviewed against CLAUDE.md. Stack-level meta on #268.
Critical
- Main HDR composite path ignores
transformandborderRadius—packages/producer/src/services/renderOrchestrator.ts:~1012-1064(verified against PR diff). The loop builds[scaleX, 0, 0, scaleY, el.x, el.y]fromel.width/srcW, el.height/srcH, el.x, el.yonly.el.transformandel.borderRadius— the fields this PR adds toElementStackingInfo— 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. ThreadparseTransformMatrix(el.transform)andel.borderRadiusthrough, or unify with the path at ~line 386 via a single helper.
Important
- Zero tests for DOM-walking math —
getViewportMatrix,getEffectiveOpacity,getEffectiveBorderRadius(videoFrameInjector.ts:282-375). No unit coverage; not exercised bylayerCompositor.test.ts. These run insidepage.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-originpercentage resolution is suspect —videoFrameInjector.ts:370-373.parseFloat(origin[0] || "0")on"50% 50%"returns50(treated as px, not 50% of width). Chrome normalizestransformOriginto px for rendered elements, but keyword origins (center) and unresolved percentages can leak through. Assert px or resolve%againstoffsetWidth/offsetHeight.offsetParentnull forposition:fixedsilently truncates matrix walk —videoFrameInjector.ts:355-359. Fixed wrappers with GSAP transforms → dropped. Fall back toparentElementwhenoffsetParentis 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 wholequeryElementStackingpage.evaluaterejects → frame dropped. Wrap in try/catch, fall back to identity with a warning. offsetLeft/Topcomposition order —videoFrameInjector.ts:364-374. Translating byoffsetLeft/Topbefore the transform-origin sandwich is fine for a single transform, butoffsetParentsemantics 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 dims —
videoFrameInjector.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=0triggers the fallback torect.width, which is the very transformed-bounding-box value the field exists to avoid. PreferhtmlEl instanceof HTMLElement ? htmlEl.offsetWidth : Math.round(rect.width).hdrExtractionDimsgates onel.width > 0—renderOrchestrator.tsextraction-dim block. HDR elements that start invisible (GSAPautoAlpha: 0at t=0, common pattern) haveel.width = 0→ fall back to composition dims → original bug reintroduced. UselayoutWidth/layoutHeight.
Strengths
Number.isNaN(val) ? 1 : valcorrectly implemented (videoFrameInjector.ts:325);opacity:0trap 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()+parseTransformMatrixas 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.
Collaborator
Author
|
Thanks. Fixes landed. Addressed:
Not addressed:
|
1ae0ee9 to
cae950e
Compare
8bfdcbc to
dca5d69
Compare
7ad09c5 to
fa0afc1
Compare
dca5d69 to
8b68cd7
Compare
miguel-heygen
approved these changes
Apr 19, 2026
8b68cd7 to
fe57d14
Compare
b76fe30 to
aa33ab9
Compare
fe57d14 to
c7f106c
Compare
aa33ab9 to
bbaf4c3
Compare
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.
c7f106c to
840df0c
Compare
bbaf4c3 to
d95a0ab
Compare
840df0c to
eab565e
Compare
9c4ef2f to
7493f87
Compare
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.
eab565e to
559f8d5
Compare
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
559f8d5 to
32ddda3
Compare
Merge activity
|
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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 CSSmatrix(a,b,c,d,e,f)strings into[a,b,c,d,e,f]tuples.Accumulated viewport matrix:
getViewportMatrix()— Walks theoffsetParentchain from element to viewport, accumulating position offsets and CSS transforms at each level. Correctly handlestransform-originusing 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. UsesNumber.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 foroverflow:hidden+ border-radius. Resolves percentage values (e.g.,50%for circles) viaoffsetWidth/offsetHeight.Layout dimensions for extraction:
offsetWidth/offsetHeight(unaffected by CSS transforms) instead ofgetBoundingClientRect()(which returns the transformed bounding box and wobbles under rotation).Files changed
packages/engine/src/utils/alphaBlit.tsblitRgb48leAffine(),parseTransformMatrix(),roundedRectAlpha(),cornerAlpha()packages/engine/src/services/videoFrameInjector.tsgetViewportMatrix(),getEffectiveOpacity(),getEffectiveBorderRadius(),layoutWidth/layoutHeightonElementStackingInfopackages/producer/src/services/renderOrchestrator.tsHow 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