Skip to content

fix(core): words in a scene the preview mounts later keep their spaces - #7342

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/preview-mounted-scene-spaces
Nov 7, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
fix/preview-mounted-scene-spaces

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

What

In Studio's preview, words in a scene that mounts after load keep the spaces between them. Since 0.8.200 a line built as one span per word ("over the hill") showed as "overthe hill" once its scene mounted.

Why

Since #6189 the preview parks scenes away from the playhead and mounts them as it comes near. A mounted host keeps the display:none the visibility pass wrote on its stand-in, and its scripts run while it is still hidden.

A script that builds GSAP tweens on transform properties makes GSAP read each target's current transform. For an element it cannot lay out, GSAP 3.14's CSSPlugin moves the element to <html>, reads it and puts it back with parent.insertBefore(el, el.nextElementSibling). nextElementSibling skips text, so in <span>over</span> <span>the</span> the first word goes back after the space: " " over the, which renders as "overthe". The DOM stays that way until the scene is mounted again.

Before #6189 every scene's script ran at load under the first-pass rule visibility: hidden, which keeps layout, so GSAP never moved anything.

Related work

Fixes a regression from #6189.

How

runSceneScripts in packages/core/src/runtime/init.ts is the one path both a scene mount and an edit's scene swap run scripts through. While a scene's scripts run, a host that the visibility pass hid by display is given its authored display back, opacity: 0 !important and inert, so it is laid out but neither seen nor hit-tested (the HTML spec hit-tests an inert subtree as if pointer-events were none, and its children cannot opt back in). Afterwards the opacity is put back unless a script wrote its own, inert comes off unless the author set it, and the display:none goes back if the visibility pass still has the host hidden.

Renders are not affected: they never park scenes.

What the unseen layout does to the rest of the frame

Measured in the real Studio preview on the project from the report, with this branch's CLI, sampling every animation frame across 8 scene mounts in 7 scenes (seeks into four scenes, then playback across two cuts):

  • Neighbours: every mounted host was position: absolute, so out of flow. No other displayed scene's layout box (offsetLeft, offsetTop, offsetWidth, offsetHeight) changed between the last frame before a mount and the mount itself.
  • Clicks: a 3x3 grid of elementFromPoint probes over the preview, at the moment of each mount and in each of the 11 frames a mount spanned, never landed in the host being mounted (0 of 171).
  • Paint: the mounting host's computed opacity was 0 in all 11 frames, and no scene script overwrote it.
  • Not covered: this project has no in-flow (static) scene host, so a mount that changes an in-flow neighbour's position was not measured.

The words 0.8.x already joined

0.8.151 also shows some words without a space between them. Those are written that way: counting word spans whose previous sibling is another word span directly in each scene's source markup gives the same number per scene as 0.8.151 shows in the preview, in all 11 scenes (from 0 to 8 per scene, 34 in all). Tracing every DOM insert of a word span on 0.8.151 across load and a seek finds none, so no GSAP move is involved there. With this branch the scenes mounted at the playhead show the same counts as their markup.

Test plan

  • Unit tests added/updated: the preview's scene window > lays out a scene the visibility pass hid by display, unseen, while its script builds its timeline records the host's display, opacity and inert from inside the mounting scene's own script, then checks all three are put back.
  • Fails without the fix: with init.ts from main, lays out a scene the visibility pass hid by display, unseen, while its script builds its timeline fails with AssertionError: expected 'none false' to be 'block 0 true'. With the fix, init.swapScenes.test.ts passes (169 tests), 3 runs in a row.
  • Real Studio preview (headless Chrome, Linux), a project whose text is one span per word, seeked into a scene parked at load: on 0.9.19 the space sits before the first word and the gap measures 0 px; with this branch's CLI it measures 22.1 px, and the words joined per mounted scene match 0.8.151's count (only those the markup joins).
  • Published versions bisected on that project: 0.8.199 good, 0.8.200 first bad.
  • A render of the same frame on 0.9.19 shows the space, so only the preview was affected.
  • oxfmt and oxlint on the changed files; core tsc --noEmit clean.
  • Comments follow CONTRIBUTING.md "Comments".

Size

Small on purpose: one helper around the shared scene-script path plus its test.

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 111 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

Unstable (2)

  • seqplainkeys-none-px-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true
  • seqab-none-pct-r0-root-z100: tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approve at 5b41cc78. I found no blockers.

Fix. runSceneScripts now wraps runSceneScriptsLaidOut in layOutHiddenHosts with a try/finally. It only touches hosts that displayBeforeHide says the visibility pass hid. For those hosts it:

  • puts back the authored display, keeping its priority;
  • sets opacity: 0 !important and inert while the scripts run;
  • afterwards restores the author's opacity only if ours is still in place, removes inert only if the author didn't set it, and re-hides with the same style.display = "none" that hideByDisplay writes, but only if the record still exists.

That last condition covers a visibility pass that runs during the font wait. If it shows the host, restoreDisplay deletes the record, so we don't hide it again. If it hides the host, the record is still there and we leave it hidden. The edit-swap path uses fresh importNode hosts with no record, so the filter leaves them alone. Only the mount path changes.

Fails without the fix (checked). I ran init.swapScenes.test.ts at the head with init.ts from main. lays out a scene the visibility pass hid by display, unseen, while its script builds its timeline fails with AssertionError: expected 'none false' to be 'block 0 true' (1 failed, 168 passed). With the fix, all 169 passed in each of 3 runs. vitest run src/runtime in core: 2858 passed.

Non-blocking notes:

  • The laid-out window lasts through runScriptsAfterFonts, waitForFonts, and a setTimeout(0), so frames can paint during it. The visibility pass hides by display only in-flow (static/relative/sticky) leaf clips. For one of those that applyClipLayout did not absolutize, its neighbours could shift for those frames. The PR already says this case was not measured. A follow-up test with an in-flow sibling would cover it.
  • If the playhead reaches the scene while its fonts are still loading, it stays at opacity 0 until the scripts finish. This is a short delay, not a wrong frame.

CI was still running when I reviewed, with no failed jobs so far.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 7, 2026
Merged via the queue into main with commit 1ddd425 Nov 7, 2026
146 of 147 checks passed
@miguel-heygen
miguel-heygen deleted the fix/preview-mounted-scene-spaces branch November 7, 2026 03:03
SanHsien added a commit to SanHsien/hyperframes that referenced this pull request Nov 7, 2026
…-com#7336-heygen-com#7358

Adopt heygen-com#7304 (without CLI renderDiagnostic, needs heygen-com#6725), heygen-com#7331, heygen-com#7332, heygen-com#7334,
heygen-com#7337, heygen-com#7336, heygen-com#7339, heygen-com#7289, heygen-com#5594, heygen-com#6900, heygen-com#7322, heygen-com#7342, heygen-com#7343, heygen-com#7350 (onProgress
only), heygen-com#7352, heygen-com#7348, heygen-com#7344, heygen-com#7353, heygen-com#7356 and heygen-com#7355. Defer heygen-com#5708 (needs heygen-com#6911) and
heygen-com#7345 (READ_VERSION 4 vs 7); heygen-com#7307 not applicable. Re-read 14 moved pins, pin 6
new PRs (monitored 126). Make checkReportFile module-private and suppress the
cross-package closeTestChrome duplicate for fallow.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: SophiaHuangCc <129631224+SophiaHuangCc@users.noreply.github.com>
Co-authored-by: Dante <duanjl.china@gmail.com>
Co-authored-by: Muzzy5150 <muzzy5150yt@gmail.com>
Co-authored-by: ump45nose <52391318+ump45nose@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant