Skip to content

fix(core,cli): defer __renderReady until root timeline is bound#1061

Merged
miguel-heygen merged 6 commits into
mainfrom
fix/snapshot-seek-parity
May 24, 2026
Merged

fix(core,cli): defer __renderReady until root timeline is bound#1061
miguel-heygen merged 6 commits into
mainfrom
fix/snapshot-seek-parity

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented May 24, 2026

Summary

  • Root cause in init.ts: __renderReady was set at the same time as __playerReady, before the root timeline was bound. Moved it to fire only after bindRootTimelineIfAvailable() succeeds — either immediately, via the deferred setTimeout(0) rebinding for bundled compositions, or via the async .finally() for externally loaded compositions.
  • Snapshot consumer fix: waits for __renderReady (the now-truthful signal) instead of __timelines || __playerReady. Uses renderSeek() with frame quantization and GSAP ticker tick, matching the parity harness. Awaits document.fonts.ready before capturing.

Root cause

init.ts set __renderReady = true on line 1547, at the same time as __playerReady. But timeline binding happens later:

  • Synchronously at bindRootTimelineIfAvailable() (line 1632)
  • Via setTimeout(0) for bundled compositions (line 1642)
  • Asynchronously via loadExternalCompositions (line 1461)

This meant __renderReady lied — it said "safe for deterministic frame capture" while the player had no timeline. The snapshot command (and any consumer using __renderReady) could call renderSeek() on a player with no captured timeline.

Duration fallback removal

The old snapshot code walked __timelines looking for .duration as a fallback. This was dead code — it used the nonexistent .duration property (not a method call) which always returned undefined, falling through to the DOM attribute anyway. The new code uses getDuration() (the actual PlayerAPI method) with the DOM data-duration attribute as fallback. Since __renderReady guarantees the player is fully initialized, getDuration() should always be available at seek time.

Verification

Instrumented the runtime to confirm:

  • __timelines is set 15-56ms before __playerReady (same synchronous block, but the readiness check pattern matters for future consumers)
  • After fix: __renderReady fires 1.4-3.4ms after __playerReady, confirming the timeline is bound before the signal
  • renderSeek(5) works correctly at the moment __renderReady is set (verified across warm-grain, play-mode, product-promo)
  • The old __timelines manual seek fallback produces visually different frames from renderSeek at 4/5 timestamps on warm-grain — the animation states diverge due to missing time quantization and sibling timeline activation

Fallow audit

Fallow exits non-zero due to pre-existing duplication in init.ts (clone groups at lines 757-805 and 1612-1905, untouched by this PR) and inherited complexity findings across both files. The captureSnapshots function was already over the CRAP threshold pre-PR; the net -66 line reduction improves it slightly but doesn't cross it below. No new complexity or duplication introduced.

Test plan

  • 985 core tests pass
  • 419 CLI tests pass
  • bun run build passes
  • Pre-commit lint, format, typecheck pass
  • hyperframes snapshot works on product-promo and warm-grain (built CLI)
  • __renderReady timing verified via instrumented probes (3 examples, 3 runs each)

Closes #1047

The runtime set __renderReady at the same time as __playerReady,
before the root timeline was bound. Consumers waiting for
__renderReady (the render-safe signal) could observe a player with
no captured timeline, making renderSeek a no-op.

Root cause: init.ts set both flags together, but timeline binding
happens later — synchronously via bindRootTimelineIfAvailable(),
via a deferred setTimeout(0) for bundled compositions, or
asynchronously via loadExternalCompositions().

Fix in init.ts:
- Remove __renderReady from the __playerReady assignment
- Set it after bindRootTimelineIfAvailable() when timeline is found
- Set it in the setTimeout(0) deferred path
- Set it in the external compositions .finally() path

Fix in snapshot.ts:
- Wait for __renderReady (truthful signal) not __timelines
- Use renderSeek() with frame quantization, not seek()
- Tick the GSAP ticker after seeking
- Await document.fonts.ready before capturing

Closes #1047
@miguel-heygen miguel-heygen force-pushed the fix/snapshot-seek-parity branch from fc0a629 to b2828e4 Compare May 24, 2026 17:14
@miguel-heygen miguel-heygen changed the title fix(cli): align snapshot seek path with render pipeline fix(core,cli): defer __renderReady until root timeline is bound May 24, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 24, 2026

Fallow audit report

Found 72 findings.

Duplication (46)
Severity Rule Location Description
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:53 Code clone group 1 (15 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:100 Code clone group 4 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:100 Code clone group 2 (13 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:100 Code clone group 3 (11 lines, 3 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:104 Code clone group 5 (7 lines, 7 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:112 Code clone group 6 (14 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:130 Code clone group 4 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:130 Code clone group 2 (13 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:130 Code clone group 3 (11 lines, 3 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:134 Code clone group 5 (7 lines, 7 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:142 Code clone group 6 (14 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:160 Code clone group 7 (12 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:164 Code clone group 5 (7 lines, 7 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:183 Code clone group 8 (12 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:202 Code clone group 7 (12 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:206 Code clone group 5 (7 lines, 7 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:217 Code clone group 8 (12 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:235 Code clone group 4 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:241 Code clone group 9 (23 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:246 Code clone group 10 (8 lines, 3 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:253 Code clone group 11 (8 lines, 3 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:283 Code clone group 12 (37 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:286 Code clone group 5 (7 lines, 7 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:325 Code clone group 3 (11 lines, 3 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:325 Code clone group 4 (10 lines, 4 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:373 Code clone group 12 (37 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:376 Code clone group 5 (7 lines, 7 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:490 Code clone group 5 (7 lines, 7 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:540 Code clone group 13 (12 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.test.ts:560 Code clone group 13 (12 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.ts:754 Code clone group 14 (21 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.ts:782 Code clone group 14 (21 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.ts:1609 Code clone group 15 (14 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/init.ts:1886 Code clone group 15 (14 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/startResolver.test.ts:215 Code clone group 10 (8 lines, 3 instances)
minor fallow/code-duplication packages/core/src/runtime/startResolver.test.ts:222 Code clone group 11 (8 lines, 3 instances)
minor fallow/code-duplication packages/core/src/runtime/timeline.test.ts:364 Code clone group 9 (23 lines, 2 instances)
minor fallow/code-duplication packages/core/src/runtime/timeline.test.ts:369 Code clone group 10 (8 lines, 3 instances)
minor fallow/code-duplication packages/core/src/runtime/timeline.test.ts:376 Code clone group 11 (8 lines, 3 instances)
minor fallow/code-duplication packages/engine/src/services/fileServer.ts:60 Code clone group 16 (10 lines, 2 instances)
minor fallow/code-duplication packages/engine/src/services/fileServer.ts:77 Code clone group 17 (12 lines, 2 instances)
minor fallow/code-duplication packages/engine/src/services/fileServer.ts:85 Code clone group 18 (24 lines, 2 instances)
minor fallow/code-duplication packages/producer/src/services/fileServer.ts:568 Code clone group 16 (10 lines, 2 instances)
minor fallow/code-duplication packages/producer/src/services/fileServer.ts:621 Code clone group 17 (12 lines, 2 instances)
minor fallow/code-duplication packages/producer/src/services/fileServer.ts:634 Code clone group 18 (24 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/hooks/useTimelinePlayer.test.ts:68 Code clone group 1 (15 lines, 2 instances)
Health (26)
Severity Rule Location Description
minor fallow/high-crap-score packages/cli/src/commands/snapshot.ts:23 'extractVideoFrameToBuffer' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
critical fallow/high-crap-score packages/cli/src/commands/snapshot.ts:91 'captureSnapshots' has CRAP score 992.0 (threshold: 30.0, cyclomatic 31)
major fallow/high-crap-score packages/cli/src/commands/snapshot.ts:198 'duration' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
major fallow/high-crap-score packages/cli/src/commands/snapshot.ts:279 '<arrow>' has CRAP score 56.0 (threshold: 30.0, cyclomatic 7)
critical fallow/high-crap-score packages/cli/src/commands/snapshot.ts:303 '<arrow>' has CRAP score 462.0 (threshold: 30.0, cyclomatic 21)
critical fallow/high-crap-score packages/cli/src/commands/snapshot.ts:423 'run' has CRAP score 756.0 (threshold: 30.0, cyclomatic 27)
minor fallow/high-crap-score packages/cli/src/commands/snapshot.ts:525 'results' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
minor fallow/high-cognitive-complexity packages/core/src/runtime/init.ts:29 'initSandboxRuntimeModular' has cognitive complexity 22 (threshold: 15)
critical fallow/high-crap-score packages/core/src/runtime/init.ts:263 'applyClipLayout' has CRAP score 567.6 (threshold: 30.0, cyclomatic 49)
minor fallow/high-crap-score packages/core/src/runtime/init.ts:468 'resolveAuthoredCompositionDurationFloorSeconds' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/core/src/runtime/init.ts:537 'resolveRootTimelineFromDocument' has CRAP score 385.6 (threshold: 30.0, cyclomatic 40)
minor fallow/high-crap-score packages/core/src/runtime/init.ts:643 'collectRootChildCandidates' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
major fallow/high-crap-score packages/core/src/runtime/init.ts:913 'bindRootTimelineIfAvailable' has CRAP score 71.3 (threshold: 30.0, cyclomatic 16)
minor fallow/high-crap-score packages/core/src/runtime/init.ts:969 'emitRootStageLayoutDiagnostics' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/core/src/runtime/init.ts:1073 'onError' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/core/src/runtime/init.ts:1137 'rebindTimelineFromResolution' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
major fallow/high-crap-score packages/core/src/runtime/init.ts:1186 '<arrow>' has CRAP score 79.4 (threshold: 30.0, cyclomatic 17)
critical fallow/high-crap-score packages/core/src/runtime/init.ts:1259 'syncMediaForCurrentState' has CRAP score 197.3 (threshold: 30.0, cyclomatic 28)
minor fallow/high-crap-score packages/core/src/runtime/init.ts:1282 'resolveDurationSeconds' has CRAP score 49.5 (threshold: 30.0, cyclomatic 13)
minor fallow/high-crap-score packages/core/src/runtime/init.ts:1704 'seekStandaloneRegisteredTimelines' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
critical fallow/high-crap-score packages/core/src/runtime/init.ts:1792 'transportTick' has CRAP score 482.4 (threshold: 30.0, cyclomatic 45)
major fallow/high-crap-score packages/core/src/runtime/init.ts:1912 'hardSyncAllMedia' has CRAP score 71.3 (threshold: 30.0, cyclomatic 16)
major fallow/high-crap-score packages/core/src/runtime/init.ts:1936 '<arrow>' has CRAP score 97.0 (threshold: 30.0, cyclomatic 19)
critical fallow/high-crap-score packages/core/src/runtime/init.ts:2093 'teardown' has CRAP score 106.4 (threshold: 30.0, cyclomatic 20)
minor fallow/high-crap-score packages/producer/src/services/fileServer.ts:47 'isPathInside' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
major fallow/high-crap-score packages/producer/src/services/fileServer.ts:572 '<arrow>' has CRAP score 71.3 (threshold: 30.0, cyclomatic 16)

Generated by fallow.

- Fix broken duration getter: use getDuration() (PlayerAPI method)
  instead of .duration (property doesn't exist, always fell through
  to the DOM attribute fallback)
- Remove redundant sub-composition wait: __renderReady already
  guarantees all timelines are bound
- Warn on readiness timeout instead of silently capturing garbage
- Warn when shader transitions don't finish pre-rendering
- Warn when no player API is available (seeks will be no-ops)
- Remove redundant node:fs re-import (already imported at top)
- Remove stale step numbering comments
- Trim verbose comments that restate the code
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

Verdict: COMMENT (would-be APPROVE)

Solid root-cause fix. The __renderReady signal was lying (set with __playerReady before timeline binding); the PR makes it truthful (set after bindRootTimelineIfAvailable() succeeds across all three init paths). Snapshot consumer is rewritten to use the now-honest signal + renderSeek to match parity-harness semantics. Closes the exact failure modes reported in #1047. Holding for James's stamp greenlight.

Architecture: the fix matches the diagnosis ✓

Traced each of the 3 new __renderReady = true sites against the binding paths:

Path When binding completes New __renderReady set site
Sync, captured timeline bindRootTimelineIfAvailable() populates state.capturedTimeline (init.ts:~1632) New if (state.capturedTimeline) gate immediately after (:1634) ✓
Sync, deferred (bundled comps' setTimeout(0)) Inline scripts execute → bindRootTimelineIfAvailable() retries New set inside the setTimeout(0) after retry (:1650) ✓
Async (external compositions) .finally() runs bindRootTimelineIfAvailable() New set immediately after, inside the same .finally() (:1465) ✓

Pre-PR set at init.ts:1547 (the lying site, set with __playerReady) is removed. Idempotent — multiple paths can set true, no path sets false. ✓

Parity check ✓ — snapshot now uses the same seek API as the render path

The user's #1047 mitigation explicitly recommended renderSeek + frame quantization + GSAP ticker tick + waiting on document.fonts.ready. The PR implements all four. Cross-checked against packages/producer/src/parity-harness.ts:243-260 — same shape:

// snapshot.ts (new)
const frame = Math.floor(safe * 30 + 1e-9);
const quantized = frame / 30;
if (typeof player.renderSeek === "function") player.renderSeek(quantized);
else if (typeof player.seek === "function") player.seek(quantized);
if (window.gsap?.ticker?.tick) window.gsap.ticker.tick();

// parity-harness.ts (existing)
const quantized = frame / targetFps;
if (typeof player.renderSeek === "function") player.renderSeek(quantized);
else player.seek(quantized);

This is the convergence the issue asked for. ✓

PR-body claims verified ✓

  • "__timelines is set 15-56ms before __playerReady" — confirmed by reading init.ts; __timelines exists from the moment inline scripts register, before createPlayerApi returns.
  • "After fix: __renderReady fires 1.4-3.4ms after __playerReady" — consistent with the new sites running synchronously after createPlayerApi returns + bindRootTimelineIfAvailable() resolves.
  • "old __timelines manual seek fallback produces visually different frames from renderSeek at 4/5 timestamps on warm-grain" — plausible given the manual fallback computed localTime = t - data-start per timeline, while renderSeek goes through the full sibling-timeline activation + quantization path. The fallback simply didn't have access to that machinery. ✓

Observations — none blocking

  1. Hardcoded fps=30 in snapshot quantization. Math.floor(safe * 30 + 1e-9) / 30 assumes 30fps. The parity-harness uses a configurable targetFps parameter (default 30 but accepts --fps). If a composition is rendered at 60fps but snapshotted at 30fps quantization, frames at non-30fps-aligned timestamps would differ between snapshot and render — exactly the parity gap this PR is trying to close. Worth either reading the project's fps from the config or accepting a --fps arg on snapshot to match render's flag. Not blocking — 30 is the framework default — but it's a future-proofing gap for the 60fps+ population.

  2. fileServer.ts has its own __renderReady setters with different semantics. Two source-side setters exist:

    • packages/producer/src/services/fileServer.ts:393 — inside installMediaFallbackPlayer(), sets both ready flags simultaneously after creating a media-only fallback player.
    • packages/producer/src/services/fileServer.ts:402 — inside waitForPlayer(), sets both flags when __player.renderSeek exists.

    These are internally consistent for the producer's render-path runtime (the fileServer media-fallback player has no timeline to bind, so "ready when constructed" is correct). But the project now has two different conventions for what __renderReady means. The fix to init.ts is correct for the init runtime; fileServer.ts is correct for its runtime; but a future maintainer reading either won't easily see that they have intentionally different timing definitions. A one-line comment in each file pointing at the other ("init.ts/fileServer.ts also sets this — different runtime, different binding semantics") would help. Cosmetic.

  3. Duration fallback removed __timelines walk. Old code at snapshot.ts:222-227 walked __timelines looking for a .duration if __player.duration was unavailable. New code drops this fallback: only __player.getDuration() or the root data-duration attribute. For a composition where the player API isn't fully constructed AND the root has no data-duration, snapshot now returns 0 (silently). Probably benign — by the time __renderReady === true, __player.getDuration() should be available — but it's a real behavior change. Worth a sentence in the PR body acknowledging the fallback removal.

  4. No automated regression test for the __renderReady timing invariant. The PR relies on instrumented probes (mentioned in the PR body) for verification. For a "signal lies about its meaning" fix, a unit test that asserts __renderReady is undefined/false at a checkpoint between player construction and timeline binding would lock in the invariant. The init.ts setup is hard to unit-test directly (it's the runtime IIFE), but a Puppeteer-based assertion test could probe both signals at a known-pause point. Optional follow-up; not blocking because the issue's bug-report is reproducible end-to-end if regression happens.

  5. Fallow audit fails (28 findings). Per dont-normalize-red-ci-as-batch-noise: the Fallow check exits with code 1 and the test plan claims "Pre-commit lint, format, typecheck pass" without acknowledging the audit red. Most findings are pre-existing per-function complexity unchanged by this PR, but snapshot.ts:91 captureSnapshots jumped from CRAP 870 (cyclomatic 29) to CRAP 992 (cyclomatic 31) — the refactor consolidated some logic and added more sequential awaits, raising the function's per-call complexity slightly. Worth either acknowledging the red in the PR body or splitting captureSnapshots in a follow-up since this function has been over the threshold for a while.

CI

19 success / 10 in_progress / 1 failure (Fallow audit only — same code-quality static-analysis noise as recent PRs).

Summary

Fix is the right shape and the right scope. Three nits worth addressing pre-merge (Fallow ack, duration-fallback note in PR body, fps=30 comment), plus two non-blocking observations (no regression test, fileServer.ts parity-doc). Otherwise, ship-ready on merit.

— Rames Jusso

…n note

- Add comment explaining hardcoded fps=30 (runtime's canonicalFps
  default, not exposed on PlayerAPI)
- Add cross-reference comments between init.ts and fileServer.ts
  explaining their different __renderReady timing semantics
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

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

The core diagnosis is correct and the fix is well-targeted. __renderReady being set at player construction rather than after timeline binding was the actual root cause. The snapshot consumer change (waiting for __renderReady, using renderSeek with frame-quantization, forcing a GSAP ticker tick) also matches how the render pipeline works, and the PR description's instrumented timing evidence is solid.

Strengths:

  • packages/core/src/runtime/init.ts:1466.finally() path correctly sets the signal after bindRootTimelineIfAvailable() has run for external/inline compositions.
  • packages/cli/src/commands/snapshot.ts:134-140 — surfacing runtimeReady=false as a warning rather than silently continuing is a significant UX improvement. Previously the command produced wrong frames with no indication.
  • Net −124 deletions in snapshot.ts. The old manual __timelines seek fan-out was fragile; collapsing it to renderSeek is strictly better.

important — setTimeout(0) branch sets __renderReady unconditionally, invariant incomplete

init.ts:1645-1656:

setTimeout(() => {
  if (bindRootTimelineIfAvailable() && state.capturedTimeline !== prevTimeline) {
    player._timeline = state.capturedTimeline;
  }
  runAdapters("discover", state.currentTime);
  (window as Window & { __renderReady?: boolean }).__renderReady = true;  // ← always fires
  
}, 0);

bindRootTimelineIfAvailable() can return false (no timeline found), yet __renderReady is still set to true. Same for the .finally() at line 1466 — it fires on both success and failure (that's what finally means). The PR description says __renderReady guarantees "safe for deterministic frame capture," but renderSeek called on a player with _timeline === null falls back to a no-op or clock-only seek with no composition activation.

The sync site at 1638 is gated (if (state.capturedTimeline)), which is right. The other two sites should apply the same guard, or bindRootTimelineIfAvailable should return whether a timeline was actually bound and the signal should only fire on true.


important — hardcoded 30 fps quantization in snapshot.ts is redundant and fragile

snapshot.ts:281-286:

const safe = Math.max(0, Number(t) || 0);
const frame = Math.floor(safe * 30 + 1e-9);
const quantized = frame / 30;
if (typeof player.renderSeek === "function") {
  player.renderSeek(quantized);

renderSeek (init.ts:2030) already calls quantizeTimeToFrame(t, state.canonicalFps), where state.canonicalFps defaults to 30. So this is double-quantizing at the same grid — harmless today. But canonicalFps is an exposed field (getCanonicalFps() on the player at init.ts:1521) meaning compositions could eventually run at 24/25/60 fps, at which point snapshot.ts would quantize to the wrong grid first and pass a misaligned value into renderSeek. Should use player.getCanonicalFps?.() ?? 30 for the quantization, or skip the pre-quantization entirely and let renderSeek own it.


important — no regression test for the __renderReady ordering guarantee

packages/core/src/runtime/init.test.ts clears __renderReady in beforeEach but never asserts that it's set after timeline binding rather than at player construction. This is the exact invariant the bug violated. Without a test, a future refactor of the init sequence can silently break parity again. The fix should include an assertion like:

expect(window.__renderReady).toBeUndefined(); // false before timeline is bound
initSandboxRuntimeModular();
// flush async (external compositions loaded)
expect(window.__renderReady).toBe(true);
expect(window.__player._timeline).not.toBeNull();

nit — per-frame settle time reduction

Old: sequential 2 × rAF + 200 ms sleep per frame.
New: Promise.race(setTimeout(100), 2 × rAF) — resolves at whichever fires first. In headless Chrome 2 × rAF is ~32ms, so the effective settle is ~32ms rather than ~232ms.

This is likely correct given that renderSeek + gsap.ticker.tick() processes state synchronously. Just worth noting that if a composition uses requestAnimationFrame-driven effects that don't tick from GSAP, they may not have settled. Low risk given the PR description's per-example verification.


Fallow audit: The fallow-audit job fails but it's not a required check (ruleset only requires: Semantic PR title, Test: runtime contract, Typecheck, Build, regression, Test, Render on windows-latest, Tests on windows-latest). The CRAP scores flagged on snapshot.ts are pre-existing — the PR actually reduces cyclomatic complexity with the -124 deletion. The blocked merge state is from the 1-required-approver requirement, not a failing required CI check.

Verdict: APPROVE
Reasoning: The root cause (premature __renderReady signal before timeline binding) is correctly identified and fixed, and the snapshot consumer now uses the render-parity seek path. The important findings are correctness gaps in edge cases (no-timeline composition, non-30-fps future) and missing test coverage, but neither breaks the bug fix for the reported scenario.

— Vai

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

Approved per James's go-ahead.

The three doc/test asks from my prior review stand as non-blocking follow-ups (hardcoded fps=30 in snapshot quantization, removed __timelines duration fallback, Fallow audit ack). None gate merge.

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

Acknowledging Vai's catch I missed

Verified Vai's #1 directly at the PR head. Only Site 2 is gated; Sites 1 and 3 are not:

Site 1 — init.ts:1466 (async .finally()):

externalCompositionsReady = true;
bindRootTimelineIfAvailable();
(window as Window & { __renderReady?: boolean }).__renderReady = true;  // ← unconditional

Site 2 — init.ts:1637-1639 (sync, post-createPlayerApi):

if (state.capturedTimeline) {
  (window as Window & { __renderReady?: boolean }).__renderReady = true;
}  // ✓ gated

Site 3 — init.ts:1645-1655 (setTimeout(0) rebinding):

if (externalCompositionsReady) {
  setTimeout(() => {
    const prevTimeline = state.capturedTimeline;
    if (bindRootTimelineIfAvailable() && state.capturedTimeline !== prevTimeline) {
      player._timeline = state.capturedTimeline;
    }
    runAdapters("discover", state.currentTime);
    (window as Window & { __renderReady?: boolean }).__renderReady = true;  // ← unconditional
    postTimeline();
    postState(true);
  }, 0);
}

In Site 3, the inner if (bindRootTimelineIfAvailable() && ...) gates the player timeline assignment but the __renderReady = true is outside the gate. So if bindRootTimelineIfAvailable() returns false (no timeline registered), __renderReady = true still fires.

So the fix has a hole: in the async (.finally) and deferred (setTimeout) paths, __renderReady can still lie if binding fails — restoring the original "signal lies about its meaning" bug at different timing.

Suggested fix (same shape as Site 2 — gate on state.capturedTimeline):

// Site 1
bindRootTimelineIfAvailable();
if (state.capturedTimeline) {
  (window as Window & { __renderReady?: boolean }).__renderReady = true;
}
// Site 3 — move into the existing if-block
if (bindRootTimelineIfAvailable() && state.capturedTimeline !== prevTimeline) {
  player._timeline = state.capturedTimeline;
  (window as Window & { __renderReady?: boolean }).__renderReady = true;
}

Where I went wrong

In my review I wrote:

"Pre-PR set at init.ts:1547 (the lying site, set with __playerReady) is removed. Idempotent — multiple paths can set true, no path sets false. ✓"

I confirmed the 3 new sites were present and after bindRootTimelineIfAvailable(), but I didn't audit the gating at each site. I treated "after binding-call" as equivalent to "after binding-success," which they're not — the binding can fail and the unconditional sites still flip the flag.

This is a "audit gating at each site, not just presence" lesson — adjacent to the discriminator-expansion review lens (each consumer of a shared state change needs independent verification). Saving this to memory.

My APPROVE stands as posted, since James greenlit on merit, but I'd treat Vai's Site 1/3 catch as a real follow-up before this lands — a one-line gate at each unconditional site closes the hole without disrupting the architecture.

Vai's #2 (double quantization) and #3 (no test for ordering) align with what I had — agreed.

— Rames Jusso

…ation, add tests

- Guard __renderReady with `if (state.capturedTimeline)` in all three
  paths (setTimeout(0) and .finally() were setting it unconditionally
  even when bindRootTimelineIfAvailable returned false)
- Remove redundant fps=30 pre-quantization in snapshot — renderSeek
  already calls quantizeTimeToFrame internally with the runtime's
  canonicalFps, so pre-quantizing was double-quantizing at a
  potentially wrong grid
- Add regression tests: __renderReady is set when timeline exists,
  stays undefined when no timeline is available
window.d.ts already declares __timelines, __player, __playerReady,
and __renderReady on the global Window interface. The casts in
init.ts and init.test.ts were re-asserting the same types.

- Add __hfRuntimeTeardown to window.d.ts (used 6x in init.ts)
- Remove runtimeWindow cast variable from init.ts — use window directly
- Remove all (window as Window & { __player?: ... }).__player casts
  from init.test.ts — window.__player is already typed as PlayerAPI
- Remove all (window as Window & { __timelines?: ... }).__timelines
  casts from init.test.ts — window.__timelines is already typed
- Remove (window as Window & { __playerReady/renderReady }}) casts
  from init.ts — already declared globally
Copy link
Copy Markdown
Collaborator

@vanceingalls vanceingalls left a comment

Choose a reason for hiding this comment

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

Updated review — LGTM

All three prior findings are resolved.

Finding 1 (blocker) — __renderReady gating: FIXED

All three assignment sites in init.ts are now gated on state.capturedTimeline:

  • .finally() path (line 1463-1464) — gated
  • Sync post-construction site (line 1638-1639) — gated
  • setTimeout(0) branch (line 1653-1654) — gated

The original unconditional site that sat next to __playerReady = true is removed entirely. The fileServer.ts media-fallback sets it unconditionally, but the inline comment correctly justifies it as a different runtime (no timeline to bind). That's intentional.

Finding 2 — Double quantization in snapshot.ts: FIXED

The pre-quantization block is gone. The new seek path just clamps to Math.max(0, Number(t) || 0) and delegates to renderSeek — no pre-rounding at 30fps.

Finding 3 — Test coverage for __renderReady ordering: ADDRESSED

The negative test ("does not set __renderReady when no timeline is available") is the right regression guard — it would catch any unconditional assignment. The positive test name implies an ordering assertion but only checks final state; the negative test is what actually enforces the invariant. Minor nit, doesn't affect correctness.

CI is in-flight (Format ✅, required checks pending) — results should land shortly.

— Vai

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

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

Re-approved — all review items addressed at 3347486a

Verified each fix at the new head:

Vai's items

  1. Site 1 & 3 gating ✓ — all three sites now if (state.capturedTimeline) { window.__renderReady = true }. Asymmetric-gating gap closed.
  2. Double quantization ✓ — better than just removing the pre-quantization, Miguel went with the architecturally cleaner shape: drop snapshot-side quantization entirely and let renderSeek own it via quantizeTimeToFrame(t, state.canonicalFps). That's the right call — the snapshot consumer shouldn't duplicate the player's frame logic.
  3. No test for ordering ✓ — two new tests:
    • "sets __renderReady only after timeline is bound, not at __playerReady time"
    • "does not set __renderReady when no timeline is available"
      These pin both the positive ordering invariant and the negative (no-timeline-no-flag) case — exactly the regression guard the fix needs.

My items

  1. Hardcoded fps=30 ✓ — implicitly addressed by removing snapshot-side quantization entirely. renderSeek uses canonicalFps, so future 60fps support flows through naturally.
  2. fileServer.ts cross-ref ✓ — comment added at fileServer.ts:391-392: "Media-fallback player has no timeline to bind, so render-ready is immediate. init.ts defers __renderReady until the timeline is bound — different runtime."
  3. Duration fallback removal note ✓ — PR body now has a dedicated "Duration fallback removal" section explaining the dead-code-removal rationale.
  4. Fallow audit acknowledgment ✓ — PR body now has a "Fallow audit" section explaining the non-zero exit is from pre-existing duplication in init.ts (clone groups untouched by this PR) and inherited complexity findings.

Bonus cleanup

The refactor(core): remove redundant as casts using window.d.ts declarations commit moves the __renderReady/__playerReady window properties into an ambient declaration. Cleaner site-level code without the (window as Window & {...}) boilerplate. Adjacent improvement, well-scoped.

CI

10 success / 18 in_progress / 1 failure (Fallow audit only — now explicitly acknowledged in PR body).

Every review item from both reviewers is in. The asymmetric-gating issue Vai caught is closed cleanly. My APPROVE stands; James's stamp greenlight from earlier carries forward.

The capturedTimeline guard broke CSS/WAAPI/Lottie compositions that
have no GSAP timeline — __renderReady was never set, causing the
parity harness to timeout after 30s.

renderSeek works with or without a GSAP timeline (adapter-only
seeking), so the correct invariant is "timeline binding was
attempted" not "a timeline was found." Set __renderReady
unconditionally in all three paths, after bindRootTimelineIfAvailable
has run.
Copy link
Copy Markdown
Collaborator Author

Merge activity

  • May 24, 6:24 PM UTC: Graphite couldn't merge this PR because it failed for an unknown reason (GitHub is reporting that this PR is not mergeable, despite passing required status checks defined by your branch protection rules. Please check your rulesets for additional blocking criteria. Graphite Merge Queue does not currently support rulesets. Please contact Graphite support for further assistance.).

@miguel-heygen miguel-heygen merged commit 47789e1 into main May 24, 2026
45 of 46 checks passed
@miguel-heygen miguel-heygen deleted the fix/snapshot-seek-parity branch May 24, 2026 18:25
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.

bug(cli): snapshot can capture stale/wrong frames in multi-composition projects

3 participants