Skip to content

fix: producer render diverges from preview for sub-composition root styling#1886

Merged
miguel-heygen merged 7 commits into
mainfrom
fix/subcomposition-root-style-render
Jul 3, 2026
Merged

fix: producer render diverges from preview for sub-composition root styling#1886
miguel-heygen merged 7 commits into
mainfrom
fix/subcomposition-root-style-render

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Fixes #1847

Root cause

The producer's render path stripped a sub-composition's authored root element and inlined only its children, so any CSS anchored on that root (its id or classes) matched nothing in the compiled HTML even though it resolved fine in Studio preview.

Fixing that surfaced two more divergences between render and preview, both caught by the existing regression suite once the flatten path was actually exercised end to end:

  • A composition styling its own box via the bare [data-composition-id="X"] selector lost that styling once the authored root became a wrapper one level below the host, since the styling only ever reached the host.
  • A sub-composition host mounted without its own data-composition-id (an "anonymous" host) lost its own composition id entirely once the authored root was flattened, breaking both its own root-styling CSS and any script that self-references it (document.querySelector('[data-composition-id="X"]')), and by extension the runtime's own timeline-seek resolution for that composition.

Changes

  1. packages/producer/src/services/htmlCompiler.ts — pass flattenInnerRoot (the same function the preview bundler already uses) into the producer's sub-composition inliner, so the render-time DOM shape matches preview: the authored root survives as a child of the host instead of being discarded.
  2. packages/core/src/compiler/compositionScoping.ts — a bare root [data-composition-id="X"] selector now compiles to a :has()/:not() pair that matches exactly one of the host or the flattened wrapper, whichever one actually holds the composition's content. Targeting both (an earlier attempt) double-applies additive properties like padding, since the wrapper is nested inside the host.
  3. packages/core/src/compiler/inlineSubCompositions.ts — restore the composition's own id onto the flattened wrapper when the host has none of its own, matching what the render path did before flattening was introduced and what preview already does.
  4. packages/core/src/runtime/startResolver.ts — the walk-up-to-host fallback for resolving a composition's start time only recognized a host carrying data-composition-src or data-composition-id. Once inlining strips data-composition-src and replaces it with data-composition-file, an anonymous host matched neither, so any composition mounted through one had its own timeline seeked from the wrong start time.

Verification

  • Full data-composition-src fixture sweep (28 fixtures) run 3 times in Docker on linux/amd64 (matching CI's render environment; local macOS headless rendering was flaky/non-deterministic and not used for verification) — all pass with 0 failed frames on every run.
  • Every fixture's golden baseline is unchanged: 4 are byte-identical to main, the remaining 2 are frame-identical (verified by direct pixel comparison, not just the harness's PSNR check) with only encoder bitrate differing.
  • packages/core unit tests: 966/966 passing, including new regression tests for the root-box-styling and anonymous-host-id-restoration behavior added in this PR.
  • packages/producer unit tests: 548/557 passing; the 9 failures are pre-existing and unrelated to this change (a different in-flight file with no diffs from this PR).

The render path stripped a sub-composition's authored root element and
inlined only its children, so any CSS anchored on that root (its id or
classes) matched nothing in the compiled HTML even though it resolved
fine in Studio preview. Pass flattenInnerRoot (the same
prepareFlattenedInnerRoot used by the preview bundler) into the
producer's inlineSubCompositions call so the render-time DOM shape
matches preview: the authored root survives as a child of the host.
…ppers

Compositions that style their own box via the bare `[data-composition-id="X"]`
selector (e.g. `display: flex` to center their children) rely on that rule
landing on whatever element actually parents their content. Once
flattenInnerRoot preserves the authored root as a wrapper below the host
(data-hf-inner-root), the bare root selector was only ever rewritten onto the
host, one level too high, so the composition's own children lost their flex
or grid layout context entirely.

Rewrite a bare root composition-id selector to a compound-OR targeting both
the host and its data-hf-inner-root descendant, so the box styling reaches
whichever element actually holds the composition's content. Selectors with a
descendant part (e.g. `[data-composition-id="X"] .title`) are unaffected,
since a plain descendant combinator already matches at any depth.
The previous fix rewrote a bare root [data-composition-id] box selector to
match both the host and the flattened wrapper. That double-applies any
additive property (padding, margin, a non-zero transform): the wrapper is
nested inside the host, so a rule like `padding-top: 200px` shifted content
down twice instead of once, visible as overlapping elements in compositions
that combine flex alignment with an offset (e.g. a captions overlay using
`align-items: flex-start; padding-top: 200px`).

Use :has()/:not() to target exactly one match: the wrapper when it exists
(the flattened case), or the host when it doesn't (the documented
non-flattened fallback for callers that omit flattenInnerRoot).
flattenInnerRoot strips data-composition-id from the flattened wrapper,
assuming the host already carries the composition's identity. That's true
for hosts authored with their own data-composition-id, but not for a host
mounted via data-composition-src with no id of its own (an "anonymous"
host) — a pattern the producer already had a dedicated regression test for
(missing-host-comp-id) and correctly supported before flattenInnerRoot was
wired in, via a different code path that preserved the whole composition
element as-is.

Once nothing in the render DOM carries the composition's own id, its root-
styling CSS (`[data-composition-id="X"] { ... }`) and any script that
self-references it (e.g. `document.querySelector('[data-composition-id="X"]')`)
both silently stop resolving. Restore the id onto the wrapper specifically
when the host has none of its own, matching what the pre-flatten producer
path did and what preview visually expects.
…n-file

createRuntimeStartTimeResolver's walk-up-to-host fallback (for an inner root
with no data-start of its own) only recognized a host carrying
data-composition-src or data-composition-id. Once inlining strips
data-composition-src and replaces it with data-composition-file, an
anonymous host (no data-composition-id of its own) matched neither check,
so the walk-up silently failed and the composition's own timeline got
seeked using its start time as the fallback (0) instead of the host's real
data-start.

This surfaced once the previous commit restored data-composition-id onto
the flattened wrapper for anonymous hosts: the composition's self-query
started resolving to that wrapper, but resolveStartForElement still
couldn't find its actual mount time through the host, so any composition
with real entrance/exit animation timing rendered stuck at t=0 (visually:
missing or frozen at its hidden initial state) instead of at the correct
point in its own timeline.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed at 86ead17af5b284679c6d0d3375e56bce6599469b.
Peer scan: no reviews posted yet — this is the first pass.
Companion issue #1847: sub-composition authored root wrapper (its classes/id) survives in Studio preview but is stripped at render, so any CSS anchored on that root — .scene-wrapper .title, #scene-root, [data-composition-id="scene"] { padding } — silently no-ops in the compiled MP4.

Summary — Converges producer and preview on a single flatten path: producer's inlineSubCompositions now takes the same prepareFlattenedInnerRoot the preview bundler and runtime compositionLoader already used, so the authored root survives as a data-hf-inner-root wrapper below the host in both worlds. Three self-consistent follow-ups fall out: (a) bare [data-composition-id="X"] box selectors compile to a :has()/:not() pair that lands on exactly one of host-or-wrapper — nice touch, the commit-3 fix for double-application of additive properties like padding-top: 200px is the kind of correction that only shows up because a real fixture blew up; (b) anonymous hosts get their composition id restored onto the wrapper so root-scoped CSS + document.querySelector('[data-composition-id="X"]') self-references still resolve; (c) startResolver walks up through the new post-inlining data-composition-file marker so anonymous-host wrapper timelines seek at the right start. Deletes the now-obsolete data-hf-authored-id post-hoist producer-only branch. 28-fixture sweep run 3x, byte/pixel-identical goldens.

The convergence-on-shared-helper shape is exactly what I'd hope for in this class of bug — no per-side patch. The commit sequence also reads clean: each commit's message calls out the specific divergence the previous commit exposed, which is honest engineering. Nothing here rises to blocker.

Concerns

  • 🟠 Anonymous-host id restoration lives in inlineSubCompositions.ts:375-384, not in the shared prepareFlattenedInnerRoot. The runtime compositionLoader.ts:527 calls prepareFlattenedInnerRoot(innerRoot) directly, bypassing inlineSubCompositions, and does NOT restore data-composition-id on the wrapper for an anonymous host. That means an anonymous host loaded live at runtime (not through the bundler / not through the producer) exhibits the same silent-no-op the PR fixes for render: its own root-styling CSS and self-querySelector won't resolve, and startResolver won't find a data-composition-id on the wrapper to anchor the walk-up (though data-composition-file on the host may not be present in the pure-runtime shape either, since inlining is what writes that). Two questions I'd want you to sanity-check before I stamp: (1) does an anonymous host ever reach compositionLoader.load() — i.e., can a data-composition-src host with no data-composition-id be resolved live at runtime, e.g. in Studio's live-preview mode, not through the bundle? (2) if yes, should the id-restoration move into prepareFlattenedInnerRoot itself and take the composition id as a parameter, so the two call sites don't drift? If the answer to (1) is "no, runtime-live only serves bundled/inlined output," this is dead code and I withdraw the concern.

  • 🟠 sub-comp-id-selector fixture description is now stale and misleading. packages/producer/tests/sub-comp-id-selector/meta.json still says "Documents that sub-compositions using #ID selectors may render differently between preview and render due to the producer stripping the inner root element. Workaround: use [data-composition-id] selectors instead of #ID." — which is precisely the divergence this PR eliminates. If someone hits an unrelated #id-scoping bug in six months and greps for it, this text will misdirect them into thinking the workaround is still load-bearing. Worth updating the description to reflect that #id selectors now round-trip through data-hf-authored-id via prepareFlattenedInnerRoot, so the divergence is closed.

Nits

  • 🟡 The comment at packages/producer/src/services/htmlCompiler.ts:751-754 says "matching the preview/runtime shape (compositionLoader's prepareFlattenedInnerRoot)", but the import at the top is from @hyperframes/core/compiler — the canonical implementation lives in htmlBundler.ts:407, and compositionLoader.ts:188 has its own near-duplicate. Referencing the bundler as the canonical shape (and mentioning that compositionLoader mirrors it) would be more accurate.

  • 🟡 compoundAuthoredRoot is still a live option on inlineSubCompositions (inlineSubCompositions.ts:76,163,260,295, compositionScoping.ts:105) even though the producer's only consumer just swapped to flattenInnerRoot. If no in-repo caller still passes compoundAuthoredRoot: true, worth deleting the option in a follow-up — otherwise it's a Chekhov's gun the next reader has to decode.

  • 🟡 The commit sequence has a squashable authorial arc — commit 3 fixes commit 2, and both fix the same "root box styling on flattened wrapper" story. If the merge is squash-and-merge (the repo default IIUC), the final message can just describe the :has()/:not() shape; the intermediate double-apply detour is nice narrative but not future-load-bearing. No AI-authorship trailers to strip.

Questions

  • The PR body notes "9 producer unit-test failures are pre-existing and unrelated (a different in-flight file with no diffs from this PR)." — worth naming which file, so a reviewer coming in cold doesn't have to git-blame to trust the claim. Which in-flight PR / file?

  • Any observability signal that would have caught this earlier? The failure mode is "compiled HTML looks fine, CSS rule matches nothing, render is silently unstyled" — no error, no warning. If there's a preview-regression harness that pixel-diffs preview vs. render (I see preview-parity is a passing check), does it cover the class-based-wrapper case now? If it doesn't, adding a fixture that uses a bare .wrapper-class .child selector — the exact repro shape from issue #1847 — would guard the exact failure class from re-emerging.

What I didn't verify

  • Whether compositionLoader.load() is ever called with an anonymous host at runtime in practice. The code path exists; the operational question is whether Studio's live-preview mode (or any other consumer) ever hands it one.
  • The claim that all 6 golden-baseline compositions are frame-identical modulo encoder bitrate. Trusting the PR body + green CI.
  • Cross-repo consumers of @hyperframes/* types — the diff doesn't change any exported type shapes (still Element in / Element out on flattenInnerRoot), just wires in an existing export. Low risk here.

— Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review of HF #1886 @ 86ead17 — fix: producer render diverges from preview for sub-composition root styling

Verdict: 🟢 LGTM

Small, disciplined fix. Root cause is correctly diagnosed: producer's inliner was calling into shared inlineSubCompositions with compoundAuthoredRoot: true (host-tagged authored-id workaround) rather than flattenInnerRoot: prepareFlattenedInnerRoot (preview's actual DOM shape). Swapping to the shared preparer converges producer's render DOM onto the runtime compositionLoader's shape. All three named divergences are addressed in this PR, tests exercise each, and no other consumer of the pre-fix helper is left holding the old contract.

Findings

F1 — Root-cause fix is surgical, not a band-aid ✅

packages/producer/src/services/htmlCompiler.ts swaps compoundAuthoredRoot: trueflattenInnerRoot: prepareFlattenedInnerRoot (the same fn packages/core/src/compiler/htmlBundler.ts:798 already passes and that packages/core/src/runtime/compositionLoader.ts:527 runs at preview time). The 10-line block that manually stamped data-hf-authored-id on hosts is removed, because the shared inliner's flatten path now does that work via the wrapper. Producer + bundler + runtime now all go through one preparer. Confirmed by grep: compoundAuthoredRoot: true now has zero non-test callers repo-wide at head SHA (only unit tests that still exercise the legacy path).

F2 — Bare-root CSS scope rewrite: exactly-one-of semantics ✅

packages/core/src/compiler/compositionScoping.ts:118-129 rewrites a bare [data-composition-id="X"] selector to scope:not(:has([data-hf-inner-root])), scope > [data-hf-inner-root]. The XOR shape is the right choice — an earlier attempt (per commit 4da03228) targeted both host AND wrapper and would have double-applied additive properties (padding, margin, non-zero transform). The unit test at compositionScoping.test.ts:625 verifies the exactly-one match via parseHTML + querySelectorAll against both flattened and non-flattened DOM shapes; that's the right level of proof, not just a string assertion. Non-bare selectors ([data-composition-id="X"] .title) intentionally fall through to the plain scope-prefix path (test at compositionScoping.test.ts:660).

F3 — Anonymous-host id restoration ✅

packages/core/src/compiler/inlineSubCompositions.ts:375-384: when !compId && inferredCompId, the composition's own id is stamped back onto the wrapper (which prepareFlattenedInnerRoot had stripped). Correct scoping — compId is the host-authored id (null for anonymous hosts) and inferredCompId comes from the inner root inside the resolved sub-composition file (line 234). Test at inlineSubCompositions.test.ts:178 uses a local flattenInnerRoot stub matching prepareFlattenedInnerRoot's attribute-stripping contract, and asserts both the id restoration and that the composition's own [data-composition-id="scoped-text"] CSS still resolves.

F4 — startResolver walk-up chain is complete ✅

packages/core/src/runtime/startResolver.ts:172-179 adds data-composition-file to the parent-marker check. This is necessary because inlineSubCompositions.ts strips data-composition-src and replaces it with data-composition-file post-inlining (line ~406 of inlineSubCompositions.ts), so an anonymous host post-inlining had NEITHER data-composition-src nor data-composition-id — the walk-up broke silently. Test at startResolver.test.ts:196 covers the anonymous-host post-inlining shape explicitly. The comment now correctly enumerates all three markers with their lifecycle stage (runtime / bundled / post-inline).

F5 — Dispatch chain audit: no orphan consumers 🟢

Grepped every inlineSubCompositions importer at head SHA:

  • packages/producer/src/services/htmlCompiler.ts — now passes flattenInnerRoot.
  • packages/core/src/compiler/htmlBundler.ts:798 — already passed flattenInnerRoot.
  • packages/studio-server/src/helpers/subComposition.ts, packages/cli/src/commands/validate.ts, packages/parsers/src/subCompositionValidity.ts — reference the module but don't call it; no orphan.

The compoundAuthoredRoot option remains declared in inlineSubCompositions.ts:76 and continues to power the fall-through branch in compositionScoping.ts:139, but it's dead in production — only exercised by two unit tests. Not blocking (harmless as a documented legacy shape), but a follow-up to delete would remove a decoy code path. Filing this as a soft nit only.

F6 — Scope discipline ✅

+192/-19 across 8 files, all directly tied to the three named divergences. No unrelated refactors ride along. Layer boundaries respected — no upward imports from producer into anything it shouldn't touch; the new prepareFlattenedInnerRoot import from @hyperframes/core/compiler is already in the exported surface at packages/core/src/compiler/index.ts:33.

F7 — Regression proof is credible ✅

28-fixture Docker sweep × 3 runs, 4 byte-identical + 2 frame-identical vs main, plus 966/966 core unit tests including the three new regression cases. This is the right level of proof for a producer-vs-preview convergence — pixel deltas would surface any drift the unit tests miss.

Soft nit (non-blocking)

Preview parity for anonymous-host script self-references. The runtime compositionLoader.ts:527 calls prepareFlattenedInnerRoot(innerRoot) directly on anonymous hosts and does NOT restore data-composition-id on the wrapper the way the fixed producer/bundler path does. That means document.querySelector('[data-composition-id="X"]') inside a composition script would still resolve nothing in preview for an anonymous host — while it now resolves in render. That's the mirror asymmetry: render used to be broken and preview worked; now render works but a specific corner of preview (anonymous host + self-referencing script) may still be broken. If that's already true on main and outside the scope of this PR, ignore — the PR description's "preview works fine" claim covers root-styling (which the CSS scoping rewrite handles via data-hf-inner-root, not the id), and the anonymous-host script self-ref case may just be documented-unsupported. Worth a follow-up issue if it isn't.

Prior reviewer state at post time

No other reviews on the PR (Rames not yet posted at 86ead17 / freshness-checked immediately pre-post).

Review by Via

- Fix a comment in htmlCompiler.ts that misattributed the canonical
  prepareFlattenedInnerRoot to compositionLoader.ts; the canonical
  implementation lives in htmlBundler.ts, which compositionLoader.ts mirrors
  with its own copy for the live-loaded case.
- Update sub-comp-id-selector's stale fixture description: it used to
  document the #ID divergence this PR closes and recommend a workaround
  that no longer applies.
- Add a regression test proving compositionLoader.ts's anonymous-host path
  does not share the bug this PR fixes: an anonymous host's
  authoredCompositionId is null, so mountCompositionContent's innerRoot
  lookup never runs and prepareFlattenedInnerRoot is never reached for it.
  It falls through to a raw document.importNode() instead, which never
  stripped data-composition-id in the first place.
- Add producer and end-to-end regression coverage for the literal repro
  from issue #1847 (a class, not just an id, on the authored root, styled
  via a descendant selector) — this shape wasn't covered by any existing
  fixture or unit test.
Verified deterministic across 3 runs in Docker on linux/amd64.
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Thanks both, addressed in a5b87f1 (on top of 86ead17).

@james-russo-rames-d-jusso Concern 1 / @vanceingalls soft nit: anonymous-host id restoration in compositionLoader.ts

Traced this end to end and added a regression test (compositionLoader.test.ts, "preserves data-composition-id unflattened for a host with no id of its own") to prove it rather than just asserting it.

Answer to question 1: yes, an anonymous host does reach loadExternalCompositions(). But mountCompositionContent's innerRoot lookup only runs if (params.authoredCompositionId) (line 397), and authoredCompositionId comes straight from host.getAttribute("data-composition-id") with no fallback inference from the fetched file's own root id. For a true anonymous host that's null, so innerRoot stays null and the code falls to the else if (params.hasTemplate) branch, a raw document.importNode(contentNode, true) of the whole template content. prepareFlattenedInnerRoot (line 527) is only reached when the host's own id matches an element inside the fetched content, i.e. the non-anonymous case this PR already handles correctly.

Net effect: the raw import never strips data-composition-id in the first place, so root-styling CSS and self-referencing querySelector('[data-composition-id="X"]') already resolve for anonymous hosts in live preview today. No fix needed there, this is answer "no" from your framing, just arrived at via the more specific path rather than a blanket runtime-only-serves-bundled-output claim. Left a comment pointing at the new test for whoever reads this next.

Concern 2: stale sub-comp-id-selector description

Fixed, it now describes the current (fixed) contract instead of the old workaround.

Nits

  • Comment attribution in htmlCompiler.ts fixed to point at htmlBundler.ts:407 as canonical, with compositionLoader.ts noted as the runtime mirror.
  • compoundAuthoredRoot: leaving it as a follow-up per both your suggestions rather than touching public API surface in this PR.
  • Commit squashing: leaving as is, happy to squash-merge with a clean final message if that's the repo default.

Questions

  • The pre-existing unrelated producer test failures are all in packages/producer/src/services/render/stages/captureStreamingStage.test.ts, zero commits or diffs from this branch touch that file.
  • Observability: added the literal issue Rendered video drops sub-composition root styles (producer inlining strips the authored root wrapper, diverging from preview) #1847 repro shape (a class on the authored root, not just an id, referenced via a descendant selector) as both a producer unit test and a new end-to-end fixture, sub-comp-class-selector. It renders the exact .scene-wrapper .title case from the bug report and is now part of the regression sweep (29 fixtures, verified 3x deterministic in Docker).

All local and Docker verification re-run after these changes: 967/967 core unit tests, 29/29 fixture sweep clean.

@miguel-heygen miguel-heygen merged commit 7e8a146 into main Jul 3, 2026
65 of 66 checks passed
@miguel-heygen miguel-heygen deleted the fix/subcomposition-root-style-render branch July 3, 2026 19:08
HackingCorp added a commit to HackingCorp/ltc-motion that referenced this pull request Jul 3, 2026
HackingCorp added a commit to HackingCorp/ltc-motion that referenced this pull request Jul 3, 2026
…pstream heygen-com#1886

The upstream fix (flattenInnerRoot wired into the producer inliner so
its DOM shape matches the preview bundler) supersedes our selector-side
compensation (:is() root-class rewrite + host class merge). Keeping
both risks double-applying root styles — exactly what heygen-com#1886's
:has()/:not() pair exists to avoid. Core compiler suite (120 tests,
including upstream's heygen-com#1886 regressions) and producer htmlCompiler
suite (78 tests) pass on the merged tree.
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.

Rendered video drops sub-composition root styles (producer inlining strips the authored root wrapper, diverging from preview)

3 participants