Skip to content

fix(core): the preview transport keeps ticking in a hidden tab - #5872

Merged
miguel-heygen merged 3 commits into
heygen-com:mainfrom
krishs0404:fix/hidden-tab-transport
Oct 19, 2026
Merged

miguel-heygen merged 3 commits into
heygen-com:mainfrom
krishs0404:fix/hidden-tab-transport

Conversation

@krishs0404

Copy link
Copy Markdown
Contributor

Fixes cause 1 of #5710. Cause 2 (the unmute on a natural buffer end in webAudioTransport.ts) is not part of this PR.

Problem

A hidden document runs no animation frames, but its media keeps playing. The transport ticked on requestAnimationFrame only, so syncRuntimeMedia stopped running while the tab was hidden. A clip was never paused at the end of its window and the next clip never started until the tab was shown again. Someone listening to a draft in another tab hears the raw source past each trim.

Change

In packages/core/src/runtime/init.ts:

  • requestTransportTick schedules the next tick on a 16 ms timer while document.visibilityState === "hidden", and on requestAnimationFrame otherwise. cancelTransportTick cancels either.
  • All four places that requested a frame (scheduleNextTransportFrame, wakeTransport, the initial tick) and the teardown cancel now go through these two helpers. wakeTransport checks for a pending tick of either kind.
  • A visibilitychange listener moves a pending tick to the scheduler the new state calls for: a frame requested just before the tab hid would never fire. Teardown removes the listener.
  • A paused, hidden transport still parks as before; the park timer is untouched.

Chromium does not throttle the timers of a page that is playing audio, so the hidden tick keeps time (per the report in #5710).

Tests

Two tests in packages/core/src/runtime/transportPark.test.ts:

  • Hidden tab ends a clip: plays a 1 s audio clip, hides the document, never steps an animation frame, and advances 100 timer ticks. The playhead passes 1 s and the clip's element is paused. Fails without the change.
  • Handoff on visibility change: a pending frame is cancelled when the tab hides, the transport keeps playing on the timer, and a frame is requested again when the tab shows.

Both fail on main and pass here.

Ran locally:

  • bunx vitest run in packages/core: 4510 pass, 1 fail. 7 suites fail with and without this change, because they cannot resolve unbuilt sibling packages (@hyperframes/lint, @hyperframes/studio-server).
  • bun run --filter @hyperframes/core test:hyperframe-runtime-ci: passes.
  • tsc --noEmit, oxlint, oxfmt, and the comment checks: clean.

Not covered by an automated test: real Chromium behaviour in a hidden tab. jsdom has no throttling, so the tests hold animation frames and drive the timer directly.

Notes

🤖 Generated with Claude Code

@miguel-heygen miguel-heygen 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.

Reviewed at f3920d67.

Strengths

  • init.ts: every transport scheduler now goes through requestTransportTick / cancelTransportTick. I grepped transportRafId at this head: the only remaining uses are inside those two helpers and scheduleNextTransportFrame's reset, so no site still asks only "is a frame pending" while a hidden-tab timer is pending.
  • The visibilitychange handoff covers the edge that is easy to miss: a frame requested just before the tab hides would never fire.
  • Teardown cancels both kinds of tick and removes the listener.

Verified locally

  • transportPark.test.ts: 44/44 pass at this head. With init.ts restored from the merge base, the two new tests fail ("keeps the transport ticking on a timer while the document is hidden", "moves a pending tick between animation frames and a timer as visibility changes"), so they are not vacuous.
  • transportTick takes no arguments, so calling it from setTimeout instead of requestAnimationFrame loses nothing.

Nits (not blocking)

  • Chromium only skips timer throttling for an audible page. A hidden tab playing muted or silent media is clamped to about 1 timer per second (and intensive throttling after 5 minutes), so there a clip ends up to ~1 s late instead of on time. That is still far better than never ending, but worth a line in the comment above HIDDEN_TRANSPORT_TICK_MS.
  • As the PR notes, #5754 touches the same two files; whichever lands second rebases.

Verdict: APPROVE
Reasoning: The change routes every transport scheduler through one place, the tests fail without it, and nothing outside the transport loop changes.

@krishs0404

Copy link
Copy Markdown
Contributor Author

Real-browser check, since the unit tests run in jsdom and cannot show Chromium's hidden-tab behaviour.

Setup. Google Chrome 155.0.8059.82 (headed), macOS on Apple silicon, driven by puppeteer. One composition with the report's two trimmed clips of one 8 s WAV: a1 plays source 0.5 s to 1.5 s at composition 0 to 1 s, and a2 plays source 5.0 s to 6.5 s at composition 1 s to 2.5 s. The page loads the runtime bundle built from main, then the one built from this branch, with the same composition each time. Play starts with the tab visible. A second tab is then opened and focused, so the first tab is genuinely hidden (document.visibilityState === "hidden", not emulated), while a1 is still inside its window.

Without the change (main). The playhead advances, but a1 keeps playing through the rest of the source and a2 never starts:

composition time a1 (paused, source time) a2 (paused, source time)
0.67 s no, 1.17 s yes, 0 s
1.47 s no, 1.97 s yes, 0 s
3.49 s no, 3.99 s yes, 0 s

a1 was only paused when the tab was shown again (source time 4.4 s).

With the change. a1 is paused at the end of its window and a2 starts at its own in-point and stops at its own end, all while hidden:

composition time a1 (paused, source time) a2 (paused, source time)
0.60 s no, 1.10 s yes, 0 s
1.00 s no, 1.50 s no, 5.00 s
1.41 s yes, 1.52 s no, 5.41 s
2.21 s yes, 1.52 s no, 6.21 s
2.62 s yes, 1.52 s yes, 6.50 s

Limits: a single run on one machine and one Chrome version, with an 8 s synthetic tone, not the reporter's talking-head take. It covers cause 1 only; I did not exercise the Web Audio path of cause 2.

auto-merge was automatically disabled October 19, 2026 04:00

Head branch was pushed to by a user without write access

@krishs0404

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I took the nit: e089d8e extends the comment above HIDDEN_TRANSPORT_TICK_MS to say that a hidden page playing only muted or silent media is clamped to about one timer a second, so a clip there can end up to a second late. Comment-only change, no behaviour change.

@jrusso1020 jrusso1020 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.

Approving at bf3d5a27. Thanks, Krish, this is a clean fix.

On your three questions:

  • The claim: confirmed. #5710 has no other linked PR and nobody on our side is working on it, so cause 1 is yours.
  • The approach: no change wanted. It's the one the issue proposed, and it fits how the transport already schedules itself (details below).
  • Cause 2 (the unmute on a natural end in webAudioTransport.ts): leave it with the reporter for now. They already run a patch with unit tests and a browser test for that race (5 of 6 runs fail without it), and they offered to open the PR. If they haven't opened one in a few days, ask on the issue and take it then.

What I checked at this head:

  • Every scheduler path goes through the two new helpers. transportRafId is now only read or written inside requestTransportTick/cancelTransportTick, plus the reset at the top of scheduleNextTransportFrame. So no site still asks only "is a frame pending" while a hidden timer is pending. wakeTransport uses transportTickPending() for both kinds of pending tick. The initial tick and teardown go through the helpers too, and teardown removes the listener.
  • The timer path keeps the tick contract. It clears its id before calling transportTick(). That function takes no arguments, so calling it from a timer is the same as from a frame. The tail (scheduleNextTransportFrame) still decides between ticking and parking, so a paused, hidden transport parks as before. A parked transport has nothing pending, so visibilitychange leaves it alone, and the next wake uses whichever scheduler fits the current state.
  • Nothing else is affected. Only how the tick is scheduled changes. What a tick does, the park timer and WebAudioTransport are untouched. A page that starts hidden simply begins on the timer.
  • Your A/B on Chrome 155 is credible. The tab is genuinely hidden by focusing a second tab, not emulated. The same composition runs on both builds, and the before/after tables show exactly the failure and the fix (a1 pauses at source 1.52 s and a2 plays 5.00 to 6.50 s while hidden). It's one run with a synthetic tone, which you note. That's enough for a scheduling change whose behaviour the unit tests already pin.
  • The tests cover both halves: the stall (the playhead passes 1 s and the element is paused with no animation frames), and the hand-off both ways.
  • The two later commits since Miguel's approval at f3920d67 change comments only.

Notes:

  • CI workflows haven't run on this fork PR yet. Only Graphite and WIP report, so a maintainer needs to approve the workflow runs. The merge queue runs the required checks before anything lands.
  • The commits aren't signed. That's fine here: the queue squash-merges, and GitHub signs the squash.

No live run on our side. I didn't run your tests locally; the review is from reading the code and your A/B.

— Rames

@jrusso1020

Copy link
Copy Markdown
Collaborator

@krishs0404 one thing before this can merge: all three commits (f3920d67, e089d8e3, bf3d5a27) show as unsigned, and this repo requires signed commits. Please sign them and force-push. For example, with signing set up (GitHub's guide):

git rebase --exec 'git commit --amend --no-edit -S' origin/main
git push --force-with-lease

Each commit should then show Verified on GitHub. Note that a new push needs a fresh approval, so ping here once it's done.

Also: I've approved the CI workflow runs for this head, so the checks are running now.

— Rames

krishs0404 and others added 3 commits October 18, 2026 21:13
A hidden document runs no animation frames, but its media keeps playing.
The transport ticked on requestAnimationFrame only, so syncRuntimeMedia
stopped running: a clip was never paused at the end of its window and the
next clip never started until the tab was shown again.

While document.visibilityState is "hidden", the transport now schedules
its tick on a 16 ms timer instead. A visibilitychange listener moves a
pending tick to whichever scheduler the new state calls for, and teardown
removes it. A paused, hidden transport still parks as before.

Refs heygen-com#5710 (cause 1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ment share

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@krishs0404
krishs0404 force-pushed the fix/hidden-tab-transport branch from bf3d5a2 to 1717b0b Compare October 19, 2026 04:14
@krishs0404

Copy link
Copy Markdown
Contributor Author

Re-signed. All three commits are now SSH-signed and GitHub shows them as Verified (4dab867, d2d6583, 1717b0b). The diff is unchanged; only the commit hashes differ from the version that was approved at f3920d6. Sorry for missing this the first time.

@jrusso1020 jrusso1020 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.

Re-approving at 1717b0bf, now that the commits are signed. Thanks @krishs0404.

  • All three commits (4dab867f, d2d6583c, 1717b0bf) show as verified.
  • git range-diff against the head I approved before (bf3d5a27) shows all three patches identical, on the same base (84349457). Only the signatures changed, so my earlier review still stands.
  • I've approved the CI workflow runs for this head. They need to pass before it can merge.

Need: yes · Size: ok · Reuse: the existing transport tick · Simpler: no.

No live run.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 19, 2026
Merged via the queue into heygen-com:main with commit b168d0b Oct 19, 2026
79 checks passed
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.

3 participants