Skip to content

perf(engine): reduce init overhead in headless capture sessions#1718

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/capture-init-regression
Jun 28, 2026
Merged

perf(engine): reduce init overhead in headless capture sessions#1718
miguel-heygen merged 3 commits into
mainfrom
fix/capture-init-regression

Conversation

@miga-heygen

Copy link
Copy Markdown
Contributor

Problem

Per-frame capture time (captureAvgMs) roughly doubled between v0.6.42 and v0.7.5 — p50 went from ~64ms to ~162ms in production benchmarks (#1715, #1653). The regression is init-time overhead being amortized into per-frame timing, not actual per-frame cost.

Root cause

Two bottlenecks in initializeSession:

  1. GSAP proxy queue drain via rAF — The HF_EARLY_STUB batches GSAP timeline operations (.to(), .from(), etc.) and drains them 100 ops per rAF tick. In BeginFrame mode, rAF ticks at the warmup loop's 33ms interval. For a composition with 8000 tweens: 80 ticks × 33ms = ~2.6 seconds of drain time. In screenshot mode, rAF runs at native ~16ms, but it's still the dominant init cost.

  2. Sequential init pollspollVideosReady, pollImagesReady + decodeAllImages, document.fonts.ready, and waitForOptionalTailwindReady ran sequentially despite being independent DOM queries.

Fix

  1. Synchronous GSAP proxy flush — Expose flushPendingOperations() as window.__hfFlushSync in the early stub. Call it from initializeSession before pollHfReady. In headless mode there's no UI responsiveness concern, so draining the queue instantly eliminates the largest init-time cost for tween-heavy compositions.

  2. Parallel init polls — Run the four independent readiness checks concurrently via Promise.all instead of sequentially. Wall-clock time drops to the slowest individual check instead of the sum.

Both optimizations apply to screenshot and BeginFrame capture modes.

Expected impact

For a composition with 8000 GSAP tweens on an AWS c6a.4xlarge (the production bench environment):

  • Before: ~2600ms drain + sequential polls (~500ms) = ~3100ms init overhead → amortized across 125 frames = ~25ms/frame extra
  • After: ~10ms sync flush + parallel polls (~max of individual checks) = ~200ms init overhead → ~1.6ms/frame extra

The exact improvement depends on composition complexity (tween count, media element count), but the GSAP flush alone should recover the majority of the regression for tween-heavy compositions at p50.

Testing

  • oxlint + oxfmt --check clean on changed files
  • Existing browserManager.test.ts tests pass (21/21)
  • The __hfFlushSync call is guarded with ?.() — no-op if the stub isn't present (e.g., non-producer page loads)

Closes #1715

— Miga

miga-heygen and others added 2 commits June 25, 2026 15:01
Two changes to reduce the ~2.5x captureAvgMs regression between v0.6.42
and v0.7.5 (issue #1715 / #1653):

1. Synchronous GSAP proxy flush: expose flushPendingOperations() as
   window.__hfFlushSync in the early stub, then call it from
   initializeSession before pollHfReady. In headless mode there's no UI
   responsiveness concern, so draining the proxy queue instantly
   eliminates the dominant init cost for tween-heavy compositions
   (previously 100 ops/rAF-tick at 33ms intervals).

2. Parallel init polls: run pollVideosReady, pollImagesReady +
   decodeAllImages, document.fonts.ready, and waitForOptionalTailwindReady
   concurrently via Promise.all instead of sequentially. These are
   independent DOM queries that don't depend on each other's completion.

Both optimizations apply to screenshot and BeginFrame capture modes.

Closes #1715

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The initial sync flush drained the GSAP proxy queue but left the
"timelines built" signal deferred via setTimeout(0). Now the flush
also force-publishes immediately when the queue is empty, so the
readiness cascade (__hfTimelinesBuilding → __renderReady → __hf.duration)
can complete without waiting for a deferred macrotask + poll cycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@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 the current head f7797418 and the full three-file diff.

Specific checks:

  • packages/producer/stubs/hf-early-stub.ts:399 exposes a guarded synchronous __hfFlushSync that drains the queued GSAP timeline operations and immediately publishes the built signal only when the queue is empty. This preserves the existing deferred/rAF path for normal page execution while giving headless capture an explicit fast path.
  • packages/engine/src/services/frameCapture.ts:970 and :1106 call the flush after page.goto(..., domcontentloaded) and before pollHfReady in both screenshot and BeginFrame modes, so readiness still gates rendering after the forced drain.
  • packages/engine/src/services/frameCapture.ts:987 and :1129 parallelize only independent readiness waits; the previous image/video warning behavior and decodeAllImages step are preserved.
  • Generated inline stub is updated with the source stub change.

CI is fully green, including Build, Test, Typecheck, CLI smoke, CodeQL, Windows render/tests, perf, preview parity, and all regression shards. No blockers found.

Verdict: APPROVE
Reasoning: The patch addresses the init-time bottleneck without changing frame-seek semantics, preserves both capture-mode readiness contracts, and has full CI/regression coverage green.

— Magi

@miguel-heygen miguel-heygen merged commit 35a01d9 into main Jun 28, 2026
50 checks passed
@miguel-heygen miguel-heygen deleted the fix/capture-init-regression branch June 28, 2026 14:36
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.

https://github.com/heygen-com/hyperframes/issues/1653 - still reproducible in 0.75.0

2 participants